diff --git a/CODEOWNERS b/CODEOWNERS index ceb58f370d4..27e06d874e1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -339,6 +339,7 @@ homeassistant/components/weblink/* @home-assistant/core homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @sqldiablo homeassistant/components/withings/* @vangorra +homeassistant/components/wled/* @frenck homeassistant/components/worldclock/* @fabaff homeassistant/components/wwlln/* @bachya homeassistant/components/xbox_live/* @MartinHjelmare diff --git a/homeassistant/components/wled/.translations/en.json b/homeassistant/components/wled/.translations/en.json new file mode 100644 index 00000000000..dde66b8e122 --- /dev/null +++ b/homeassistant/components/wled/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "WLED", + "flow_title": "WLED: {name}", + "step": { + "user": { + "title": "Link your WLED", + "description": "Set up your WLED to integrate with Home Assistant.", + "data": { + "host": "Host or IP address" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the WLED named `{name}` to Home Assistant?", + "title": "Discovered WLED device" + } + }, + "error": { + "connection_error": "Failed to connect to WLED device." + }, + "abort": { + "already_configured": "This WLED device is already configured.", + "connection_error": "Failed to connect to WLED device." + } + } +} diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py new file mode 100644 index 00000000000..62f611b18ec --- /dev/null +++ b/homeassistant/components/wled/__init__.py @@ -0,0 +1,182 @@ +"""Support for WLED.""" +from datetime import timedelta +import logging +from typing import Any, Dict, Optional, Union + +from wled import WLED, WLEDConnectionError, WLEDError + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, + DATA_WLED_CLIENT, + DATA_WLED_TIMER, + DATA_WLED_UPDATED, + DOMAIN, +) + +SCAN_INTERVAL = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the WLED components.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WLED from a config entry.""" + + # Create WLED instance for this entry + session = async_get_clientsession(hass) + wled = WLED(entry.data[CONF_HOST], loop=hass.loop, session=session) + + # Ensure we can connect and talk to it + try: + await wled.update() + except WLEDConnectionError as exception: + raise ConfigEntryNotReady from exception + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_WLED_CLIENT: wled} + + # Set up all platforms for this device/entry. + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, LIGHT_DOMAIN) + ) + + async def interval_update(now: dt_util.dt.datetime = None) -> None: + """Poll WLED device function, dispatches event after update.""" + try: + await wled.update() + except WLEDError: + _LOGGER.debug("An error occurred while updating WLED", exc_info=True) + + # Even if the update failed, we still send out the event. + # To allow entities to make themselves unavailable. + async_dispatcher_send(hass, DATA_WLED_UPDATED, entry.entry_id) + + # Schedule update interval + hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER] = async_track_time_interval( + hass, interval_update, SCAN_INTERVAL + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload WLED config entry.""" + + # Cancel update timer for this entry/device. + cancel_timer = hass.data[DOMAIN][entry.entry_id][DATA_WLED_TIMER] + cancel_timer() + + # Unload entities for this entry/device. + await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) + + # Cleanup + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + + return True + + +class WLEDEntity(Entity): + """Defines a base WLED entity.""" + + def __init__(self, entry_id: str, wled: WLED, name: str, icon: str) -> None: + """Initialize the WLED entity.""" + self._attributes: Dict[str, Union[str, int, float]] = {} + self._available = True + self._entry_id = entry_id + self._icon = icon + self._name = name + self._unsub_dispatcher = None + self.wled = wled + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return False + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the entity.""" + return self._attributes + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DATA_WLED_UPDATED, self._schedule_immediate_update + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect from update signal.""" + self._unsub_dispatcher() + + @callback + def _schedule_immediate_update(self, entry_id: str) -> None: + """Schedule an immediate update of the entity.""" + if entry_id == self._entry_id: + self.async_schedule_update_ha_state(True) + + async def async_update(self) -> None: + """Update WLED entity.""" + if self.wled.device is None: + self._available = False + return + + self._available = True + await self._wled_update() + + async def _wled_update(self) -> None: + """Update WLED entity.""" + raise NotImplementedError() + + +class WLEDDeviceEntity(WLEDEntity): + """Defines a WLED device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this WLED device.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self.wled.device.info.mac_address)}, + ATTR_NAME: self.wled.device.info.name, + ATTR_MANUFACTURER: self.wled.device.info.brand, + ATTR_MODEL: self.wled.device.info.product, + ATTR_SOFTWARE_VERSION: self.wled.device.info.version, + } diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py new file mode 100644 index 00000000000..7be283874e0 --- /dev/null +++ b/homeassistant/components/wled/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow to configure the WLED integration.""" +import logging +from typing import Any, Dict, Optional + +import voluptuous as vol +from wled import WLED, WLEDConnectionError + +from homeassistant.config_entries import ( + CONN_CLASS_LOCAL_POLL, + SOURCE_ZEROCONF, + ConfigFlow, +) +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.helpers import ConfigType +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN # pylint: disable=W0611 + +_LOGGER = logging.getLogger(__name__) + + +class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a WLED config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + return await self._handle_config_flow(user_input) + + async def async_step_zeroconf( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle zeroconf discovery.""" + if user_input is None: + return self.async_abort(reason="connection_error") + + # Hostname is format: wled-livingroom.local. + host = user_input["hostname"].rstrip(".") + name, _ = host.rsplit(".") + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + {CONF_HOST: host, CONF_NAME: name, "title_placeholders": {"name": name}} + ) + + # Prepare configuration flow + return await self._handle_config_flow(user_input, True) + + async def async_step_zeroconf_confirm( + self, user_input: ConfigType = None + ) -> Dict[str, Any]: + """Handle a flow initiated by zeroconf.""" + return await self._handle_config_flow(user_input) + + async def _handle_config_flow( + self, user_input: Optional[ConfigType] = None, prepare: bool = False + ) -> Dict[str, Any]: + """Config flow handler for WLED.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + source = self.context.get("source") + + # Request user input, unless we are preparing discovery flow + if user_input is None and not prepare: + if source == SOURCE_ZEROCONF: + return self._show_confirm_dialog() + return self._show_setup_form() + + if source == SOURCE_ZEROCONF: + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + user_input[CONF_HOST] = self.context.get(CONF_HOST) + + errors = {} + session = async_get_clientsession(self.hass) + wled = WLED(user_input[CONF_HOST], loop=self.hass.loop, session=session) + + try: + device = await wled.update() + except WLEDConnectionError: + if source == SOURCE_ZEROCONF: + return self.async_abort(reason="connection_error") + errors["base"] = "connection_error" + return self._show_setup_form(errors) + + # Check if already configured + mac_address = device.info.mac_address + for entry in self._async_current_entries(): + if entry.data[CONF_MAC] == mac_address: + # This mac address is already configured + return self.async_abort(reason="already_configured") + + title = user_input[CONF_HOST] + if source == SOURCE_ZEROCONF: + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + title = self.context.get(CONF_NAME) + + if prepare: + return await self.async_step_zeroconf_confirm() + + return self.async_create_entry( + title=title, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: mac_address} + ) + + def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors or {}, + ) + + def _show_confirm_dialog(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """Show the confirm dialog to the user.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + name = self.context.get(CONF_NAME) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": name}, + errors=errors or {}, + ) diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py new file mode 100644 index 00000000000..9bc5f64a444 --- /dev/null +++ b/homeassistant/components/wled/const.py @@ -0,0 +1,25 @@ +"""Constants for the WLED integration.""" + +# Integration domain +DOMAIN = "wled" + +# Hass data keys +DATA_WLED_CLIENT = "wled_client" +DATA_WLED_TIMER = "wled_timer" +DATA_WLED_UPDATED = "wled_updated" + +# Attributes +ATTR_COLOR_PRIMARY = "color_primary" +ATTR_DURATION = "duration" +ATTR_IDENTIFIERS = "identifiers" +ATTR_INTENSITY = "intensity" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_ON = "on" +ATTR_PALETTE = "palette" +ATTR_PLAYLIST = "playlist" +ATTR_PRESET = "preset" +ATTR_SEGMENT_ID = "segment_id" +ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_SPEED = "speed" +ATTR_TARGET_BRIGHTNESS = "target_brightness" diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py new file mode 100644 index 00000000000..3d2c9d6ef2c --- /dev/null +++ b/homeassistant/components/wled/light.py @@ -0,0 +1,219 @@ +"""Support for LED lights.""" +import logging +from typing import Any, Callable, List, Optional, Tuple + +from wled import WLED, Effect, WLEDError + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_TRANSITION, + Light, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +import homeassistant.util.color as color_util + +from . import WLEDDeviceEntity +from .const import ( + ATTR_COLOR_PRIMARY, + ATTR_INTENSITY, + ATTR_ON, + ATTR_PALETTE, + ATTR_PLAYLIST, + ATTR_PRESET, + ATTR_SEGMENT_ID, + ATTR_SPEED, + DATA_WLED_CLIENT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up WLED light based on a config entry.""" + wled: WLED = hass.data[DOMAIN][entry.entry_id][DATA_WLED_CLIENT] + + # Does the WLED device support RGBW + rgbw = wled.device.info.leds.rgbw + + # List of supported effects + effects = wled.device.effects + + # WLED supports splitting a strip in multiple segments + # Each segment will be a separate light in Home Assistant + lights = [] + for light in wled.device.state.segments: + lights.append(WLEDLight(entry.entry_id, wled, light.segment_id, rgbw, effects)) + + async_add_entities(lights, True) + + +class WLEDLight(Light, WLEDDeviceEntity): + """Defines a WLED light.""" + + def __init__( + self, entry_id: str, wled: WLED, segment: int, rgbw: bool, effects: List[Effect] + ): + """Initialize WLED light.""" + self._effects = effects + self._rgbw = rgbw + self._segment = segment + + self._brightness: Optional[int] = None + self._color: Optional[Tuple[float, float]] = None + self._effect: Optional[str] = None + self._state: Optional[bool] = None + + # Only apply the segment ID if it is not the first segment + name = wled.device.info.name + if segment != 0: + name += f" {segment}" + + super().__init__(entry_id, wled, name, "mdi:led-strip-variant") + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self.wled.device.info.mac_address}_{self._segment}" + + @property + def hs_color(self) -> Optional[Tuple[float, float]]: + """Return the hue and saturation color value [float, float].""" + return self._color + + @property + def effect(self) -> Optional[str]: + """Return the current effect of the light.""" + return self._effect + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light between 1..255.""" + return self._brightness + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return ( + SUPPORT_BRIGHTNESS + | SUPPORT_COLOR + | SUPPORT_COLOR_TEMP + | SUPPORT_EFFECT + | SUPPORT_TRANSITION + ) + + @property + def effect_list(self) -> List[str]: + """Return the list of supported effects.""" + return [effect.name for effect in self._effects] + + @property + def is_on(self) -> bool: + """Return the state of the light.""" + return bool(self._state) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + try: + await self.wled.light(on=False) + self._state = False + except WLEDError: + _LOGGER.error("An error occurred while turning off WLED light.") + self._available = False + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + data = {ATTR_ON: True, ATTR_SEGMENT_ID: self._segment} + + if ATTR_COLOR_TEMP in kwargs: + mireds = color_util.color_temperature_kelvin_to_mired( + kwargs[ATTR_COLOR_TEMP] + ) + data[ATTR_COLOR_PRIMARY] = tuple( + map(int, color_util.color_temperature_to_rgb(mireds)) + ) + + if ATTR_HS_COLOR in kwargs: + hue, sat = kwargs[ATTR_HS_COLOR] + data[ATTR_COLOR_PRIMARY] = color_util.color_hsv_to_RGB(hue, sat, 100) + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] + + if ATTR_EFFECT in kwargs: + data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] + + # Support for RGBW strips + if self._rgbw and any(x in (ATTR_COLOR_TEMP, ATTR_HS_COLOR) for x in kwargs): + data[ATTR_COLOR_PRIMARY] = color_util.color_rgb_to_rgbw( + *data[ATTR_COLOR_PRIMARY] + ) + + try: + await self.wled.light(**data) + + self._state = True + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + if ATTR_EFFECT in kwargs: + self._effect = kwargs[ATTR_EFFECT] + + if ATTR_HS_COLOR in kwargs: + self._color = kwargs[ATTR_HS_COLOR] + + if ATTR_COLOR_TEMP in kwargs: + self._color = color_util.color_temperature_to_hs(mireds) + + except WLEDError: + _LOGGER.error("An error occurred while turning on WLED light.") + self._available = False + self.async_schedule_update_ha_state() + + async def _wled_update(self) -> None: + """Update WLED entity.""" + self._brightness = self.wled.device.state.brightness + self._effect = self.wled.device.state.segments[self._segment].effect.name + self._state = self.wled.device.state.on + + color = self.wled.device.state.segments[self._segment].color_primary + if self._rgbw: + color = color_util.color_rgbw_to_rgb(*color) + self._color = color_util.color_RGB_to_hs(*color) + + playlist = self.wled.device.state.playlist + if playlist == -1: + playlist = None + + preset = self.wled.device.state.preset + if preset == -1: + preset = None + + self._attributes = { + ATTR_INTENSITY: self.wled.device.state.segments[self._segment].intensity, + ATTR_PALETTE: self.wled.device.state.segments[self._segment].palette.name, + ATTR_PLAYLIST: playlist, + ATTR_PRESET: preset, + ATTR_SPEED: self.wled.device.state.segments[self._segment].speed, + } diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json new file mode 100644 index 00000000000..97e46998511 --- /dev/null +++ b/homeassistant/components/wled/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "wled", + "name": "WLED", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wled", + "requirements": ["wled==0.1.0"], + "dependencies": [], + "zeroconf": ["_wled._tcp.local."], + "codeowners": ["@frenck"] +} diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json new file mode 100644 index 00000000000..dde66b8e122 --- /dev/null +++ b/homeassistant/components/wled/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "WLED", + "flow_title": "WLED: {name}", + "step": { + "user": { + "title": "Link your WLED", + "description": "Set up your WLED to integrate with Home Assistant.", + "data": { + "host": "Host or IP address" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the WLED named `{name}` to Home Assistant?", + "title": "Discovered WLED device" + } + }, + "error": { + "connection_error": "Failed to connect to WLED device." + }, + "abort": { + "already_configured": "This WLED device is already configured.", + "connection_error": "Failed to connect to WLED device." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 22d36fc46c6..519df86f5e9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,6 +81,7 @@ FLOWS = [ "vesync", "wemo", "withings", + "wled", "wwlln", "zha", "zone", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6200e2facb0..108fe38e647 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -20,6 +20,9 @@ ZEROCONF = { ], "_hap._tcp.local.": [ "homekit_controller" + ], + "_wled._tcp.local.": [ + "wled" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 3f5ac68cc8e..ac6529ff869 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1995,6 +1995,9 @@ wirelesstagpy==0.4.0 # homeassistant.components.withings withings-api==2.1.3 +# homeassistant.components.wled +wled==0.1.0 + # homeassistant.components.wunderlist wunderpy2==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecf8d00d648..605e244ace1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -617,6 +617,9 @@ websockets==6.0 # homeassistant.components.withings withings-api==2.1.3 +# homeassistant.components.wled +wled==0.1.0 + # homeassistant.components.bluesound # homeassistant.components.startca # homeassistant.components.ted5000 diff --git a/tests/components/wled/__init__.py b/tests/components/wled/__init__.py new file mode 100644 index 00000000000..41cbbf01074 --- /dev/null +++ b/tests/components/wled/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the WLED integration.""" + +from homeassistant.components.wled.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def init_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + rgbw: bool = False, + skip_setup: bool = False, +) -> MockConfigEntry: + """Set up the WLED integration in Home Assistant.""" + + fixture = "wled/rgb.json" if not rgbw else "wled/rgbw.json" + aioclient_mock.get( + "http://example.local:80/json/", + text=load_fixture(fixture), + headers={"Content-Type": "application/json"}, + ) + + aioclient_mock.post( + "http://example.local:80/json/state", + json={"success": True}, + headers={"Content-Type": "application/json"}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "example.local", CONF_MAC: "aabbccddeeff"} + ) + + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py new file mode 100644 index 00000000000..322a068150b --- /dev/null +++ b/tests/components/wled/test_config_flow.py @@ -0,0 +1,207 @@ +"""Tests for the WLED config flow.""" +import aiohttp + +from homeassistant import data_entry_flow +from homeassistant.components.wled import config_flow +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_USER} + result = await flow.async_step_user(user_input=None) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_show_zeroconf_confirm_form(hass: HomeAssistant) -> None: + """Test that the zeroconf confirmation form is served.""" + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF, CONF_NAME: "test"} + result = await flow.async_step_zeroconf_confirm() + + assert result["description_placeholders"] == {CONF_NAME: "test"} + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_show_zerconf_form( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that the zeroconf confirmation form is served.""" + aioclient_mock.get( + "http://example.local:80/json/", + text=load_fixture("wled/rgb.json"), + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + result = await flow.async_step_zeroconf({"hostname": "example.local."}) + + assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_NAME] == "example" + assert result["description_placeholders"] == {CONF_NAME: "example"} + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on WLED connection error.""" + aioclient_mock.get("http://example.com/json/", exc=aiohttp.ClientError) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_USER} + result = await flow.async_step_user(user_input={CONF_HOST: "example.com"}) + + assert result["errors"] == {"base": "connection_error"} + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow on WLED connection error.""" + aioclient_mock.get("http://example.local/json/", exc=aiohttp.ClientError) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + result = await flow.async_step_zeroconf(user_input={"hostname": "example.local."}) + + assert result["reason"] == "connection_error" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_zeroconf_confirm_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow on WLED connection error.""" + aioclient_mock.get("http://example.com/json/", exc=aiohttp.ClientError) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = { + "source": SOURCE_ZEROCONF, + CONF_HOST: "example.com", + CONF_NAME: "test", + } + result = await flow.async_step_zeroconf_confirm( + user_input={CONF_HOST: "example.com"} + ) + + assert result["reason"] == "connection_error" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_zeroconf_no_data( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort if zeroconf provides no data.""" + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + result = await flow.async_step_zeroconf() + + assert result["reason"] == "connection_error" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow if WLED device already configured.""" + await init_integration(hass, aioclient_mock) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_USER} + result = await flow.async_step_user({CONF_HOST: "example.local"}) + + assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow if WLED device already configured.""" + await init_integration(hass, aioclient_mock) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + result = await flow.async_step_zeroconf({"hostname": "example.local."}) + + assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.get( + "http://example.local:80/json/", + text=load_fixture("wled/rgb.json"), + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_USER} + result = await flow.async_step_user(user_input=None) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_user(user_input={CONF_HOST: "example.local"}) + assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_MAC] == "aabbccddeeff" + assert result["title"] == "example.local" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.get( + "http://example.local:80/json/", + text=load_fixture("wled/rgb.json"), + headers={"Content-Type": "application/json"}, + ) + + flow = config_flow.WLEDFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + result = await flow.async_step_zeroconf({"hostname": "example.local."}) + + assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_NAME] == "example" + assert result["description_placeholders"] == {CONF_NAME: "example"} + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_zeroconf_confirm( + user_input={CONF_HOST: "example.local"} + ) + assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_MAC] == "aabbccddeeff" + assert result["title"] == "example" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py new file mode 100644 index 00000000000..a565dcfb181 --- /dev/null +++ b/tests/components/wled/test_init.py @@ -0,0 +1,60 @@ +"""Tests for the WLED integration.""" +import aiohttp +from asynctest import patch + +from homeassistant.components.wled.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.wled import init_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_config_entry_not_ready( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the WLED configuration entry not ready.""" + aioclient_mock.get("http://example.local:80/json/", exc=aiohttp.ClientError) + + entry = await init_integration(hass, aioclient_mock) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the WLED configuration entry unloading.""" + entry = await init_integration(hass, aioclient_mock) + assert hass.data[DOMAIN] + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) + + +async def test_interval_update( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the WLED configuration entry unloading.""" + entry = await init_integration(hass, aioclient_mock, skip_setup=True) + + interval_action = False + + def async_track_time_interval(hass, action, interval): + nonlocal interval_action + interval_action = action + + with patch( + "homeassistant.components.wled.async_track_time_interval", + new=async_track_time_interval, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert interval_action + await interval_action() # pylint: disable=not-callable + await hass.async_block_till_done() + + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_ON diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py new file mode 100644 index 00000000000..185a25b0507 --- /dev/null +++ b/tests/components/wled/test_light.py @@ -0,0 +1,179 @@ +"""Tests for the WLED light platform.""" +import aiohttp + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.components.wled.const import ( + ATTR_INTENSITY, + ATTR_PALETTE, + ATTR_PLAYLIST, + ATTR_PRESET, + ATTR_SPEED, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from tests.components.wled import init_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_rgb_light_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the creation and values of the WLED lights.""" + await init_integration(hass, aioclient_mock) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # First segment of the strip + state = hass.states.get("light.wled_rgb_light") + assert state + assert state.attributes.get(ATTR_BRIGHTNESS) == 127 + assert state.attributes.get(ATTR_EFFECT) == "Solid" + assert state.attributes.get(ATTR_HS_COLOR) == (37.412, 100.0) + assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_INTENSITY) == 128 + assert state.attributes.get(ATTR_PALETTE) == "Default" + assert state.attributes.get(ATTR_PLAYLIST) is None + assert state.attributes.get(ATTR_PRESET) is None + assert state.attributes.get(ATTR_SPEED) == 32 + assert state.state == STATE_ON + + entry = entity_registry.async_get("light.wled_rgb_light") + assert entry + assert entry.unique_id == "aabbccddeeff_0" + + # Second segment of the strip + state = hass.states.get("light.wled_rgb_light_1") + assert state + assert state.attributes.get(ATTR_BRIGHTNESS) == 127 + assert state.attributes.get(ATTR_EFFECT) == "Blink" + assert state.attributes.get(ATTR_HS_COLOR) == (148.941, 100.0) + assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" + assert state.attributes.get(ATTR_INTENSITY) == 64 + assert state.attributes.get(ATTR_PALETTE) == "Random Cycle" + assert state.attributes.get(ATTR_PLAYLIST) is None + assert state.attributes.get(ATTR_PRESET) is None + assert state.attributes.get(ATTR_SPEED) == 16 + assert state.state == STATE_ON + + entry = entity_registry.async_get("light.wled_rgb_light_1") + assert entry + assert entry.unique_id == "aabbccddeeff_1" + + +async def test_switch_change_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the change of state of the WLED switches.""" + await init_integration(hass, aioclient_mock) + + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.wled_rgb_light"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_BRIGHTNESS: 42, + ATTR_EFFECT: "Chase", + ATTR_ENTITY_ID: "light.wled_rgb_light", + ATTR_RGB_COLOR: [255, 0, 0], + ATTR_TRANSITION: 5, + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 42 + assert state.attributes.get(ATTR_EFFECT) == "Chase" + assert state.attributes.get(ATTR_HS_COLOR) == (0.0, 100.0) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_COLOR_TEMP: 400}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_HS_COLOR) == (28.874, 72.522) + + +async def test_light_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test error handling of the WLED switches.""" + aioclient_mock.post("http://example.local:80/json/state", exc=aiohttp.ClientError) + await init_integration(hass, aioclient_mock) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.wled_rgb_light"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.wled_rgb_light") + assert state.state == STATE_UNAVAILABLE + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wled_rgb_light_1"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.wled_rgb_light_1") + assert state.state == STATE_UNAVAILABLE + + +async def test_rgbw_light( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test RGBW support for WLED.""" + await init_integration(hass, aioclient_mock, rgbw=True) + + state = hass.states.get("light.wled_rgbw_light") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_HS_COLOR) == (0.0, 64.706) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wled_rgbw_light", ATTR_COLOR_TEMP: 400}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wled_rgbw_light") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_HS_COLOR) == (28.874, 72.522) diff --git a/tests/fixtures/wled/rgb.json b/tests/fixtures/wled/rgb.json new file mode 100644 index 00000000000..70a54f06644 --- /dev/null +++ b/tests/fixtures/wled/rgb.json @@ -0,0 +1,210 @@ +{ + "state": { + "on": true, + "bri": 127, + "transition": 7, + "ps": -1, + "pl": -1, + "nl": { + "on": false, + "dur": 60, + "fade": true, + "tbri": 0 + }, + "udpn": { + "send": false, + "recv": true + }, + "seg": [ + { + "id": 0, + "start": 0, + "stop": 19, + "len": 20, + "col": [[255, 159, 0], [0, 0, 0], [0, 0, 0]], + "fx": 0, + "sx": 32, + "ix": 128, + "pal": 0, + "sel": true, + "rev": false, + "cln": -1 + }, + { + "id": 1, + "start": 20, + "stop": 30, + "len": 10, + "col": [[0, 255, 123], [0, 0, 0], [0, 0, 0]], + "fx": 1, + "sx": 16, + "ix": 64, + "pal": 1, + "sel": true, + "rev": false, + "cln": -1 + } + ] + }, + "info": { + "ver": "0.8.5", + "vid": 1909122, + "leds": { + "count": 30, + "rgbw": false, + "pin": [2], + "pwr": 470, + "maxpwr": 850, + "maxseg": 10 + }, + "name": "WLED RGB Light", + "udpport": 21324, + "live": false, + "fxcount": 81, + "palcount": 50, + "arch": "esp8266", + "core": "2_4_2", + "freeheap": 14600, + "uptime": 32, + "opt": 119, + "brand": "WLED", + "product": "DIY light", + "btype": "bin", + "mac": "aabbccddeeff" + }, + "effects": [ + "Solid", + "Blink", + "Breathe", + "Wipe", + "Wipe Random", + "Random Colors", + "Sweep", + "Dynamic", + "Colorloop", + "Rainbow", + "Scan", + "Dual Scan", + "Fade", + "Chase", + "Chase Rainbow", + "Running", + "Saw", + "Twinkle", + "Dissolve", + "Dissolve Rnd", + "Sparkle", + "Dark Sparkle", + "Sparkle+", + "Strobe", + "Strobe Rainbow", + "Mega Strobe", + "Blink Rainbow", + "Android", + "Chase", + "Chase Random", + "Chase Rainbow", + "Chase Flash", + "Chase Flash Rnd", + "Rainbow Runner", + "Colorful", + "Traffic Light", + "Sweep Random", + "Running 2", + "Red & Blue", + "Stream", + "Scanner", + "Lighthouse", + "Fireworks", + "Rain", + "Merry Christmas", + "Fire Flicker", + "Gradient", + "Loading", + "In Out", + "In In", + "Out Out", + "Out In", + "Circus", + "Halloween", + "Tri Chase", + "Tri Wipe", + "Tri Fade", + "Lightning", + "ICU", + "Multi Comet", + "Dual Scanner", + "Stream 2", + "Oscillate", + "Pride 2015", + "Juggle", + "Palette", + "Fire 2012", + "Colorwaves", + "BPM", + "Fill Noise", + "Noise 1", + "Noise 2", + "Noise 3", + "Noise 4", + "Colortwinkle", + "Lake", + "Meteor", + "Smooth Meteor", + "Railway", + "Ripple", + "Twinklefox" + ], + "palettes": [ + "Default", + "Random Cycle", + "Primary Color", + "Based on Primary", + "Set Colors", + "Based on Set", + "Party", + "Cloud", + "Lava", + "Ocean", + "Forest", + "Rainbow", + "Rainbow Bands", + "Sunset", + "Rivendell", + "Breeze", + "Red & Blue", + "Yellowout", + "Analogous", + "Splash", + "Pastel", + "Sunset 2", + "Beech", + "Vintage", + "Departure", + "Landscape", + "Beach", + "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" + ] +} diff --git a/tests/fixtures/wled/rgbw.json b/tests/fixtures/wled/rgbw.json new file mode 100644 index 00000000000..0d51dfedd2d --- /dev/null +++ b/tests/fixtures/wled/rgbw.json @@ -0,0 +1,198 @@ +{ + "state": { + "on": true, + "bri": 140, + "transition": 7, + "ps": -1, + "pl": -1, + "nl": { + "on": false, + "dur": 60, + "fade": true, + "tbri": 0 + }, + "udpn": { + "send": false, + "recv": true + }, + "seg": [ + { + "id": 0, + "start": 0, + "stop": 13, + "len": 13, + "col": [[255, 0, 0, 139], [0, 0, 0, 0], [0, 0, 0, 0]], + "fx": 9, + "sx": 165, + "ix": 128, + "pal": 0, + "sel": true, + "rev": false, + "cln": -1 + } + ] + }, + "info": { + "ver": "0.8.6", + "vid": 1910255, + "leds": { + "count": 13, + "rgbw": true, + "pin": [2], + "pwr": 208, + "maxpwr": 850, + "maxseg": 10 + }, + "name": "WLED RGBW Light", + "udpport": 21324, + "live": false, + "fxcount": 83, + "palcount": 50, + "arch": "esp8266", + "core": "2_5_2", + "freeheap": 20136, + "uptime": 5591, + "opt": 119, + "brand": "WLED", + "product": "DIY light", + "btype": "bin", + "mac": "aabbccddee11" + }, + "effects": [ + "Solid", + "Blink", + "Breathe", + "Wipe", + "Wipe Random", + "Random Colors", + "Sweep", + "Dynamic", + "Colorloop", + "Rainbow", + "Scan", + "Dual Scan", + "Fade", + "Chase", + "Chase Rainbow", + "Running", + "Saw", + "Twinkle", + "Dissolve", + "Dissolve Rnd", + "Sparkle", + "Dark Sparkle", + "Sparkle+", + "Strobe", + "Strobe Rainbow", + "Mega Strobe", + "Blink Rainbow", + "Android", + "Chase", + "Chase Random", + "Chase Rainbow", + "Chase Flash", + "Chase Flash Rnd", + "Rainbow Runner", + "Colorful", + "Traffic Light", + "Sweep Random", + "Running 2", + "Red & Blue", + "Stream", + "Scanner", + "Lighthouse", + "Fireworks", + "Rain", + "Merry Christmas", + "Fire Flicker", + "Gradient", + "Loading", + "In Out", + "In In", + "Out Out", + "Out In", + "Circus", + "Halloween", + "Tri Chase", + "Tri Wipe", + "Tri Fade", + "Lightning", + "ICU", + "Multi Comet", + "Dual Scanner", + "Stream 2", + "Oscillate", + "Pride 2015", + "Juggle", + "Palette", + "Fire 2012", + "Colorwaves", + "BPM", + "Fill Noise", + "Noise 1", + "Noise 2", + "Noise 3", + "Noise 4", + "Colortwinkles", + "Lake", + "Meteor", + "Smooth Meteor", + "Railway", + "Ripple", + "Twinklefox", + "Twinklecat", + "Halloween Eyes" + ], + "palettes": [ + "Default", + "Random Cycle", + "Primary Color", + "Based on Primary", + "Set Colors", + "Based on Set", + "Party", + "Cloud", + "Lava", + "Ocean", + "Forest", + "Rainbow", + "Rainbow Bands", + "Sunset", + "Rivendell", + "Breeze", + "Red & Blue", + "Yellowout", + "Analogous", + "Splash", + "Pastel", + "Sunset 2", + "Beech", + "Vintage", + "Departure", + "Landscape", + "Beach", + "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" + ] +}