openlogi_core/device/
light.rs1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum LightValueUnit {
10 Percent,
12 Lumens,
14 Kelvin,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
20pub struct LightValueRange {
21 min: u16,
23 max: u16,
25 step: u16,
27 unit: LightValueUnit,
29}
30
31impl LightValueRange {
32 pub const fn new(
39 min: u16,
40 max: u16,
41 step: u16,
42 unit: LightValueUnit,
43 ) -> Result<Self, LightValueRangeError> {
44 if min > max {
45 return Err(LightValueRangeError::Reversed { min, max });
46 }
47 if step == 0 {
48 return Err(LightValueRangeError::ZeroStep);
49 }
50 if !(max - min).is_multiple_of(step) {
51 return Err(LightValueRangeError::Unaligned { min, max, step });
52 }
53 if matches!(unit, LightValueUnit::Percent) && max > 100 {
54 return Err(LightValueRangeError::PercentOutOfBounds { min, max });
55 }
56 Ok(Self {
57 min,
58 max,
59 step,
60 unit,
61 })
62 }
63
64 #[must_use]
66 pub const fn min(self) -> u16 {
67 self.min
68 }
69
70 #[must_use]
72 pub const fn max(self) -> u16 {
73 self.max
74 }
75
76 #[must_use]
78 pub const fn step(self) -> u16 {
79 self.step
80 }
81
82 #[must_use]
84 pub const fn unit(self) -> LightValueUnit {
85 self.unit
86 }
87
88 #[must_use]
90 pub fn contains(self, value: u16) -> bool {
91 value >= self.min
92 && value <= self.max
93 && self.step != 0
94 && (value - self.min).is_multiple_of(self.step)
95 }
96
97 #[must_use]
99 pub fn quantize(self, value: u16) -> u16 {
100 let clamped = value.clamp(self.min, self.max);
101 let offset = clamped - self.min;
102 let lower = offset / self.step;
103 let remainder = offset % self.step;
104 let index = if remainder.saturating_mul(2) >= self.step {
105 lower.saturating_add(1)
106 } else {
107 lower
108 };
109 self.min
110 .saturating_add(index.saturating_mul(self.step))
111 .min(self.max)
112 }
113
114 #[must_use]
116 pub fn native_for_percent(self, percent: u8) -> Option<u16> {
117 if percent > 100 {
118 return None;
119 }
120 let span = u32::from(self.max) - u32::from(self.min);
121 let raw = u32::from(self.min) + (span * u32::from(percent) + 50) / 100;
122 u16::try_from(raw).ok().map(|value| self.quantize(value))
123 }
124
125 #[must_use]
127 pub fn percent_for_native(self, value: u16) -> Option<u8> {
128 if !self.contains(value) {
129 return None;
130 }
131 let span = u32::from(self.max) - u32::from(self.min);
132 if span == 0 {
133 return Some(0);
134 }
135 u8::try_from(((u32::from(value) - u32::from(self.min)) * 100 + span / 2) / span).ok()
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
141pub enum LightValueRangeError {
142 #[error("light range minimum {min} is greater than maximum {max}")]
144 Reversed {
145 min: u16,
147 max: u16,
149 },
150 #[error("light range step must be non-zero")]
152 ZeroStep,
153 #[error("light range {min}..={max} is not aligned to step {step}")]
155 Unaligned {
156 min: u16,
158 max: u16,
160 step: u16,
162 },
163 #[error("percentage light range {min}..={max} exceeds 0..=100")]
165 PercentOutOfBounds {
166 min: u16,
168 max: u16,
170 },
171}
172
173#[derive(Deserialize)]
174struct RawLightValueRange {
175 min: u16,
176 max: u16,
177 step: u16,
178 unit: LightValueUnit,
179}
180
181impl<'de> Deserialize<'de> for LightValueRange {
182 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
183 where
184 D: serde::Deserializer<'de>,
185 {
186 let raw = RawLightValueRange::deserialize(deserializer)?;
187 Self::new(raw.min, raw.max, raw.step, raw.unit).map_err(serde::de::Error::custom)
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
196#[allow(
197 clippy::struct_excessive_bools,
198 reason = "independent optional light controls are a serialized capability DTO"
199)]
200pub struct LightCapabilities {
201 pub power: bool,
203 pub brightness: Option<LightValueRange>,
205 pub temperature: Option<LightValueRange>,
207 #[serde(default)]
209 pub color: bool,
210 #[serde(default)]
212 pub zones: bool,
213}