From c8035dc829a6fa2b9469a2a9074721ac2376ddca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:36:02 +0200 Subject: [PATCH] Move service registration to async_setup in lifx (#181196) --- homeassistant/components/lifx/__init__.py | 10 +- homeassistant/components/lifx/const.py | 23 ++ homeassistant/components/lifx/light.py | 8 +- homeassistant/components/lifx/manager.py | 255 ++------------------ homeassistant/components/lifx/services.py | 262 +++++++++++++++++++++ homeassistant/components/lifx/strings.json | 5 + tests/components/lifx/test_light.py | 6 +- tests/components/lifx/test_services.py | 86 +++++++ 8 files changed, 412 insertions(+), 243 deletions(-) create mode 100644 homeassistant/components/lifx/services.py create mode 100644 tests/components/lifx/test_services.py diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 983f8142e268..fd51df100615 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -28,6 +28,7 @@ from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries +from .services import async_setup_services from .util import async_entry_is_legacy, async_get_legacy_entry, formatted_serial CONF_SERVER = "server" @@ -156,6 +157,8 @@ class LIFXDiscoveryManager: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" + async_setup_services(hass) + migrating = bool(async_get_legacy_entry(hass)) discovery_manager = LIFXDiscoveryManager(hass, migrating) @@ -197,9 +200,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool assert entry.unique_id is not None if DATA_LIFX_MANAGER not in hass.data: - manager = LIFXManager(hass) - hass.data[DATA_LIFX_MANAGER] = manager - manager.async_setup() + hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) host = entry.data[CONF_HOST] connection = LIFXConnection(host, TARGET_ANY) @@ -240,6 +241,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> boo entry.runtime_data.connection.async_stop() # Only the DATA_LIFX_MANAGER left, remove it. if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0: - manager = hass.data.pop(DATA_LIFX_MANAGER) - manager.async_unload() + hass.data.pop(DATA_LIFX_MANAGER) return unload_ok diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 1c756a0d51a4..e29bca60053f 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -58,6 +58,29 @@ ATTR_ZONES = "zones" ATTR_THEME = "theme" +ATTR_CHANGE = "change" +ATTR_CLOUD_SATURATION_MAX = "cloud_saturation_max" +ATTR_CLOUD_SATURATION_MIN = "cloud_saturation_min" +ATTR_CYCLES = "cycles" +ATTR_DIRECTION = "direction" +ATTR_PALETTE = "palette" +ATTR_PERIOD = "period" +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" + +SERVICE_EFFECT_COLORLOOP = "effect_colorloop" +SERVICE_EFFECT_FLAME = "effect_flame" +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" +SERVICE_PAINT_THEME = "paint_theme" + HEV_CYCLE_STATE = "hev_cycle_state" INFRARED_BRIGHTNESS = "infrared_brightness" INFRARED_BRIGHTNESS_VALUES_MAP = { diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 23251e58a471..28e6a18ff7b8 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -36,10 +36,6 @@ from .const import ( INFRARED_BRIGHTNESS, LIFX_CEILING_PRODUCT_IDS, LOGGER, -) -from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator -from .entity import LIFXEntity -from .manager import ( SERVICE_EFFECT_COLORLOOP, SERVICE_EFFECT_FLAME, SERVICE_EFFECT_MORPH, @@ -47,8 +43,10 @@ from .manager import ( SERVICE_EFFECT_PULSE, SERVICE_EFFECT_SKY, SERVICE_EFFECT_STOP, - LIFXManager, ) +from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator +from .entity import LIFXEntity +from .manager import LIFXManager from .util import convert_8_to_16, convert_16_to_8, find_hsbk, lifx_features, merge_hsbk LIFX_STATE_SETTLE_DELAY = 0.3 diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index c621fefe2e79..6d8fbe01590b 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -8,30 +8,39 @@ from typing import TYPE_CHECKING, Any import aiolifx_effects from aiolifx_themes.painter import ThemePainter from aiolifx_themes.themes import Theme, ThemeLibrary -import probatio from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, - ATTR_COLOR_NAME, - ATTR_COLOR_TEMP_KELVIN, - ATTR_HS_COLOR, - ATTR_RGB_COLOR, ATTR_TRANSITION, - ATTR_XY_COLOR, - COLOR_GROUP, - VALID_BRIGHTNESS, - VALID_BRIGHTNESS_PCT, ) from homeassistant.const import ATTR_MODE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.target import ( - TargetSelection, - async_extract_referenced_entity_ids, -) +from homeassistant.core import HomeAssistant, callback -from .const import ATTR_THEME, DOMAIN +from .const import ( + ATTR_CHANGE, + ATTR_CLOUD_SATURATION_MAX, + ATTR_CLOUD_SATURATION_MIN, + ATTR_CYCLES, + ATTR_DIRECTION, + ATTR_PALETTE, + ATTR_PERIOD, + ATTR_POWER_ON, + ATTR_SATURATION_MAX, + ATTR_SATURATION_MIN, + ATTR_SKY_TYPE, + ATTR_SPEED, + ATTR_SPREAD, + ATTR_THEME, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) from .coordinator import LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk @@ -40,30 +49,6 @@ if TYPE_CHECKING: SCAN_INTERVAL = timedelta(seconds=10) -SERVICE_EFFECT_COLORLOOP = "effect_colorloop" -SERVICE_EFFECT_FLAME = "effect_flame" -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" -SERVICE_PAINT_THEME = "paint_theme" - -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" -ATTR_PERIOD = "period" -ATTR_POWER_OFF = "power_off" -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" - EFFECT_FLAME = "FLAME" EFFECT_MORPH = "MORPH" EFFECT_MOVE = "MOVE" @@ -77,179 +62,13 @@ EFFECT_MORPH_DEFAULT_THEME = "exciting" EFFECT_MOVE_DEFAULT_SPEED = 3 EFFECT_MOVE_DEFAULT_DIRECTION = "right" -EFFECT_MOVE_DIRECTION_RIGHT = "right" -EFFECT_MOVE_DIRECTION_LEFT = "left" - -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"] - PAINT_THEME_DEFAULT_TRANSITION = 1 -PULSE_MODE_BLINK = "blink" -PULSE_MODE_BREATHE = "breathe" -PULSE_MODE_PING = "ping" -PULSE_MODE_SOLID = "solid" -PULSE_MODE_STROBE = "strobe" - -PULSE_MODES = [ - PULSE_MODE_BLINK, - PULSE_MODE_BREATHE, - PULSE_MODE_PING, - PULSE_MODE_STROBE, - PULSE_MODE_SOLID, -] - -LIFX_EFFECT_SCHEMA = { - probatio.Optional(ATTR_POWER_ON, default=True): cv.boolean, -} - -LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, - probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, - probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, - probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), probatio.ExactSequence((cv.byte, cv.byte, cv.byte)) - ), - probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence((cv.small_float, cv.small_float)), - ), - probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence( - ( - probatio.All( - probatio.Coerce(float), probatio.Range(min=0, max=360) - ), - probatio.All( - probatio.Coerce(float), probatio.Range(min=0, max=100) - ), - ) - ), - ), - probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): probatio.All( - probatio.Coerce(int), probatio.Range(min=1500, max=9000) - ), - ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Range(min=0.05)), - ATTR_CYCLES: probatio.All(probatio.Coerce(float), probatio.Range(min=1)), - ATTR_MODE: probatio.In(PULSE_MODES), - } -) - -LIFX_EFFECT_COLORLOOP_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, - probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, - ATTR_SATURATION_MAX: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=100) - ), - ATTR_SATURATION_MIN: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=100) - ), - ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Clamp(min=0.05)), - ATTR_CHANGE: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=360) - ), - ATTR_SPREAD: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0, max=360) - ), - ATTR_TRANSITION: cv.positive_float, - } -) - -LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) - -LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), - } -) - -HSBK_SCHEMA = probatio.All( - probatio.Coerce(tuple), - probatio.ExactSequence( - ( - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), - probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), - probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=100)), - probatio.All(probatio.Coerce(int), probatio.Clamp(min=1500, max=9000)), - ) - ), -) - -LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), - probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] - ), - } -) - -LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All( - probatio.Coerce(float), probatio.Clamp(min=0.1, max=60) - ), - ATTR_DIRECTION: probatio.In(EFFECT_MOVE_DIRECTIONS), - probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary().themes), - } -) - -LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_SPEED: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=1, max=86400) - ), - ATTR_SKY_TYPE: probatio.In(EFFECT_SKY_SKY_TYPES), - ATTR_CLOUD_SATURATION_MIN: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=255) - ), - ATTR_CLOUD_SATURATION_MAX: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=0, max=255) - ), - ATTR_PALETTE: probatio.All(cv.ensure_list, [HSBK_SCHEMA]), - } -) - -LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( - { - **LIFX_EFFECT_SCHEMA, - ATTR_TRANSITION: probatio.All( - probatio.Coerce(int), probatio.Clamp(min=1, max=3600) - ), - probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), - probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( - cv.ensure_list, [HSBK_SCHEMA] - ), - } -) - -SERVICES_SCHEMA = { - SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, - SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, - SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, - SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, - SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, - SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, - SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, - SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, -} - class LIFXManager: """Representation of all known LIFX entities.""" @@ -260,12 +79,6 @@ class LIFXManager: self.effects_conductor = aiolifx_effects.Conductor(hass.loop) self.entity_id_to_coordinator: dict[str, LIFXUpdateCoordinator] = {} - @callback - def async_unload(self) -> None: - """Release resources.""" - for service in SERVICES_SCHEMA: - self.hass.services.async_remove(DOMAIN, service) - @callback def async_register_entity( self, entity_id: str, coordinator: LIFXUpdateCoordinator @@ -280,24 +93,6 @@ class LIFXManager: return unregister_entity - @callback - def async_setup(self) -> None: - """Register the LIFX effects as hass service calls.""" - - async def service_handler(service: ServiceCall) -> None: - """Apply a service, i.e. start an effect.""" - referenced = async_extract_referenced_entity_ids( - self.hass, TargetSelection(service.data) - ) - all_referenced = referenced.referenced | referenced.indirectly_referenced - if all_referenced: - await self.start_effect(all_referenced, service.service, **service.data) - - for service, schema in SERVICES_SCHEMA.items(): - self.hass.services.async_register( - DOMAIN, service, service_handler, schema=schema - ) - @staticmethod def build_theme(theme_name: str = "exciting", palette: list | None = None) -> Theme: """Either return the predefined theme or build one from the palette.""" diff --git a/homeassistant/components/lifx/services.py b/homeassistant/components/lifx/services.py new file mode 100644 index 000000000000..a170dd54f89a --- /dev/null +++ b/homeassistant/components/lifx/services.py @@ -0,0 +1,262 @@ +"""Support for LIFX services.""" + +from typing import TYPE_CHECKING + +from aiolifx_themes.themes import ThemeLibrary +import probatio + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ATTR_XY_COLOR, + COLOR_GROUP, + VALID_BRIGHTNESS, + VALID_BRIGHTNESS_PCT, +) +from homeassistant.const import ATTR_MODE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.target import ( + TargetSelection, + async_extract_referenced_entity_ids, +) + +from .const import ( + ATTR_CHANGE, + ATTR_CLOUD_SATURATION_MAX, + ATTR_CLOUD_SATURATION_MIN, + ATTR_CYCLES, + ATTR_DIRECTION, + ATTR_PALETTE, + ATTR_PERIOD, + ATTR_POWER_ON, + ATTR_SATURATION_MAX, + ATTR_SATURATION_MIN, + ATTR_SKY_TYPE, + ATTR_SPEED, + ATTR_SPREAD, + ATTR_THEME, + DATA_LIFX_MANAGER, + DOMAIN, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) +from .util import async_entry_is_legacy + +if TYPE_CHECKING: + from .manager import LIFXManager + +EFFECT_MOVE_DIRECTION_LEFT = "left" +EFFECT_MOVE_DIRECTION_RIGHT = "right" + +EFFECT_MOVE_DIRECTIONS = [EFFECT_MOVE_DIRECTION_LEFT, EFFECT_MOVE_DIRECTION_RIGHT] + +EFFECT_SKY_SKY_TYPES = ["Sunrise", "Sunset", "Clouds"] + +PULSE_MODE_BLINK = "blink" +PULSE_MODE_BREATHE = "breathe" +PULSE_MODE_PING = "ping" +PULSE_MODE_SOLID = "solid" +PULSE_MODE_STROBE = "strobe" + +PULSE_MODES = [ + PULSE_MODE_BLINK, + PULSE_MODE_BREATHE, + PULSE_MODE_PING, + PULSE_MODE_STROBE, + PULSE_MODE_SOLID, +] + +LIFX_EFFECT_SCHEMA = { + probatio.Optional(ATTR_POWER_ON, default=True): cv.boolean, +} + +LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + probatio.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, + probatio.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), probatio.ExactSequence((cv.byte, cv.byte, cv.byte)) + ), + probatio.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence((cv.small_float, cv.small_float)), + ), + probatio.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence( + ( + probatio.All( + probatio.Coerce(float), probatio.Range(min=0, max=360) + ), + probatio.All( + probatio.Coerce(float), probatio.Range(min=0, max=100) + ), + ) + ), + ), + probatio.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): probatio.All( + probatio.Coerce(int), probatio.Range(min=1500, max=9000) + ), + ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Range(min=0.05)), + ATTR_CYCLES: probatio.All(probatio.Coerce(float), probatio.Range(min=1)), + ATTR_MODE: probatio.In(PULSE_MODES), + } +) + +LIFX_EFFECT_COLORLOOP_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + probatio.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + probatio.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + ATTR_SATURATION_MAX: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=100) + ), + ATTR_SATURATION_MIN: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=100) + ), + ATTR_PERIOD: probatio.All(probatio.Coerce(float), probatio.Clamp(min=0.05)), + ATTR_CHANGE: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=360) + ), + ATTR_SPREAD: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0, max=360) + ), + ATTR_TRANSITION: cv.positive_float, + } +) + +LIFX_EFFECT_STOP_SCHEMA = cv.make_entity_service_schema({}) + +LIFX_EFFECT_FLAME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), + } +) + +HSBK_SCHEMA = probatio.All( + probatio.Coerce(tuple), + probatio.ExactSequence( + ( + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=360)), + probatio.All(probatio.Coerce(float), probatio.Range(min=0, max=100)), + probatio.All(probatio.Coerce(float), probatio.Clamp(min=0, max=100)), + probatio.All(probatio.Coerce(int), probatio.Clamp(min=1500, max=9000)), + ) + ), +) + +LIFX_EFFECT_MORPH_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All(probatio.Coerce(int), probatio.Clamp(min=1, max=25)), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), + probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) + +LIFX_EFFECT_MOVE_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All( + probatio.Coerce(float), probatio.Clamp(min=0.1, max=60) + ), + ATTR_DIRECTION: probatio.In(EFFECT_MOVE_DIRECTIONS), + probatio.Optional(ATTR_THEME): probatio.In(ThemeLibrary().themes), + } +) + +LIFX_EFFECT_SKY_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_SPEED: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=1, max=86400) + ), + ATTR_SKY_TYPE: probatio.In(EFFECT_SKY_SKY_TYPES), + ATTR_CLOUD_SATURATION_MIN: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=255) + ), + ATTR_CLOUD_SATURATION_MAX: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=0, max=255) + ), + ATTR_PALETTE: probatio.All(cv.ensure_list, [HSBK_SCHEMA]), + } +) + +LIFX_PAINT_THEME_SCHEMA = cv.make_entity_service_schema( + { + **LIFX_EFFECT_SCHEMA, + ATTR_TRANSITION: probatio.All( + probatio.Coerce(int), probatio.Clamp(min=1, max=3600) + ), + probatio.Exclusive(ATTR_THEME, COLOR_GROUP): probatio.In(ThemeLibrary().themes), + probatio.Exclusive(ATTR_PALETTE, COLOR_GROUP): probatio.All( + cv.ensure_list, [HSBK_SCHEMA] + ), + } +) + +SERVICES_SCHEMA = { + SERVICE_EFFECT_COLORLOOP: LIFX_EFFECT_COLORLOOP_SCHEMA, + SERVICE_EFFECT_FLAME: LIFX_EFFECT_FLAME_SCHEMA, + SERVICE_EFFECT_MORPH: LIFX_EFFECT_MORPH_SCHEMA, + SERVICE_EFFECT_MOVE: LIFX_EFFECT_MOVE_SCHEMA, + SERVICE_EFFECT_PULSE: LIFX_EFFECT_PULSE_SCHEMA, + SERVICE_EFFECT_SKY: LIFX_EFFECT_SKY_SCHEMA, + SERVICE_EFFECT_STOP: LIFX_EFFECT_STOP_SCHEMA, + SERVICE_PAINT_THEME: LIFX_PAINT_THEME_SCHEMA, +} + + +def _get_manager(service: ServiceCall) -> LIFXManager: + """Return the LIFX manager, raising a user-facing error if unavailable.""" + hass = service.hass + # The manager is stored before the connection and first refresh are awaited, + # so its presence alone does not mean a device is usable. + if (manager := hass.data.get(DATA_LIFX_MANAGER)) is None or all( + async_entry_is_legacy(entry) + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + ) + + return manager + + +async def _async_start_effect(service: ServiceCall) -> None: + """Apply a service, i.e. start an effect.""" + manager = _get_manager(service) + referenced = async_extract_referenced_entity_ids( + service.hass, TargetSelection(service.data) + ) + all_referenced = referenced.referenced | referenced.indirectly_referenced + if all_referenced: + await manager.start_effect(all_referenced, service.service, **service.data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the LIFX effect services.""" + for service, schema in SERVICES_SCHEMA.items(): + hass.services.async_register( + DOMAIN, service, _async_start_effect, schema=schema + ) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index d9f0f7bf02c3..ffadf54c8807 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -50,6 +50,11 @@ } } }, + "exceptions": { + "not_loaded": { + "message": "LIFX is not loaded, so this action cannot be performed." + } + }, "services": { "effect_colorloop": { "description": "Runs an effect with looping colors.", diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index fb290e4f82ad..6fdd886249f9 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -9,13 +9,12 @@ import pytest from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN -from homeassistant.components.lifx.const import ATTR_POWER -from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES -from homeassistant.components.lifx.manager import ( +from homeassistant.components.lifx.const import ( ATTR_CLOUD_SATURATION_MAX, ATTR_CLOUD_SATURATION_MIN, ATTR_DIRECTION, ATTR_PALETTE, + ATTR_POWER, ATTR_SATURATION_MAX, ATTR_SATURATION_MIN, ATTR_SKY_TYPE, @@ -27,6 +26,7 @@ from homeassistant.components.lifx.manager import ( SERVICE_EFFECT_SKY, SERVICE_PAINT_THEME, ) +from homeassistant.components.lifx.light import ATTR_INFRARED, ATTR_ZONES from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, diff --git a/tests/components/lifx/test_services.py b/tests/components/lifx/test_services.py new file mode 100644 index 000000000000..aa6539ad2226 --- /dev/null +++ b/tests/components/lifx/test_services.py @@ -0,0 +1,86 @@ +"""Tests for the LIFX services.""" + +import pytest + +from homeassistant.components.lifx import DOMAIN +from homeassistant.components.lifx.const import ( + DATA_LIFX_MANAGER, + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) +from homeassistant.components.lifx.manager import LIFXManager +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.setup import async_setup_component + +SERVICES = ( + SERVICE_EFFECT_COLORLOOP, + SERVICE_EFFECT_FLAME, + SERVICE_EFFECT_MORPH, + SERVICE_EFFECT_MOVE, + SERVICE_EFFECT_PULSE, + SERVICE_EFFECT_SKY, + SERVICE_EFFECT_STOP, + SERVICE_PAINT_THEME, +) + + +@pytest.mark.usefixtures("mock_discovery") +async def test_services_registered_without_entry(hass: HomeAssistant) -> None: + """Test the effect actions are registered during component setup.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + for service in SERVICES: + assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.parametrize("service", SERVICES) +@pytest.mark.usefixtures("mock_discovery") +async def test_service_without_manager_raises( + hass: HomeAssistant, service: str +) -> None: + """Test the effect actions raise when no config entry is loaded.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: "light.test"}, blocking=True + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "not_loaded" + assert "LIFX is not loaded" in str(err.value) + + +@pytest.mark.parametrize("service", SERVICES) +@pytest.mark.usefixtures("mock_discovery") +async def test_service_with_manager_but_no_loaded_entry_raises( + hass: HomeAssistant, service: str +) -> None: + """Test the actions raise when a failed setup left a manager behind. + + async_setup_entry stores the manager before awaiting the connection and the + first refresh, so a setup retry leaves an empty manager in hass.data. + """ + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + # Simulate the leftover manager from a setup that raised ConfigEntryNotReady. + hass.data[DATA_LIFX_MANAGER] = LIFXManager(hass) + assert not hass.config_entries.async_loaded_entries(DOMAIN) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: "light.test"}, blocking=True + ) + + assert err.value.translation_key == "not_loaded"