From 5f33e85b30512a5c6d0b4f3bc3036e4b29d34a4a Mon Sep 17 00:00:00 2001
From: Avi Miller <me@dje.li>
Date: Sat, 13 Jul 2024 12:16:16 +1000
Subject: [PATCH] Initial support for LIFX Ceiling SKY effect (#121820)

---
 homeassistant/components/lifx/const.py       |   1 +
 homeassistant/components/lifx/coordinator.py |  72 +++++++--
 homeassistant/components/lifx/icons.json     |   1 +
 homeassistant/components/lifx/light.py       |  20 ++-
 homeassistant/components/lifx/manager.py     |  81 +++++++++-
 homeassistant/components/lifx/services.yaml  |  52 +++++++
 homeassistant/components/lifx/strings.json   |  30 ++++
 tests/components/lifx/__init__.py            |  13 ++
 tests/components/lifx/test_light.py          | 147 +++++++++++++++++++
 9 files changed, 394 insertions(+), 23 deletions(-)

diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py
index 2208537b591ccf..9b213cc9f6d2d3 100644
--- a/homeassistant/components/lifx/const.py
+++ b/homeassistant/components/lifx/const.py
@@ -61,5 +61,6 @@
 }
 DATA_LIFX_MANAGER = "lifx_manager"
 
+LIFX_CEILING_PRODUCT_IDS = {176, 177}
 
 _LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py
index 63912cbb820214..9d5532aeeb20a1 100644
--- a/homeassistant/components/lifx/coordinator.py
+++ b/homeassistant/components/lifx/coordinator.py
@@ -6,7 +6,7 @@
 from collections.abc import Callable
 from datetime import timedelta
 from enum import IntEnum
-from functools import partial
+from functools import cached_property, partial
 from math import floor, log10
 from typing import Any, cast
 
@@ -15,6 +15,7 @@
     Message,
     MultiZoneDirection,
     MultiZoneEffectType,
+    TileEffectSkyType,
     TileEffectType,
 )
 from aiolifx.connection import LIFXConnection
@@ -70,9 +71,18 @@ class FirmwareEffect(IntEnum):
     MOVE = 1
     MORPH = 2
     FLAME = 3
+    SKY = 5
 
 
-class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
+class SkyType(IntEnum):
+    """Enumeration of sky types for SKY firmware effect."""
+
+    SUNRISE = 0
+    SUNSET = 1
+    CLOUDS = 2
+
+
+class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):  # noqa: PLR0904
     """DataUpdateCoordinator to gather data for a specific lifx device."""
 
     def __init__(
@@ -128,14 +138,14 @@ def current_infrared_brightness(self) -> str | None:
         """Return the current infrared brightness as a string."""
         return infrared_brightness_value_to_option(self.device.infrared_brightness)
 
-    @property
+    @cached_property
     def serial_number(self) -> str:
         """Return the internal mac address."""
         return cast(
             str, self.device.mac_addr
         )  # device.mac_addr is not the mac_address, its the serial number
 
-    @property
+    @cached_property
     def mac_address(self) -> str:
         """Return the physical mac address."""
         return get_real_mac_addr(
@@ -149,6 +159,23 @@ def label(self) -> str:
         """Return the label of the bulb."""
         return cast(str, self.device.label)
 
+    @cached_property
+    def is_extended_multizone(self) -> bool:
+        """Return true if this is a multizone device."""
+        return bool(lifx_features(self.device)["extended_multizone"])
+
+    @cached_property
+    def is_legacy_multizone(self) -> bool:
+        """Return true if this is a legacy multizone device."""
+        return bool(
+            lifx_features(self.device)["multizone"] and not self.is_extended_multizone
+        )
+
+    @cached_property
+    def is_matrix(self) -> bool:
+        """Return true if this is a matrix device."""
+        return bool(lifx_features(self.device)["matrix"])
+
     async def diagnostics(self) -> dict[str, Any]:
         """Return diagnostic information about the device."""
         features = lifx_features(self.device)
@@ -269,17 +296,23 @@ async def _async_update_data(self) -> None:
 
         num_zones = self.get_number_of_zones()
         features = lifx_features(self.device)
-        is_extended_multizone = features["extended_multizone"]
-        is_legacy_multizone = not is_extended_multizone and features["multizone"]
         update_rssi = self._update_rssi
         methods: list[Callable] = [self.device.get_color]
         if update_rssi:
             methods.append(self.device.get_wifiinfo)
-        if is_extended_multizone:
+        if self.is_matrix:
+            methods.extend(
+                [
+                    self.device.get_tile_effect,
+                    self.device.get_device_chain,
+                    self.device.get64,
+                ]
+            )
+        if self.is_extended_multizone:
             methods.append(self.device.get_extended_color_zones)
-        elif is_legacy_multizone:
+        elif self.is_legacy_multizone:
             methods.extend(self._async_build_color_zones_update_requests())
-        if is_extended_multizone or is_legacy_multizone:
+        if self.is_extended_multizone or self.is_legacy_multizone:
             methods.append(self.device.get_multizone_effect)
         if features["hev"]:
             methods.append(self.device.get_hev_cycle)
@@ -297,9 +330,9 @@ async def _async_update_data(self) -> None:
             # We always send the rssi request second
             self._rssi = int(floor(10 * log10(responses[1].signal) + 0.5))
 
-        if is_extended_multizone or is_legacy_multizone:
+        if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone:
             self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
-        if is_legacy_multizone and num_zones != self.get_number_of_zones():
+        if self.is_legacy_multizone and num_zones != self.get_number_of_zones():
             # The number of zones has changed so we need
             # to update the zones again. This happens rarely.
             await self.async_get_color_zones()
@@ -402,7 +435,7 @@ async def async_set_multizone_effect(
         power_on: bool = True,
     ) -> None:
         """Control the firmware-based Move effect on a multizone device."""
-        if lifx_features(self.device)["multizone"] is True:
+        if self.is_extended_multizone or self.is_legacy_multizone:
             if power_on and self.device.power_level == 0:
                 await self.async_set_power(True, 0)
 
@@ -422,27 +455,36 @@ async def async_set_multizone_effect(
             )
             self.active_effect = FirmwareEffect[effect.upper()]
 
-    async def async_set_matrix_effect(
+    async def async_set_matrix_effect(  # noqa: PLR0917
         self,
         effect: str,
         palette: list[tuple[int, int, int, int]] | None = None,
-        speed: float = 3,
+        speed: float | None = None,
         power_on: bool = True,
+        sky_type: str | None = None,
+        cloud_saturation_min: int | None = None,
+        cloud_saturation_max: int | None = None,
     ) -> None:
         """Control the firmware-based effects on a matrix device."""
-        if lifx_features(self.device)["matrix"] is True:
+        if self.is_matrix:
             if power_on and self.device.power_level == 0:
                 await self.async_set_power(True, 0)
 
             if palette is None:
                 palette = []
 
+            if sky_type is not None:
+                sky_type = TileEffectSkyType[sky_type.upper()].value
+
             await async_execute_lifx(
                 partial(
                     self.device.set_tile_effect,
                     effect=TileEffectType[effect.upper()].value,
                     speed=speed,
                     palette=palette,
+                    sky_type=sky_type,
+                    cloud_saturation_min=cloud_saturation_min,
+                    cloud_saturation_max=cloud_saturation_max,
                 )
             )
             self.active_effect = FirmwareEffect[effect.upper()]
diff --git a/homeassistant/components/lifx/icons.json b/homeassistant/components/lifx/icons.json
index bf9e5e732d5d13..e32fdb5e06bf3a 100644
--- a/homeassistant/components/lifx/icons.json
+++ b/homeassistant/components/lifx/icons.json
@@ -7,6 +7,7 @@
     "effect_move": "mdi:cube-send",
     "effect_flame": "mdi:fire",
     "effect_morph": "mdi:shape-outline",
+    "effect_sky": "mdi:clouds",
     "effect_stop": "mdi:stop"
   }
 }
diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py
index caa1140b0999b2..22bcef4915e198 100644
--- a/homeassistant/components/lifx/light.py
+++ b/homeassistant/components/lifx/light.py
@@ -36,6 +36,7 @@
     DATA_LIFX_MANAGER,
     DOMAIN,
     INFRARED_BRIGHTNESS,
+    LIFX_CEILING_PRODUCT_IDS,
 )
 from .coordinator import FirmwareEffect, LIFXUpdateCoordinator
 from .entity import LIFXEntity
@@ -45,6 +46,7 @@
     SERVICE_EFFECT_MORPH,
     SERVICE_EFFECT_MOVE,
     SERVICE_EFFECT_PULSE,
+    SERVICE_EFFECT_SKY,
     SERVICE_EFFECT_STOP,
     LIFXManager,
 )
@@ -97,7 +99,10 @@ async def async_setup_entry(
         "set_hev_cycle_state",
     )
     if lifx_features(device)["matrix"]:
-        entity: LIFXLight = LIFXMatrix(coordinator, manager, entry)
+        if device.product in LIFX_CEILING_PRODUCT_IDS:
+            entity: LIFXLight = LIFXCeiling(coordinator, manager, entry)
+        else:
+            entity = LIFXMatrix(coordinator, manager, entry)
     elif lifx_features(device)["extended_multizone"]:
         entity = LIFXExtendedMultiZone(coordinator, manager, entry)
     elif lifx_features(device)["multizone"]:
@@ -499,3 +504,16 @@ class LIFXMatrix(LIFXColor):
         SERVICE_EFFECT_MORPH,
         SERVICE_EFFECT_STOP,
     ]
+
+
+class LIFXCeiling(LIFXMatrix):
+    """Representation of a LIFX Ceiling device."""
+
+    _attr_effect_list = [
+        SERVICE_EFFECT_COLORLOOP,
+        SERVICE_EFFECT_FLAME,
+        SERVICE_EFFECT_PULSE,
+        SERVICE_EFFECT_MORPH,
+        SERVICE_EFFECT_SKY,
+        SERVICE_EFFECT_STOP,
+    ]
diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py
index 038fdceab26a25..c23837c5fccc47 100644
--- a/homeassistant/components/lifx/manager.py
+++ b/homeassistant/components/lifx/manager.py
@@ -41,9 +41,12 @@
 SERVICE_EFFECT_MORPH = "effect_morph"
 SERVICE_EFFECT_MOVE = "effect_move"
 SERVICE_EFFECT_PULSE = "effect_pulse"
+SERVICE_EFFECT_SKY = "effect_sky"
 SERVICE_EFFECT_STOP = "effect_stop"
 
 ATTR_CHANGE = "change"
+ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min"
+ATTR_CLOUD_SATURATION_MAX = "cloud_saturation_max"
 ATTR_CYCLES = "cycles"
 ATTR_DIRECTION = "direction"
 ATTR_PALETTE = "palette"
@@ -52,6 +55,7 @@
 ATTR_POWER_ON = "power_on"
 ATTR_SATURATION_MAX = "saturation_max"
 ATTR_SATURATION_MIN = "saturation_min"
+ATTR_SKY_TYPE = "sky_type"
 ATTR_SPEED = "speed"
 ATTR_SPREAD = "spread"
 
@@ -59,6 +63,7 @@
 EFFECT_MORPH = "MORPH"
 EFFECT_MOVE = "MOVE"
 EFFECT_OFF = "OFF"
+EFFECT_SKY = "SKY"
 
 EFFECT_FLAME_DEFAULT_SPEED = 3
 
@@ -72,6 +77,13 @@
 
 EFFECT_MOVE_DIRECTIONS = [EFFECT_MOVE_DIRECTION_LEFT, EFFECT_MOVE_DIRECTION_RIGHT]
 
+EFFECT_SKY_DEFAULT_SPEED = 50
+EFFECT_SKY_DEFAULT_SKY_TYPE = "Clouds"
+EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN = 50
+EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX = 180
+
+EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"]
+
 PULSE_MODE_BLINK = "blink"
 PULSE_MODE_BREATHE = "breathe"
 PULSE_MODE_PING = "ping"
@@ -137,13 +149,6 @@
 
 LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({})
 
-SERVICES = (
-    SERVICE_EFFECT_STOP,
-    SERVICE_EFFECT_PULSE,
-    SERVICE_EFFECT_MOVE,
-    SERVICE_EFFECT_COLORLOOP,
-)
-
 LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema(
     {
         **LIFX_EFFECT_SCHEMA,
@@ -185,6 +190,28 @@
     }
 )
 
+LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema(
+    {
+        **LIFX_EFFECT_SCHEMA,
+        ATTR_SPEED: vol.All(vol.Coerce(int), vol.Clamp(min=1, max=86400)),
+        ATTR_SKY_TYPE: vol.In(EFFECT_SKY_SKY_TYPES),
+        ATTR_CLOUD_SATURATION_MIN: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)),
+        ATTR_CLOUD_SATURATION_MAX: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)),
+        ATTR_PALETTE: vol.All(cv.ensure_list, [HSBK_SCHEMA]),
+    }
+)
+
+
+SERVICES = (
+    SERVICE_EFFECT_COLORLOOP,
+    SERVICE_EFFECT_FLAME,
+    SERVICE_EFFECT_MORPH,
+    SERVICE_EFFECT_MOVE,
+    SERVICE_EFFECT_PULSE,
+    SERVICE_EFFECT_SKY,
+    SERVICE_EFFECT_STOP,
+)
+
 
 class LIFXManager:
     """Representation of all known LIFX entities."""
@@ -261,6 +288,13 @@ async def service_handler(service: ServiceCall) -> None:
             schema=LIFX_EFFECT_MOVE_SCHEMA,
         )
 
+        self.hass.services.async_register(
+            DOMAIN,
+            SERVICE_EFFECT_SKY,
+            service_handler,
+            schema=LIFX_EFFECT_SKY_SCHEMA,
+        )
+
         self.hass.services.async_register(
             DOMAIN,
             SERVICE_EFFECT_STOP,
@@ -375,6 +409,39 @@ async def start_effect(
             )
             await self.effects_conductor.start(effect, bulbs)
 
+        elif service == SERVICE_EFFECT_SKY:
+            palette = kwargs.get(ATTR_PALETTE, None)
+            if palette is not None:
+                theme = Theme()
+                for hsbk in palette:
+                    theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3])
+
+            speed = kwargs.get(ATTR_SPEED, EFFECT_SKY_DEFAULT_SPEED)
+            sky_type = kwargs.get(ATTR_SKY_TYPE, EFFECT_SKY_DEFAULT_SKY_TYPE)
+
+            cloud_saturation_min = kwargs.get(
+                ATTR_CLOUD_SATURATION_MIN,
+                EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MIN,
+            )
+            cloud_saturation_max = kwargs.get(
+                ATTR_CLOUD_SATURATION_MAX,
+                EFFECT_SKY_DEFAULT_CLOUD_SATURATION_MAX,
+            )
+
+            await asyncio.gather(
+                *(
+                    coordinator.async_set_matrix_effect(
+                        effect=EFFECT_SKY,
+                        speed=speed,
+                        sky_type=sky_type,
+                        cloud_saturation_min=cloud_saturation_min,
+                        cloud_saturation_max=cloud_saturation_max,
+                        palette=theme.colors,
+                    )
+                    for coordinator in coordinators
+                )
+            )
+
         elif service == SERVICE_EFFECT_STOP:
             await self.effects_conductor.stop(bulbs)
 
diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml
index 83d314396661ee..c2eb2e249cbb5f 100644
--- a/homeassistant/components/lifx/services.yaml
+++ b/homeassistant/components/lifx/services.yaml
@@ -281,6 +281,58 @@ effect_morph:
       default: true
       selector:
         boolean:
+effect_sky:
+  target:
+    entity:
+      integration: lifx
+      domain: light
+  fields:
+    power_on:
+      default: true
+      selector:
+        boolean:
+    speed:
+      default: 50
+      example: 50
+      selector:
+        number:
+          min: 1
+          max: 86400
+          step: 1
+          unit_of_measurement: seconds
+    sky_type:
+      default: "Clouds"
+      example: "Clouds"
+      selector:
+        select:
+          options:
+            - "Clouds"
+            - "Sunrise"
+            - "Sunset"
+    cloud_saturation_min:
+      default: 50
+      example: 50
+      selector:
+        number:
+          min: 0
+          max: 255
+    cloud_saturation_max:
+      default: 180
+      example: 180
+      selector:
+        number:
+          min: 0
+          max: 255
+    palette:
+      example:
+        - "[200, 1, 1, 3500]"
+        - "[241, 1, 0.01, 3500]"
+        - "[189, 1, 0.08, 3500]"
+        - "[40, 1, 1, 3500]"
+        - "[40, 0.5, 1, 3500]"
+        - "[40, 0, 1, 6500]"
+      selector:
+        object:
 effect_stop:
   target:
     entity:
diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json
index 21f3b3fe52b67f..68f9e31aabd36f 100644
--- a/homeassistant/components/lifx/strings.json
+++ b/homeassistant/components/lifx/strings.json
@@ -220,6 +220,36 @@
         }
       }
     },
+    "effect_sky": {
+      "name": "Sky effect",
+      "description": "Starts the firmware-based Sky effect on LIFX Ceiling.",
+      "fields": {
+        "speed": {
+          "name": "Speed",
+          "description": "How long the Sunrise and Sunset sky types will take to complete. For the Cloud sky type, it is the speed of the clouds across the device."
+        },
+        "sky_type": {
+          "name": "Sky type",
+          "description": "The style of sky that will be animated by the effect."
+        },
+        "cloud_saturation_min": {
+          "name": "Cloud saturation Minimum",
+          "description": "Minimum cloud saturation."
+        },
+        "cloud_saturation_max": {
+          "name": "Cloud Saturation maximum",
+          "description": "Maximum cloud saturation."
+        },
+        "palette": {
+          "name": "Palette",
+          "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect."
+        },
+        "power_on": {
+          "name": "Power on",
+          "description": "[%key:component::lifx::services::effect_move::fields::power_on::description%]"
+        }
+      }
+    },
     "effect_stop": {
       "name": "Stop effect",
       "description": "Stops a running effect."
diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py
index 505d212a35226c..4834e486ec0065 100644
--- a/tests/components/lifx/__init__.py
+++ b/tests/components/lifx/__init__.py
@@ -172,6 +172,19 @@ def _mocked_tile() -> Light:
     bulb.effect = {"effect": "OFF"}
     bulb.get_tile_effect = MockLifxCommand(bulb)
     bulb.set_tile_effect = MockLifxCommand(bulb)
+    bulb.get64 = MockLifxCommand(bulb)
+    bulb.get_device_chain = MockLifxCommand(bulb)
+    return bulb
+
+
+def _mocked_ceiling() -> Light:
+    bulb = _mocked_bulb()
+    bulb.product = 176  # LIFX Ceiling
+    bulb.effect = {"effect": "OFF"}
+    bulb.get_tile_effect = MockLifxCommand(bulb)
+    bulb.set_tile_effect = MockLifxCommand(bulb)
+    bulb.get64 = MockLifxCommand(bulb)
+    bulb.get_device_chain = MockLifxCommand(bulb)
     return bulb
 
 
diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py
index 56630053cc01a4..9972bc1021a541 100644
--- a/tests/components/lifx/test_light.py
+++ b/tests/components/lifx/test_light.py
@@ -11,15 +11,19 @@
 from homeassistant.components.lifx.const import ATTR_POWER
 from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES
 from homeassistant.components.lifx.manager import (
+    ATTR_CLOUD_SATURATION_MAX,
+    ATTR_CLOUD_SATURATION_MIN,
     ATTR_DIRECTION,
     ATTR_PALETTE,
     ATTR_SATURATION_MAX,
     ATTR_SATURATION_MIN,
+    ATTR_SKY_TYPE,
     ATTR_SPEED,
     ATTR_THEME,
     SERVICE_EFFECT_COLORLOOP,
     SERVICE_EFFECT_MORPH,
     SERVICE_EFFECT_MOVE,
+    SERVICE_EFFECT_SKY,
 )
 from homeassistant.components.light import (
     ATTR_BRIGHTNESS,
@@ -62,6 +66,7 @@
     _mocked_brightness_bulb,
     _mocked_bulb,
     _mocked_bulb_new_firmware,
+    _mocked_ceiling,
     _mocked_clean_bulb,
     _mocked_light_strip,
     _mocked_tile,
@@ -691,6 +696,7 @@ async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None:
 
     entity_id = "light.my_bulb"
 
+    # FLAME effect test
     await hass.services.async_call(
         LIGHT_DOMAIN,
         "turn_on",
@@ -707,11 +713,15 @@ async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None:
         "effect": 3,
         "speed": 3,
         "palette": [],
+        "sky_type": None,
+        "cloud_saturation_min": None,
+        "cloud_saturation_max": None,
     }
     bulb.get_tile_effect.reset_mock()
     bulb.set_tile_effect.reset_mock()
     bulb.set_power.reset_mock()
 
+    # MORPH effect tests
     bulb.power_level = 0
     await hass.services.async_call(
         DOMAIN,
@@ -750,6 +760,9 @@ async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None:
             (8920, 65535, 32768, 3500),
             (10558, 65535, 32768, 3500),
         ],
+        "sky_type": None,
+        "cloud_saturation_min": None,
+        "cloud_saturation_max": None,
     }
     bulb.get_tile_effect.reset_mock()
     bulb.set_tile_effect.reset_mock()
@@ -808,6 +821,140 @@ async def test_matrix_flame_morph_effects(hass: HomeAssistant) -> None:
             (43690, 65535, 65535, 3500),
             (54613, 65535, 65535, 3500),
         ],
+        "sky_type": None,
+        "cloud_saturation_min": None,
+        "cloud_saturation_max": None,
+    }
+    bulb.get_tile_effect.reset_mock()
+    bulb.set_tile_effect.reset_mock()
+    bulb.set_power.reset_mock()
+
+
+@pytest.mark.usefixtures("mock_discovery")
+async def test_sky_effect(hass: HomeAssistant) -> None:
+    """Test the firmware sky effect on a ceiling device."""
+    config_entry = MockConfigEntry(
+        domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL
+    )
+    config_entry.add_to_hass(hass)
+    bulb = _mocked_ceiling()
+    bulb.power_level = 0
+    bulb.color = [65535, 65535, 65535, 65535]
+    with (
+        _patch_discovery(device=bulb),
+        _patch_config_flow_try_connect(device=bulb),
+        _patch_device(device=bulb),
+    ):
+        await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}})
+        await hass.async_block_till_done()
+
+    entity_id = "light.my_bulb"
+
+    # SKY effect test
+    bulb.power_level = 0
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_EFFECT_SKY,
+        {
+            ATTR_ENTITY_ID: entity_id,
+            ATTR_PALETTE: [],
+            ATTR_SKY_TYPE: "Clouds",
+            ATTR_CLOUD_SATURATION_MAX: 180,
+            ATTR_CLOUD_SATURATION_MIN: 50,
+        },
+        blocking=True,
+    )
+
+    bulb.power_level = 65535
+    bulb.effect = {
+        "effect": "SKY",
+        "palette": [],
+        "sky_type": 2,
+        "cloud_saturation_min": 50,
+        "cloud_saturation_max": 180,
+    }
+    async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
+    await hass.async_block_till_done(wait_background_tasks=True)
+
+    state = hass.states.get(entity_id)
+    assert state.state == STATE_ON
+
+    assert len(bulb.set_power.calls) == 1
+    assert len(bulb.set_tile_effect.calls) == 1
+    call_dict = bulb.set_tile_effect.calls[0][1]
+    call_dict.pop("callb")
+    assert call_dict == {
+        "effect": 5,
+        "speed": 50,
+        "palette": [],
+        "sky_type": 2,
+        "cloud_saturation_min": 50,
+        "cloud_saturation_max": 180,
+    }
+    bulb.get_tile_effect.reset_mock()
+    bulb.set_tile_effect.reset_mock()
+    bulb.set_power.reset_mock()
+
+    bulb.power_level = 0
+    await hass.services.async_call(
+        DOMAIN,
+        SERVICE_EFFECT_SKY,
+        {
+            ATTR_ENTITY_ID: entity_id,
+            ATTR_PALETTE: [
+                (200, 100, 1, 3500),
+                (241, 100, 1, 3500),
+                (189, 100, 8, 3500),
+                (40, 100, 100, 3500),
+                (40, 50, 100, 3500),
+                (0, 0, 100, 6500),
+            ],
+            ATTR_SKY_TYPE: "Sunrise",
+            ATTR_CLOUD_SATURATION_MAX: 180,
+            ATTR_CLOUD_SATURATION_MIN: 50,
+        },
+        blocking=True,
+    )
+
+    bulb.power_level = 65535
+    bulb.effect = {
+        "effect": "SKY",
+        "palette": [
+            (200, 100, 1, 3500),
+            (241, 100, 1, 3500),
+            (189, 100, 8, 3500),
+            (40, 100, 100, 3500),
+            (40, 50, 100, 3500),
+            (0, 0, 100, 6500),
+        ],
+        "sky_type": 0,
+        "cloud_saturation_min": 50,
+        "cloud_saturation_max": 180,
+    }
+    async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
+    await hass.async_block_till_done(wait_background_tasks=True)
+
+    state = hass.states.get(entity_id)
+    assert state.state == STATE_ON
+
+    assert len(bulb.set_power.calls) == 1
+    assert len(bulb.set_tile_effect.calls) == 1
+    call_dict = bulb.set_tile_effect.calls[0][1]
+    call_dict.pop("callb")
+    assert call_dict == {
+        "effect": 5,
+        "speed": 50,
+        "palette": [
+            (36408, 65535, 65535, 3500),
+            (43872, 65535, 65535, 3500),
+            (34406, 65535, 5243, 3500),
+            (7281, 65535, 65535, 3500),
+            (7281, 32768, 65535, 3500),
+            (0, 0, 65535, 6500),
+        ],
+        "sky_type": 0,
+        "cloud_saturation_min": 50,
+        "cloud_saturation_max": 180,
     }
     bulb.get_tile_effect.reset_mock()
     bulb.set_tile_effect.reset_mock()