diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 635b78dcf13..69ff6ccb1fa 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -19,6 +19,7 @@ CONF_KEEP_MAIN_LIGHT = "keep_master_light" DEFAULT_KEEP_MAIN_LIGHT = False # Attributes +ATTR_CCT = "cct" ATTR_COLOR_PRIMARY = "color_primary" ATTR_DURATION = "duration" ATTR_FADE = "fade" @@ -30,6 +31,10 @@ ATTR_SPEED = "speed" ATTR_TARGET_BRIGHTNESS = "target_brightness" ATTR_UDP_PORT = "udp_port" +# Static values +COLOR_TEMP_K_MIN = 2000 +COLOR_TEMP_K_MAX = 6535 + LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = { LightCapability.NONE: [ @@ -56,8 +61,8 @@ LIGHT_CAPABILITIES_COLOR_MODE_MAPPING: dict[LightCapability, list[ColorMode]] = LightCapability.RGB_COLOR | LightCapability.WHITE_CHANNEL | LightCapability.COLOR_TEMPERATURE: [ - ColorMode.RGB, ColorMode.COLOR_TEMP, + ColorMode.RGBW, ], LightCapability.MANUAL_WHITE: [ ColorMode.BRIGHTNESS, diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 0dd29fdc2a3..216dba67c94 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -35,3 +35,13 @@ def wled_exception_handler[_WLEDEntityT: WLEDEntity, **_P]( raise HomeAssistantError("Invalid response from WLED API") from error return handler + + +def kelvin_to_255(k: int, min_k: int, max_k: int) -> int: + """Map color temperature in K from minK-maxK to 0-255.""" + return int((k - min_k) / (max_k - min_k) * 255) + + +def kelvin_to_255_reverse(v: int, min_k: int, max_k: int) -> int: + """Map color temperature from 0-255 to minK-maxK K.""" + return int(v / 255 * (max_k - min_k) + min_k) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 5423df84686..b4edf10dc58 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -7,6 +7,7 @@ from typing import Any, cast from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, @@ -20,14 +21,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import WLEDConfigEntry from .const import ( + ATTR_CCT, ATTR_COLOR_PRIMARY, ATTR_ON, ATTR_SEGMENT_ID, + COLOR_TEMP_K_MAX, + COLOR_TEMP_K_MIN, LIGHT_CAPABILITIES_COLOR_MODE_MAPPING, ) from .coordinator import WLEDDataUpdateCoordinator from .entity import WLEDEntity -from .helpers import wled_exception_handler +from .helpers import kelvin_to_255, kelvin_to_255_reverse, wled_exception_handler PARALLEL_UPDATES = 1 @@ -109,6 +113,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION _attr_translation_key = "segment" + _attr_min_color_temp_kelvin = COLOR_TEMP_K_MIN + _attr_max_color_temp_kelvin = COLOR_TEMP_K_MAX def __init__( self, @@ -166,6 +172,12 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): return None return cast(tuple[int, int, int, int], color.primary) + @property + def color_temp_kelvin(self) -> int | None: + """Return the CT color value in K.""" + cct = self.coordinator.data.state.segments[self._segment].cct + return kelvin_to_255_reverse(cct, COLOR_TEMP_K_MIN, COLOR_TEMP_K_MAX) + @property def effect(self) -> str | None: """Return the current effect of the light.""" @@ -235,6 +247,11 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if ATTR_RGBW_COLOR in kwargs: data[ATTR_COLOR_PRIMARY] = kwargs[ATTR_RGBW_COLOR] + if ATTR_COLOR_TEMP_KELVIN in kwargs: + data[ATTR_CCT] = kelvin_to_255( + kwargs[ATTR_COLOR_TEMP_KELVIN], COLOR_TEMP_K_MIN, COLOR_TEMP_K_MAX + ) + if ATTR_TRANSITION in kwargs: # WLED uses 100ms per unit, so 10 = 1 second. data[ATTR_TRANSITION] = round(kwargs[ATTR_TRANSITION] * 10) diff --git a/tests/components/wled/fixtures/cct.json b/tests/components/wled/fixtures/cct.json new file mode 100644 index 00000000000..da36f8a5f69 --- /dev/null +++ b/tests/components/wled/fixtures/cct.json @@ -0,0 +1,383 @@ +{ + "state": { + "on": true, + "bri": 255, + "transition": 7, + "ps": 2, + "pl": -1, + "nl": { + "on": false, + "dur": 60, + "mode": 1, + "tbri": 0, + "rem": -1 + }, + "udpn": { + "send": false, + "recv": true, + "sgrp": 1, + "rgrp": 1 + }, + "lor": 0, + "mainseg": 0, + "seg": [ + { + "id": 0, + "start": 0, + "stop": 178, + "len": 178, + "grp": 1, + "spc": 0, + "of": 0, + "on": true, + "frz": false, + "bri": 255, + "cct": 53, + "set": 0, + "col": [ + [0, 0, 0, 255], + [0, 0, 0, 0], + [0, 0, 0, 0] + ], + "fx": 0, + "sx": 128, + "ix": 128, + "pal": 0, + "c1": 128, + "c2": 128, + "c3": 16, + "sel": true, + "rev": false, + "mi": false, + "o1": false, + "o2": false, + "o3": false, + "si": 0, + "m12": 0 + } + ] + }, + "info": { + "ver": "0.15.0-b3", + "vid": 2405180, + "cn": "Kōsen", + "release": "ESP32", + "leds": { + "count": 178, + "pwr": 0, + "fps": 0, + "maxpwr": 0, + "maxseg": 32, + "bootps": 1, + "seglc": [7], + "lc": 7, + "rgbw": true, + "wv": 2, + "cct": 4 + }, + "str": false, + "name": "WLED CCT light", + "udpport": 21324, + "simplifiedui": false, + "live": false, + "liveseg": -1, + "lm": "", + "lip": "", + "ws": 1, + "fxcount": 187, + "palcount": 75, + "cpalcount": 4, + "maps": [ + { + "id": 0 + } + ], + "wifi": { + "bssid": "AA:AA:AA:AA:AA:BB", + "rssi": -44, + "signal": 100, + "channel": 11 + }, + "fs": { + "u": 20, + "t": 983, + "pmt": 1721752272 + }, + "ndc": 1, + "arch": "esp32", + "core": "v3.3.6-16-gcc5440f6a2", + "clock": 240, + "flash": 4, + "lwip": 0, + "freeheap": 164804, + "uptime": 79769, + "time": "2024-7-24, 14:34:00", + "opt": 79, + "brand": "WLED", + "product": "FOSS", + "mac": "aabbccddeeff", + "ip": "127.0.0.1" + }, + "effects": [ + "Solid", + "Blink", + "Breathe", + "Wipe", + "Wipe Random", + "Random Colors", + "Sweep", + "Dynamic", + "Colorloop", + "Rainbow", + "Scan", + "Scan Dual", + "Fade", + "Theater", + "Theater Rainbow", + "Running", + "Saw", + "Twinkle", + "Dissolve", + "Dissolve Rnd", + "Sparkle", + "Sparkle Dark", + "Sparkle+", + "Strobe", + "Strobe Rainbow", + "Strobe Mega", + "Blink Rainbow", + "Android", + "Chase", + "Chase Random", + "Chase Rainbow", + "Chase Flash", + "Chase Flash Rnd", + "Rainbow Runner", + "Colorful", + "Traffic Light", + "Sweep Random", + "Chase 2", + "Aurora", + "Stream", + "Scanner", + "Lighthouse", + "Fireworks", + "Rain", + "Tetrix", + "Fire Flicker", + "Gradient", + "Loading", + "Rolling Balls", + "Fairy", + "Two Dots", + "Fairytwinkle", + "Running Dual", + "RSVD", + "Chase 3", + "Tri Wipe", + "Tri Fade", + "Lightning", + "ICU", + "Multi Comet", + "Scanner Dual", + "Stream 2", + "Oscillate", + "Pride 2015", + "Juggle", + "Palette", + "Fire 2012", + "Colorwaves", + "Bpm", + "Fill Noise", + "Noise 1", + "Noise 2", + "Noise 3", + "Noise 4", + "Colortwinkles", + "Lake", + "Meteor", + "Meteor Smooth", + "Railway", + "Ripple", + "Twinklefox", + "Twinklecat", + "Halloween Eyes", + "Solid Pattern", + "Solid Pattern Tri", + "Spots", + "Spots Fade", + "Glitter", + "Candle", + "Fireworks Starburst", + "Fireworks 1D", + "Bouncing Balls", + "Sinelon", + "Sinelon Dual", + "Sinelon Rainbow", + "Popcorn", + "Drip", + "Plasma", + "Percent", + "Ripple Rainbow", + "Heartbeat", + "Pacifica", + "Candle Multi", + "Solid Glitter", + "Sunrise", + "Phased", + "Twinkleup", + "Noise Pal", + "Sine", + "Phased Noise", + "Flow", + "Chunchun", + "Dancing Shadows", + "Washing Machine", + "Rotozoomer", + "Blends", + "TV Simulator", + "Dynamic Smooth", + "Spaceships", + "Crazy Bees", + "Ghost Rider", + "Blobs", + "Scrolling Text", + "Drift Rose", + "Distortion Waves", + "Soap", + "Octopus", + "Waving Cell", + "Pixels", + "Pixelwave", + "Juggles", + "Matripix", + "Gravimeter", + "Plasmoid", + "Puddles", + "Midnoise", + "Noisemeter", + "Freqwave", + "Freqmatrix", + "GEQ", + "Waterfall", + "Freqpixels", + "RSVD", + "Noisefire", + "Puddlepeak", + "Noisemove", + "Noise2D", + "Perlin Move", + "Ripple Peak", + "Firenoise", + "Squared Swirl", + "RSVD", + "DNA", + "Matrix", + "Metaballs", + "Freqmap", + "Gravcenter", + "Gravcentric", + "Gravfreq", + "DJ Light", + "Funky Plank", + "RSVD", + "Pulser", + "Blurz", + "Drift", + "Waverly", + "Sun Radiation", + "Colored Bursts", + "Julia", + "RSVD", + "RSVD", + "RSVD", + "Game Of Life", + "Tartan", + "Polar Lights", + "Swirl", + "Lissajous", + "Frizzles", + "Plasma Ball", + "Flow Stripe", + "Hiphotic", + "Sindots", + "DNA Spiral", + "Black Hole", + "Wavesins", + "Rocktaves", + "Akemi" + ], + "palettes": [ + "Default", + "* Random Cycle", + "* Color 1", + "* Colors 1&2", + "* Color Gradient", + "* Colors Only", + "Party", + "Cloud", + "Lava", + "Ocean", + "Forest", + "Rainbow", + "Rainbow Bands", + "Sunset", + "Rivendell", + "Breeze", + "Red & Blue", + "Yellowout", + "Analogous", + "Splash", + "Pastel", + "Sunset 2", + "Beach", + "Vintage", + "Departure", + "Landscape", + "Beech", + "Sherbet", + "Hult", + "Hult 64", + "Drywet", + "Jul", + "Grintage", + "Rewhi", + "Tertiary", + "Fire", + "Icefire", + "Cyane", + "Light Pink", + "Autumn", + "Magenta", + "Magred", + "Yelmag", + "Yelblu", + "Orange & Teal", + "Tiamat", + "April Night", + "Orangery", + "C9", + "Sakura", + "Aurora", + "Atlantica", + "C9 2", + "C9 New", + "Temperature", + "Aurora 2", + "Retro Clown", + "Candy", + "Toxy Reaf", + "Fairy Reaf", + "Semi Blue", + "Pink Candy", + "Red Reaf", + "Aqua Flash", + "Yelblu Hot", + "Lite Light", + "Red Flash", + "Blink Red", + "Red Shift", + "Red Tide", + "Candy2" + ] +} diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 032035f0141..58c4aa4e8c6 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -9,8 +9,11 @@ from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, @@ -374,3 +377,34 @@ async def test_single_segment_with_keep_main_light( assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.state == STATE_ON + + +@pytest.mark.parametrize("device_fixture", ["cct"]) +async def test_cct_light(hass: HomeAssistant, mock_wled: MagicMock) -> None: + """Test CCT support for WLED.""" + assert (state := hass.states.get("light.wled_cct_light")) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ + ColorMode.COLOR_TEMP, + ColorMode.RGBW, + ] + assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP + assert state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN) == 2000 + assert state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN) == 6535 + assert state.attributes.get(ATTR_COLOR_TEMP_KELVIN) == 2942 + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.wled_cct_light", + ATTR_COLOR_TEMP_KELVIN: 4321, + }, + blocking=True, + ) + assert mock_wled.segment.call_count == 1 + mock_wled.segment.assert_called_with( + cct=130, + on=True, + segment_id=0, + )