Skip to main content

openlogi_core/device/
light.rs

1//! Capability types shared by standalone light drivers and their clients.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// The native unit used by a standalone light control range.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum LightValueUnit {
10    /// A percentage of the device's supported range.
11    Percent,
12    /// Absolute luminous output, where the protocol exposes lumens.
13    Lumens,
14    /// Colour temperature in Kelvin.
15    Kelvin,
16}
17
18/// A validated light value range advertised by a device driver.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
20pub struct LightValueRange {
21    /// Inclusive lower bound in [`Self::unit`].
22    min: u16,
23    /// Inclusive upper bound in [`Self::unit`].
24    max: u16,
25    /// Supported increment. Must be non-zero.
26    step: u16,
27    /// The unit represented by `min`, `max`, and `step`.
28    unit: LightValueUnit,
29}
30
31impl LightValueRange {
32    /// Construct a range after validating its bounds and quantization grid.
33    ///
34    /// The upper bound must lie on the same grid as the lower bound. This
35    /// keeps driver quantization total: a valid range can never produce a
36    /// value outside the advertised interval or between device-supported
37    /// stops.
38    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    /// Inclusive lower bound in the advertised unit.
65    #[must_use]
66    pub const fn min(self) -> u16 {
67        self.min
68    }
69
70    /// Inclusive upper bound in the advertised unit.
71    #[must_use]
72    pub const fn max(self) -> u16 {
73        self.max
74    }
75
76    /// Supported increment.
77    #[must_use]
78    pub const fn step(self) -> u16 {
79        self.step
80    }
81
82    /// Unit represented by this range.
83    #[must_use]
84    pub const fn unit(self) -> LightValueUnit {
85        self.unit
86    }
87
88    /// Whether `value` is representable without clamping or quantization.
89    #[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    /// Snap `value` to the nearest supported point inside this range.
98    #[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    /// Map normalized brightness to the nearest native value in this range.
115    #[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    /// Convert a supported native value to normalized brightness.
126    #[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/// Validation failure for [`LightValueRange`].
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
141pub enum LightValueRangeError {
142    /// The lower bound is greater than the upper bound.
143    #[error("light range minimum {min} is greater than maximum {max}")]
144    Reversed {
145        /// Rejected lower bound.
146        min: u16,
147        /// Rejected upper bound.
148        max: u16,
149    },
150    /// A range cannot have a zero increment.
151    #[error("light range step must be non-zero")]
152    ZeroStep,
153    /// The upper bound is not reachable from the lower bound using `step`.
154    #[error("light range {min}..={max} is not aligned to step {step}")]
155    Unaligned {
156        /// Lower bound of the invalid range.
157        min: u16,
158        /// Upper bound of the invalid range.
159        max: u16,
160        /// Increment that does not reach the upper bound.
161        step: u16,
162    },
163    /// Percentage ranges must stay within 0–100.
164    #[error("percentage light range {min}..={max} exceeds 0..=100")]
165    PercentOutOfBounds {
166        /// Rejected lower bound.
167        min: u16,
168        /// Rejected upper bound.
169        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/// Controls a standalone light driver can implement.
192///
193/// Optional ranges are the source of truth for UI controls. A driver must not
194/// advertise a control merely because the product is classified as a light.
195#[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    /// Whether the driver can switch the light on and off.
202    pub power: bool,
203    /// Supported brightness range, if brightness is controllable.
204    pub brightness: Option<LightValueRange>,
205    /// Supported colour-temperature range, if temperature is controllable.
206    pub temperature: Option<LightValueRange>,
207    /// Whether the driver can set a colour.
208    #[serde(default)]
209    pub color: bool,
210    /// Whether the driver exposes independently-addressable zones.
211    #[serde(default)]
212    pub zones: bool,
213}