Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions drivers/drmem-drv-hue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ The driver uses a **polling-based** architecture rather than event streaming:

## Supported Device Types

- **Switch** - On/off control with indicator
- **Dimmer/Bulb** - On/off and brightness control (0-100%) with indicator
- **ColorBulb** - Full RGB color control and brightness
- **Switch** - On/off control
- **Dimmer/Bulb** - Brightness control (0-100%)
- **ColorBulb** - Full color control, with brightness conveyed by the color's alpha channel
- **Group** - Controls Hue groups/rooms (uses ColorBulb interface)

## Configuration
Expand Down Expand Up @@ -93,9 +93,9 @@ You need to create a Hue application key before using this driver. Follow the of

### State Updates

- Brightness values are clamped to 0-100 range
- A brightness of 0 turns the device off
- For color bulbs, colors are converted between RGB and CIE XY color space
- Dimmer/Bulb brightness values are clamped to 0-100 range; a brightness of 0 turns the device off
- For color bulbs, brightness is conveyed by the color's alpha channel: 0 is off, 255 is 100%
- For color bulbs, colors are converted between RGB (or color temperature) and CIE XY color space
- Missing brightness values when a device is "on" default to 100%

### Override Timeout
Expand All @@ -120,7 +120,7 @@ This allows temporary manual control without permanently losing DrMem's automati
## Color Conversion

For ColorBulb devices, the driver automatically converts between:
- RGB (0-255 per channel) used by DrMem
- CIE XY color space used by Hue devices
- RGB (0-255 per channel), or a Kelvin color temperature, used by DrMem
- CIE XY color space, plus separate on/off and brightness settings, used by Hue devices

This conversion uses the `palette` crate for accurate color representation.
A ColorBulb's single `color` device conveys all three bridge parameters: the RGB (or Kelvin) value maps to the bridge's on/off and XY settings, while the color's alpha channel (0-255) maps to the bridge's brightness (0-100%). This conversion uses the `palette` crate for accurate color representation.
27 changes: 15 additions & 12 deletions drivers/drmem-drv-hue/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use super::{config, driver::device_traits};
use drmem_api::{
Result,
device::Path,
driver::{Registrator, Reporter, ResettableState, classes},
driver::{OverrideConfig, Registrator, Reporter, ResettableState, classes},
};
use std::{collections::HashMap, sync::Arc};
use tokio::time::Duration;
Expand All @@ -21,39 +21,42 @@ impl<R: Reporter> Set<R> {
cfg: &config::DeviceConfig,
max_history: Option<usize>,
) -> Result<(Arc<str>, DeviceWrapper<R>)> {
let tmo = cfg.override_timeout.map(|v| Duration::from_secs(v));
let override_cfg = OverrideConfig {
override_duration: cfg.override_timeout.map(Duration::from_secs),
envelope: Some(Duration::from_secs(30)),
};

Ok((
cfg.id.clone(),
match cfg.r#type {
config::DevCfgType::Switch => {
DeviceWrapper::Switch(device_traits::SwitchDevice {
inner: classes::Switch::register_devices(
DeviceWrapper::Switch(device_traits::SwitchDevice(
classes::Switch::register_devices(
drc,
Some(cfg.subpath.as_ref()),
&tmo,
&override_cfg,
max_history,
)
.await?,
})
))
}
config::DevCfgType::Dimmer | config::DevCfgType::Bulb => {
DeviceWrapper::Dimmer(device_traits::DimmerDevice {
inner: classes::Dimmer::register_devices(
DeviceWrapper::Dimmer(
device_traits::DimmerDevice::register_devices(
drc,
Some(cfg.subpath.as_ref()),
&tmo,
&override_cfg,
max_history,
)
.await?,
})
)
}
config::DevCfgType::ColorBulb => DeviceWrapper::ColorBulb(
device_traits::ColorBulbDevice::new(
classes::ColorBulb::register_devices(
drc,
Some(cfg.subpath.as_ref()),
&tmo,
&override_cfg,
max_history,
)
.await?,
Expand All @@ -65,7 +68,7 @@ impl<R: Reporter> Set<R> {
classes::ColorBulb::register_devices(
drc,
Some(cfg.subpath.as_ref()),
&tmo,
&override_cfg,
max_history,
)
.await?,
Expand Down
231 changes: 231 additions & 0 deletions drivers/drmem-drv-hue/src/driver/color.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/// Color conversion utilities for Hue CIE XY color space
use drmem_api::device::ColorType;
use palette::{IntoColor, LinSrgb, LinSrgba, Yxy};

/// Convert Hue CIE XY coordinates to RGBA
Expand All @@ -25,6 +26,109 @@ pub fn rgba_to_cie_xy(rgba: &LinSrgba<u8>) -> (f32, f32) {
(yxy.x, yxy.y)
}

/// Approximates a color temperature (in Kelvin) as CIE xy coordinates
/// using the Kim et al. polynomial fit to the Planckian locus. Valid
/// over the range of colors Hue bulbs can produce (roughly
/// 1667K-25000K).
pub fn kelvin_to_cie_xy(kelvin: u16) -> (f32, f32) {
let t = kelvin as f64;
let (t2, t3) = (t * t, t * t * t);

let x = if t <= 4000.0 {
-0.2661239e9 / t3 - 0.2343589e6 / t2 + 0.8776956e3 / t + 0.179910
} else {
-3.0258469e9 / t3 + 2.1070379e6 / t2 + 0.2226347e3 / t + 0.24039
};
let (x2, x3) = (x * x, x * x * x);

let y = if t <= 2222.0 {
-1.1063814 * x3 - 1.34811020 * x2 + 2.18555832 * x - 0.20219683
} else if t <= 4000.0 {
-0.9549476 * x3 - 1.37418593 * x2 + 2.09137015 * x - 0.16748867
} else {
3.0817580 * x3 - 5.87338670 * x2 + 3.75112997 * x - 0.37001483
};

(x as f32, y as f32)
}

/// The three parameters the Hue bridge needs to represent a light's
/// setting: whether it's on, its brightness (0.0-100.0), and its
/// chromaticity.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BridgeState {
pub on: bool,
pub brightness: f32,
pub xy: (f32, f32),
}

/// Breaks a `ColorType` down into the on/off, brightness, and XY
/// coordinates the Hue bridge needs. Brightness is conveyed by the
/// color's alpha channel: 0 is off and 255 is full brightness.
pub fn color_to_bridge(color: &ColorType) -> BridgeState {
let (alpha, xy) = match color {
ColorType::Rgba { color } => (color.alpha, rgba_to_cie_xy(color)),
ColorType::Ccta { kelvin, a } => (*a, kelvin_to_cie_xy(*kelvin)),
};

BridgeState {
on: alpha > 0,
brightness: alpha as f32 / 255.0 * 100.0,
xy,
}
}

/// Merges the (possibly partial) on/off, brightness, and XY state
/// reported by the bridge into a new `ColorType`, using `prev` to fill
/// in any component that wasn't reported. The bridge only ever echoes
/// XY coordinates, so a reported `xy` always yields a `ColorType::Rgba`.
/// But when `xy` is `None` (the caller filters out echoes that just
/// match what was last sent), `prev`'s representation -- `Rgba` or
/// `Ccta` -- is preserved. This matters because a `Ccta` setting can
/// never compare equal to the `Rgba` the bridge would otherwise force
/// it into, which would make `OverridableDevice` treat every polled
/// reading as a mismatch and re-send the setting forever.
pub fn merge_bridge_update(
prev: &ColorType,
on: Option<bool>,
brightness: Option<f32>,
xy: Option<(f32, f32)>,
) -> ColorType {
let prev_alpha = match prev {
ColorType::Rgba { color } => color.alpha,
ColorType::Ccta { a, .. } => *a,
};
let is_on = on.unwrap_or(prev_alpha > 0);

let alpha = if !is_on {
0
} else if let Some(brightness) = brightness {
(brightness.clamp(0.0, 100.0) / 100.0 * 255.0).round() as u8
} else if on == Some(true) {
// Just turned on with no explicit brightness: default to 100%.
255
} else {
prev_alpha
};

match (xy, prev) {
(None, ColorType::Ccta { kelvin, .. }) => ColorType::Ccta {
kelvin: *kelvin,
a: alpha,
},
(None, ColorType::Rgba { color }) => ColorType::Rgba {
color: LinSrgba::new(color.red, color.green, color.blue, alpha),
},
(Some((x, y)), _) => {
let rgb = cie_xy_to_rgba(x, y);

ColorType::Rgba {
color: LinSrgba::new(rgb.red, rgb.green, rgb.blue, alpha),
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -86,4 +190,131 @@ mod tests {
assert_eq!(converted.alpha, 255);
}
}

#[test]
fn test_color_to_bridge_alpha_is_brightness() {
let rgb = LinSrgba::new(255, 128, 64, 0);

// alpha == 0 means off, and brightness reads as 0%.
let off = color_to_bridge(&ColorType::Rgba { color: rgb });
assert!(!off.on);
assert_eq!(off.brightness, 0.0);

// alpha == 255 means fully on, brightness reads as 100%.
let full = color_to_bridge(&ColorType::Rgba {
color: LinSrgba::new(255, 128, 64, 255),
});
assert!(full.on);
assert_eq!(full.brightness, 100.0);

// A mid-range alpha is on, with a proportional brightness.
let half = color_to_bridge(&ColorType::Rgba {
color: LinSrgba::new(255, 128, 64, 128),
});
assert!(half.on);
assert!((half.brightness - 50.196).abs() < 0.01);

// XY is derived only from RGB, independent of alpha.
assert_eq!(off.xy, full.xy);
assert_eq!(off.xy, half.xy);
}

#[test]
fn test_color_to_bridge_ccta() {
let bright = color_to_bridge(&ColorType::Ccta {
kelvin: 2700,
a: 255,
});
assert!(bright.on);
assert_eq!(bright.brightness, 100.0);

let off = color_to_bridge(&ColorType::Ccta { kelvin: 2700, a: 0 });
assert!(!off.on);

// xy should match a direct kelvin conversion.
assert_eq!(bright.xy, kelvin_to_cie_xy(2700));
}

#[test]
fn test_kelvin_to_cie_xy_known_white_points() {
// D65 (~6500K) and a warm-white (~2700K) sanity check, using a
// generous tolerance for the polynomial approximation.
let (x, y) = kelvin_to_cie_xy(6500);
assert!((x - 0.3127).abs() < 0.01, "x={x}");
assert!((y - 0.3290).abs() < 0.02, "y={y}");

let (x, y) = kelvin_to_cie_xy(2700);
assert!((x - 0.4578).abs() < 0.01, "x={x}");
assert!((y - 0.4101).abs() < 0.01, "y={y}");
}

#[test]
fn test_merge_bridge_update_on_off_only() {
let prev = ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 255),
};

// Turning off keeps the RGB hue but zeroes the alpha.
let merged = merge_bridge_update(&prev, Some(false), None, None);
assert_eq!(
merged,
ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 0)
}
);
}

#[test]
fn test_merge_bridge_update_dimming_only() {
let prev = ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 255),
};

// A brightness-only update preserves the RGB hue.
let merged = merge_bridge_update(&prev, None, Some(50.0), None);
assert_eq!(
merged,
ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 128)
}
);
}

#[test]
fn test_merge_bridge_update_xy_only() {
let prev = ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 128),
};
let (x, y) = rgba_to_cie_xy(&LinSrgba::new(10, 20, 30, 255));

// An XY-only update changes the RGB hue but keeps the alpha.
let merged = merge_bridge_update(&prev, None, None, Some((x, y)));
assert_eq!(
merged,
ColorType::Rgba {
color: LinSrgba::new(
cie_xy_to_rgba(x, y).red,
cie_xy_to_rgba(x, y).green,
cie_xy_to_rgba(x, y).blue,
128
)
}
);
}

#[test]
fn test_merge_bridge_update_turned_on_defaults_to_full_brightness() {
let prev = ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 0),
};

// Turning on with no explicit brightness defaults to 100%.
let merged = merge_bridge_update(&prev, Some(true), None, None);
assert_eq!(
merged,
ColorType::Rgba {
color: LinSrgba::new(200, 100, 50, 255)
}
);
}
}
Loading
Loading