Skip to content

Commit 5100409

Browse files
authored
Merge pull request #108 from chrivers/chrivers/api-v1-hs-color-mode
Implement support for converting Hue/Saturation ("HS") light updates to modern XY-based values. This enables support for receiving Hue/Sat light updates over API V1, specifically for `ApiLightStateUpdate` (`PUT` requests for controlling lights). When such a request is received, it is converted to XY mode, and handled from there.
2 parents 01e0171 + abc20ec commit 5100409

File tree

7 files changed

+177
-2
lines changed

7 files changed

+177
-2
lines changed

crates/hue/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ uuid = { version = "1.13.1", features = ["serde", "v5"] }
3030

3131
[dev-dependencies]
3232
hex = "0.4.3"
33+
uuid = { version = "1.13.1", features = ["v4"] }

crates/hue/src/api/light.rs

+10-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
55
use serde_json::{json, Value};
66

77
use crate::api::{DeviceArchetype, Identify, Metadata, MetadataUpdate, ResourceLink, Stub};
8+
use crate::hs::HS;
89
use crate::xy::XY;
910

1011
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -613,7 +614,15 @@ impl LightUpdate {
613614
#[must_use]
614615
pub fn with_color_xy(self, xy: impl Into<Option<XY>>) -> Self {
615616
Self {
616-
color: xy.into().map(ColorUpdate::new),
617+
color: self.color.or_else(|| xy.into().map(ColorUpdate::new)),
618+
..self
619+
}
620+
}
621+
622+
#[must_use]
623+
pub fn with_color_hs(self, hs: impl Into<Option<HS>>) -> Self {
624+
Self {
625+
color: hs.into().map(|hs| XY::from_hs(hs).0).map(ColorUpdate::new),
617626
..self
618627
}
619628
}

crates/hue/src/hs.rs

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Copy, Debug, Serialize, Deserialize, Clone)]
4+
pub struct HS {
5+
pub hue: f64,
6+
pub sat: f64,
7+
}
8+
9+
#[derive(Copy, Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
10+
pub struct RawHS {
11+
pub hue: u16,
12+
pub sat: u8,
13+
}
14+
15+
impl From<RawHS> for HS {
16+
fn from(raw: RawHS) -> Self {
17+
Self {
18+
hue: f64::from(raw.hue) / f64::from(0xFFFF),
19+
sat: f64::from(raw.sat) / f64::from(0xFF),
20+
}
21+
}
22+
}
23+
24+
#[cfg(test)]
25+
mod tests {
26+
use crate::hs::{RawHS, HS};
27+
28+
macro_rules! compare {
29+
($expr:expr, $value:expr) => {
30+
let a = $expr;
31+
let b = $value;
32+
eprintln!("{a} vs {b:.4}");
33+
assert!((a - b).abs() < 1e-4);
34+
};
35+
}
36+
37+
macro_rules! compare_hs {
38+
($a:expr, $b:expr) => {{
39+
compare!($a.hue, $b.hue);
40+
compare!($a.sat, $b.sat);
41+
}};
42+
}
43+
44+
#[test]
45+
fn from_rawhs_min() {
46+
compare_hs!(
47+
HS::from(RawHS { hue: 0, sat: 0 }),
48+
HS { hue: 0.0, sat: 0.0 }
49+
);
50+
}
51+
52+
#[test]
53+
fn from_rawhs_mid() {
54+
compare_hs!(
55+
HS::from(RawHS {
56+
hue: 0xCCCC,
57+
sat: 0xCC
58+
}),
59+
HS { hue: 0.8, sat: 0.8 }
60+
);
61+
}
62+
63+
#[test]
64+
fn from_rawhs_max() {
65+
compare_hs!(
66+
HS::from(RawHS {
67+
hue: 0xFFFF,
68+
sat: 0xFF
69+
}),
70+
HS { hue: 1.0, sat: 1.0 }
71+
);
72+
}
73+
}

crates/hue/src/legacy_api.rs

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use uuid::Uuid;
88

99
use crate::api::{ColorGamut, DeviceProductData};
1010
use crate::date_format;
11+
use crate::hs::RawHS;
1112
use crate::version::SwVersion;
1213
use crate::{api, best_guess_timezone};
1314

@@ -489,6 +490,8 @@ pub struct ApiLightStateUpdate {
489490
pub xy: Option<[f64; 2]>,
490491
#[serde(skip_serializing_if = "Option::is_none")]
491492
pub ct: Option<u16>,
493+
#[serde(skip_serializing_if = "Option::is_none", flatten)]
494+
pub hs: Option<RawHS>,
492495
}
493496

494497
#[derive(Debug, Serialize, Deserialize)]
@@ -521,6 +524,7 @@ impl From<api::SceneAction> for ApiLightStateUpdate {
521524
bri: action.dimming.map(|dim| (dim.brightness * 2.54) as u32),
522525
xy: action.color.map(|col| col.xy.into()),
523526
ct: action.color_temperature.map(|ct| ct.mirek),
527+
hs: None,
524528
}
525529
}
526530
}

crates/hue/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub mod error;
99
pub mod event;
1010
pub mod flags;
1111
pub mod gamma;
12+
pub mod hs;
1213
pub mod legacy_api;
1314
pub mod scene_icons;
1415
pub mod stream;

crates/hue/src/xy.rs

+87-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
22

33
use crate::clamp::Clamp;
44
use crate::colorspace::{self, ColorSpace};
5+
use crate::hs::HS;
56
use crate::{WIDE_GAMUT_MAX_X, WIDE_GAMUT_MAX_Y};
67

78
#[derive(Copy, Debug, Serialize, Deserialize, Clone, PartialEq)]
@@ -23,18 +24,56 @@ impl XY {
2324
y: 0.32902,
2425
};
2526

26-
#[allow(clippy::many_single_char_names)]
2727
#[must_use]
2828
pub fn from_rgb(red: u8, green: u8, blue: u8) -> (Self, f64) {
2929
let [r, g, b] = [red, green, blue].map(Clamp::unit_from_u8);
30+
Self::from_rgb_unit(r, g, b)
31+
}
3032

33+
#[allow(clippy::many_single_char_names)]
34+
#[must_use]
35+
pub fn from_rgb_unit(r: f64, g: f64, b: f64) -> (Self, f64) {
3136
let [x, y, b] = Self::COLOR_SPACE.rgb_to_xyy(r, g, b);
3237

3338
let max_y = Self::COLOR_SPACE.find_maximum_y(x, y);
3439

3540
(Self { x, y }, b / max_y * 255.0)
3641
}
3742

43+
#[must_use]
44+
pub fn from_hs(hs: HS) -> (Self, f64) {
45+
let lightness: f64 = 0.5;
46+
Self::from_hsl(hs, lightness)
47+
}
48+
49+
#[must_use]
50+
pub fn from_hsl(hs: HS, lightness: f64) -> (Self, f64) {
51+
let [r, g, b] = Self::rgb_from_hsl(hs, lightness);
52+
Self::from_rgb_unit(r, g, b)
53+
}
54+
55+
#[must_use]
56+
pub fn rgb_from_hsl(hs: HS, lightness: f64) -> [f64; 3] {
57+
let c = (1.0 - (2.0f64.mul_add(lightness, -1.0)).abs()) * hs.sat;
58+
let h = hs.hue * 6.0;
59+
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
60+
let m = lightness - c / 2.0;
61+
62+
if h < 1.0 {
63+
[m + c, m + x, m]
64+
} else if h < 2.0 {
65+
[m + x, m + c, m]
66+
} else if h < 3.0 {
67+
[m, m + c, m + x]
68+
} else if h < 4.0 {
69+
[m, m + x, m + c]
70+
} else if h < 5.0 {
71+
[m + x, m, m + c]
72+
} else {
73+
[m + c, m + 0.0, m + x]
74+
}
75+
}
76+
3877
#[must_use]
3978
pub fn to_rgb(&self, brightness: f64) -> [u8; 3] {
4079
Self::COLOR_SPACE
@@ -85,3 +124,50 @@ impl From<XY> for [f64; 2] {
85124
[value.x, value.y]
86125
}
87126
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use crate::hs::HS;
131+
use crate::xy::XY;
132+
133+
macro_rules! compare {
134+
($expr:expr, $value:expr) => {
135+
let a = $expr;
136+
let b = $value;
137+
eprintln!("{a} vs {b:.4}");
138+
assert!((a - b).abs() < 1e-4);
139+
};
140+
}
141+
142+
macro_rules! compare_rgb {
143+
($a:expr, $b:expr) => {{
144+
eprintln!("Comparing r");
145+
compare!($a[0], $b[0]);
146+
eprintln!("Comparing g");
147+
compare!($a[1], $b[1]);
148+
eprintln!("Comparing b");
149+
compare!($a[2], $b[2]);
150+
}};
151+
}
152+
153+
macro_rules! compare_hsl_rgb {
154+
($h:expr, $s:expr, $rgb:expr) => {{
155+
let sat = $s;
156+
compare_rgb!(XY::rgb_from_hsl(HS { hue: $h, sat }, 0.5), $rgb);
157+
}};
158+
}
159+
160+
#[test]
161+
fn rgb_from_hsl() {
162+
const ONE: f64 = 1.0;
163+
let sat = 1.0;
164+
165+
compare_hsl_rgb!(0.0 / 3.0, sat, [ONE, 0.0, 0.0]); // red
166+
compare_hsl_rgb!(0.5 / 3.0, sat, [ONE, ONE, 0.0]); // red-green
167+
compare_hsl_rgb!(1.0 / 3.0, sat, [0.0, ONE, 0.0]); // green
168+
compare_hsl_rgb!(1.5 / 3.0, sat, [0.0, ONE, ONE]); // green-blue
169+
compare_hsl_rgb!(2.0 / 3.0, sat, [0.0, 0.0, ONE]); // blue
170+
compare_hsl_rgb!(2.5 / 3.0, sat, [ONE, 0.0, ONE]); // blue-red
171+
compare_hsl_rgb!(3.0 / 3.0, sat, [ONE, 0.0, 0.0]); // red (wrapped around)
172+
}
173+
}

src/routes/api.rs

+1
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ async fn put_api_user_resource_id_path(
317317
.with_on(updv1.on.map(On::new))
318318
.with_brightness(updv1.bri)
319319
.with_color_temperature(updv1.ct)
320+
.with_color_hs(updv1.hs.map(Into::into))
320321
.with_color_xy(updv1.xy.map(Into::into));
321322

322323
lock.backend_request(BackendRequest::LightUpdate(link, upd))?;

0 commit comments

Comments
 (0)