huesmith-core 0.1.0

Platform-agnostic core for Hue-compatible Zigbee lights: color math, ZCL light state machine, and scene parsing
Documentation
//! Color-space conversions for Hue-compatible lights: CIE 1931 XY ↔ RGB,
//! color temperature (mireds/Kelvin) ↔ RGB, HSV ↔ RGB, and CCT channel mixing.
//!
//! The sRGB matrix coefficients and Tanner Helland polynomial constants are kept
//! at their published reference precision. The digits beyond what `f32` can
//! represent are deliberate provenance documentation — they round to the exact
//! same `f32` value — so the `excessive_precision` lint is allowed module-wide.
#![allow(clippy::excessive_precision)]

/// CIE 1931 XY to RGB conversion for linear-PWM LEDs.
/// Input: x, y as raw ZCL CurrentX/CurrentY values (valid domain 0-0xFEFF;
/// x = raw / 65536 per ZCL §5.2.2.2.1), brightness 0-254.
/// Output: (r, g, b) each 0-255, **linear** light values.
///
/// The XYZ→RGB matrix output is used directly, with **no sRGB companding**.
/// Philips' published app-oriented xy→RGB formula ends with sRGB gamma encoding
/// because it targets displays, which decode that curve; WS2812-class LEDs map
/// the byte linearly to duty/light output, so feeding them gamma-encoded values
/// lifts the midtones and visibly desaturates colors (e.g. a saturated red's
/// green channel jumps from ~13 to ~63). Skipping the companding renders the
/// requested chromaticity faithfully and keeps all three color modes consistent
/// — the HSV and color-temperature paths already produce linear values.
///
/// `brightness` is applied last as a plain linear scale via [`scale_rgb`], so
/// every channel dims in lockstep and reaches zero together (a fade to off ends
/// in clean black instead of a lingering tint from the dominant channel).
pub fn xy_to_rgb(x_raw: u16, y_raw: u16, brightness: u8) -> (u8, u8, u8) {
    // ZCL divisor is 65536 (not 65535): the attribute tops out at 0xFEFF, so
    // x/y never reach 1.0. Verified against zb_zcl_color_control.h
    // (ZB_ZCL_COLOR_CONTROL_CURRENT_X_MAX_VALUE = 0xfeff).
    let x = x_raw as f32 / 65536.0;
    let y = y_raw as f32 / 65536.0;

    if y < 0.0001 {
        return (0, 0, 0);
    }

    let z = 1.0 - x - y;
    let big_y = 1.0;
    let big_x = (big_y / y) * x;
    let big_z = (big_y / y) * z;

    // sRGB D65 conversion matrix (IEC 61966-2-1)
    let mut r = big_x * 3.2404542 - big_y * 1.5371385 - big_z * 0.4985314;
    let mut g = -big_x * 0.9692660 + big_y * 1.8760108 + big_z * 0.0415560;
    let mut b = big_x * 0.0556434 - big_y * 0.2040259 + big_z * 1.0572252;

    // Normalize out-of-gamut: scale down preserving color ratio instead of clipping
    let max_val = r.max(g).max(b);
    if max_val > 1.0 {
        r /= max_val;
        g /= max_val;
        b /= max_val;
    }

    // Clip negative (out-of-gamut on the low side)
    r = r.max(0.0);
    g = g.max(0.0);
    b = b.max(0.0);

    let full = ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8);
    scale_rgb(full, brightness)
}

/// Color temperature (mireds) to RGB.
/// Uses Tanner Helland's algorithm.
///
/// The result is at full brightness; callers scale it by their level afterwards
/// (the way `LightState` and the WS2812B backend do).
pub fn mireds_to_rgb(mireds: u16) -> (u8, u8, u8) {
    let mireds = mireds.clamp(153, 500);
    let kelvin = 1_000_000.0 / mireds as f32;
    kelvin_to_rgb(kelvin)
}

/// Scale an RGB triple by a 0-254 brightness level (round to nearest).
///
/// `brightness` is clamped to 254 first: at 255 the intermediate
/// `component * brightness / 254` reaches 256 and wraps to 0 when cast to `u8`,
/// which would blank a light that should be at full output. Backends use this to
/// dim a full-brightness color (e.g. the output of [`mireds_to_rgb`]) by the
/// current ZCL level.
///
/// Rounding to nearest (rather than truncating) keeps small channels alive
/// roughly twice as far down a fade, halving the level range near zero where
/// only the dominant channel of a color is still lit.
pub fn scale_rgb(rgb: (u8, u8, u8), brightness: u8) -> (u8, u8, u8) {
    let scale = u16::from(brightness.min(254));
    // Max intermediate: 255 * 254 + 127 = 64_897 < u16::MAX.
    let scale_channel = |c: u8| ((u16::from(c) * scale + 127) / 254) as u8;
    (
        scale_channel(rgb.0),
        scale_channel(rgb.1),
        scale_channel(rgb.2),
    )
}

/// Map a color-temperature value to cool/warm channel levels.
///
/// `cool` drives the white channel and `warm` drives the yellow channel. Both
/// outputs use the same 0-254 brightness scale as ZCL level control.
///
/// Maximum-drive overlap blend: the dominant channel always runs at the full
/// `brightness` level while the other fades in linearly — cool end = white
/// only, middle = **both at 100%**, warm end = yellow only. Level scales both
/// channels; mireds only set their ratio. Total output therefore peaks
/// mid-range (up to ~2× the endpoints). This is a deliberate choice for
/// uncalibrated DIY hardware: `level = 100%` must mean "everything this lamp
/// can emit at this color temperature", and evening out the lumen curve is the
/// builder's calibration job. (A constant-lumen crossfade — `cool + warm ==
/// brightness` — was tried and rejected: it halves the achievable mid-range
/// output.)
pub fn cct_mix_from_mireds(
    mireds: u16,
    brightness: u8,
    min_mireds: u16,
    max_mireds: u16,
) -> (u8, u8) {
    if brightness == 0 {
        return (0, 0);
    }

    let min = min_mireds.min(max_mireds);
    let max = max_mireds.max(min_mireds);
    let span = u32::from(max.saturating_sub(min)).max(1);
    let clamped = mireds.clamp(min, max);
    let position = f32::from(clamped - min) / span as f32;
    let (cool_ratio, warm_ratio) = if position <= 0.5 {
        (1.0, position * 2.0)
    } else {
        ((1.0 - position) * 2.0, 1.0)
    };

    let cool = (f32::from(brightness) * cool_ratio)
        .round()
        .clamp(0.0, f32::from(brightness)) as u8;
    let warm = (f32::from(brightness) * warm_ratio)
        .round()
        .clamp(0.0, f32::from(brightness)) as u8;

    (cool, warm)
}

/// Infer a color-temperature target from RGB chroma for CCT-only output.
///
/// This is a heuristic (`warm ratio = r / (r + b)`), not a colorimetric
/// mapping, and its output is coupled to whatever [`xy_to_rgb`] produces: when
/// that pipeline went linear (sRGB companding removed), the mireds inferred for
/// the same Hue-app XY pick shifted warmer by up to ~44 mireds. The composed
/// xy → RGB → mireds behavior is pinned by `xy_color_pick_maps_to_stable_cct`
/// in `tests/color.rs`; if either side changes deliberately, re-baseline that
/// test in the same commit.
pub fn cct_mireds_from_rgb(r: u8, _g: u8, b: u8, min_mireds: u16, max_mireds: u16) -> u16 {
    let min = min_mireds.min(max_mireds);
    let max = max_mireds.max(min_mireds);
    let red_blue = u16::from(r) + u16::from(b);
    let warm_ratio = if red_blue == 0 {
        0.5
    } else {
        f32::from(r) / f32::from(red_blue)
    };
    let span = f32::from(max - min);

    (f32::from(min) + span * warm_ratio).round() as u16
}

/// Kelvin to RGB conversion (Tanner Helland algorithm).
pub fn kelvin_to_rgb(kelvin: f32) -> (u8, u8, u8) {
    let temp = kelvin / 100.0;

    let r = if temp <= 66.0 {
        255.0
    } else {
        (329.698727446 * (temp - 60.0).powf(-0.1332047592)).clamp(0.0, 255.0)
    };

    let g = if temp <= 66.0 {
        (99.4708025861 * temp.ln() - 161.1195681661).clamp(0.0, 255.0)
    } else {
        (288.1221695283 * (temp - 60.0).powf(-0.0755148492)).clamp(0.0, 255.0)
    };

    let b = if temp >= 66.0 {
        255.0
    } else if temp <= 19.0 {
        0.0
    } else {
        (138.5177312231 * (temp - 10.0).ln() - 305.0447927307).clamp(0.0, 255.0)
    };

    (r as u8, g as u8, b as u8)
}

/// HSV to RGB conversion.
/// hue: 0-254 (ZCL range), saturation: 0-254, value/brightness: 0-254.
pub fn hsv_to_rgb(hue: u8, saturation: u8, value: u8) -> (u8, u8, u8) {
    let h = hue.min(254) as f32 / 254.0 * 360.0;
    let s = saturation.min(254) as f32 / 254.0;
    let v = value.min(254) as f32 / 254.0;

    if s < 0.001 {
        let gray = (v * 255.0) as u8;
        return (gray, gray, gray);
    }

    let h_sector = h / 60.0;
    let i = h_sector.floor() as u32;
    let f = h_sector - i as f32;
    let p = v * (1.0 - s);
    let q = v * (1.0 - s * f);
    let t = v * (1.0 - s * (1.0 - f));

    let (r, g, b) = match i % 6 {
        0 => (v, t, p),
        1 => (q, v, p),
        2 => (p, v, t),
        3 => (p, q, v),
        4 => (t, p, v),
        _ => (v, p, q),
    };

    ((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}

/// Enhanced hue (0-65535) to standard hue (0-254) for HSV conversion.
///
/// ZCL defines both through degrees — hue = EnhancedCurrentHue × 360 / 65536
/// and hue = CurrentHue × 360 / 254 — so the direct mapping divides by 65536,
/// not 65535. Rounds to nearest instead of truncating so e.g. 130 maps to 1,
/// not 0.
pub fn enhanced_hue_to_hue(enhanced_hue: u16) -> u8 {
    ((enhanced_hue as u32 * 254 + 32768) / 65536) as u8
}

/// Standard hue (0-254) to enhanced hue (0-65535) — the inverse of
/// [`enhanced_hue_to_hue`], with the same round-to-nearest convention so the
/// hue → enhanced_hue → hue round trip is stable.
///
/// Two clamps guard the ZCL edges: `hue` is clamped to 254 first (255 is a
/// ZCL-reserved value; unclamped it corrupts through the `u16` cast into a
/// value that decodes back to hue 1 — the wrong color — when a stored scene is
/// recalled), and the result is capped at 65535 because the spec formula maps
/// hue 254 to exactly 65536, one past what EnhancedCurrentHue can hold.
pub fn hue_to_enhanced_hue(hue: u8) -> u16 {
    ((u32::from(hue.min(254)) * 65536 + 127) / 254).min(65535) as u16
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn xy_d65_white_is_neutral() {
        let (r, g, b) = xy_to_rgb(20495, 21561, 254);
        assert!(r > 200, "r={r} too low for white");
        assert!(g > 200, "g={g} too low for white");
        assert!(b > 200, "b={b} too low for white");
    }

    #[test]
    fn xy_zero_brightness_is_black() {
        let (r, g, b) = xy_to_rgb(20495, 21561, 0);
        assert_eq!((r, g, b), (0, 0, 0));
    }

    #[test]
    fn xy_red_region() {
        let x = (0.64 * 65535.0) as u16;
        let y = (0.33 * 65535.0) as u16;
        let (r, g, b) = xy_to_rgb(x, y, 254);
        assert!(r > g && r > b, "red region: r={r} g={g} b={b}");
    }

    #[test]
    fn xy_dimming_scales_all_channels_together() {
        // Regression: brightness must be a single linear scale applied after
        // the color conversion. Folding it into the conversion (the old
        // approach, via Y with gamma on top) compressed unequal channels by
        // different amounts, so on a fade to off the largest channel for that
        // hue rounded to zero several steps after the others — a visible color
        // tint lingering at the tail of the fade instead of a clean black.
        let x = (0.64 * 65535.0) as u16; // saturated red region (r > g, r > b)
        let y = (0.33 * 65535.0) as u16;
        let full = xy_to_rgb(x, y, 254);

        for level in [1u8, 2, 5, 10, 40, 127] {
            assert_eq!(
                xy_to_rgb(x, y, level),
                scale_rgb(full, level),
                "xy_to_rgb({x}, {y}, {level}) must equal scale_rgb(full_color, {level})"
            );
        }
    }

    #[test]
    fn mireds_warm_white() {
        let (r, g, b) = mireds_to_rgb(370);
        assert!(r > g && g > b, "warm white r>g>b: r={r} g={g} b={b}");
    }

    #[test]
    fn mireds_cool_daylight() {
        let (r, g, b) = mireds_to_rgb(153);
        assert!(r > 200 && g > 200 && b > 200, "daylight: r={r} g={g} b={b}");
    }

    #[test]
    fn hsv_pure_red() {
        let (r, g, b) = hsv_to_rgb(0, 254, 254);
        assert!(r > 200, "r={r}");
        assert!(g < 30, "g={g}");
        assert!(b < 30, "b={b}");
    }

    #[test]
    fn hsv_zero_saturation_is_gray() {
        let (r, g, b) = hsv_to_rgb(100, 0, 127);
        assert_eq!(r, g);
        assert_eq!(g, b);
    }

    #[test]
    fn enhanced_hue_conversion() {
        assert_eq!(enhanced_hue_to_hue(0), 0);
        assert_eq!(enhanced_hue_to_hue(65535), 254);
        assert_eq!(enhanced_hue_to_hue(32768), 127);
    }

    #[test]
    fn hue_to_enhanced_hue_boundaries_and_roundtrip() {
        assert_eq!(hue_to_enhanced_hue(0), 0);
        assert_eq!(hue_to_enhanced_hue(127), 32768);
        // The spec formula maps 254 to exactly 65536; the cap keeps it in u16.
        assert_eq!(hue_to_enhanced_hue(254), 65535);
        // Regression: the ZCL-reserved 255 must map to the 254 encoding, not
        // corrupt through an unchecked u16 cast (which recalls as hue 1).
        assert_eq!(hue_to_enhanced_hue(255), 65535);
        // Round trip is stable across the whole valid domain.
        for hue in 0..=254u8 {
            assert_eq!(enhanced_hue_to_hue(hue_to_enhanced_hue(hue)), hue);
        }
    }

    #[test]
    fn enhanced_hue_intermediate_values_round_to_nearest() {
        // 128/65536 × 254 ≈ 0.496 → 0; 130/65536 × 254 ≈ 0.504 → 1.
        assert_eq!(enhanced_hue_to_hue(128), 0);
        assert_eq!(enhanced_hue_to_hue(130), 1);
        // 256/65536 × 254 ≈ 0.99 → 1 (truncation used to drop this to 0).
        assert_eq!(enhanced_hue_to_hue(256), 1);
        assert_eq!(enhanced_hue_to_hue(16384), 64);
    }

    #[test]
    fn kelvin_extremes_dont_panic() {
        let _ = kelvin_to_rgb(1000.0);
        let _ = kelvin_to_rgb(6500.0);
        let _ = kelvin_to_rgb(40000.0);
    }

    #[test]
    fn mireds_boundary_values() {
        let _ = mireds_to_rgb(153);
        let _ = mireds_to_rgb(500);
        let _ = mireds_to_rgb(250);
    }

    #[test]
    fn scale_rgb_full_level_is_identity() {
        assert_eq!(scale_rgb((255, 128, 0), 254), (255, 128, 0));
    }

    #[test]
    fn scale_rgb_zero_level_is_black() {
        assert_eq!(scale_rgb((255, 255, 255), 0), (0, 0, 0));
    }

    #[test]
    fn scale_rgb_half_level_halves() {
        assert_eq!(scale_rgb((254, 100, 0), 127), (127, 50, 0));
    }

    #[test]
    fn scale_rgb_out_of_spec_255_does_not_wrap_to_black() {
        // Regression: with an unclamped scale, 255 * 255 / 254 = 256 wraps to 0,
        // blanking a light that should be at full output. Clamp keeps it full.
        assert_eq!(scale_rgb((255, 255, 255), 255), (255, 255, 255));
    }

    #[test]
    fn scale_rgb_rounds_to_nearest_keeping_small_channels_alive() {
        // Truncation would kill a small channel earlier in a fade: with
        // (255, 63, 0) at level 3, floor gives g = 189/254 = 0 while rounding
        // gives (189 + 127)/254 = 1 — the minority channel survives further
        // down, shrinking the level range where only the dominant channel is
        // lit.
        assert_eq!(scale_rgb((255, 63, 0), 3), (3, 1, 0));
        // A 1-value channel at half level rounds up, not down to black.
        assert_eq!(scale_rgb((1, 1, 1), 127), (1, 1, 1));
    }
}