From a47f73244c0b3fbe4210ffd7731be69b3984fbbd Mon Sep 17 00:00:00 2001 From: Leonardo Figueiro <leoagfig@gmail.com> Date: Mon, 24 Aug 2020 09:15:07 -0300 Subject: [PATCH] Add Wilight integration with SSDP (#36694) Co-authored-by: Martin Hjelmare <marhje52@gmail.com> --- CODEOWNERS | 1 + homeassistant/components/wilight/__init__.py | 125 ++++++ .../components/wilight/config_flow.py | 106 +++++ homeassistant/components/wilight/const.py | 14 + homeassistant/components/wilight/light.py | 179 +++++++++ .../components/wilight/manifest.json | 14 + .../components/wilight/parent_device.py | 102 +++++ homeassistant/components/wilight/strings.json | 16 + .../components/wilight/translations/en.json | 16 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/wilight/__init__.py | 83 ++++ tests/components/wilight/test_config_flow.py | 157 ++++++++ tests/components/wilight/test_init.py | 65 +++ tests/components/wilight/test_light.py | 369 ++++++++++++++++++ 17 files changed, 1259 insertions(+) create mode 100644 homeassistant/components/wilight/__init__.py create mode 100644 homeassistant/components/wilight/config_flow.py create mode 100644 homeassistant/components/wilight/const.py create mode 100644 homeassistant/components/wilight/light.py create mode 100644 homeassistant/components/wilight/manifest.json create mode 100644 homeassistant/components/wilight/parent_device.py create mode 100644 homeassistant/components/wilight/strings.json create mode 100644 homeassistant/components/wilight/translations/en.json create mode 100644 tests/components/wilight/__init__.py create mode 100644 tests/components/wilight/test_config_flow.py create mode 100644 tests/components/wilight/test_init.py create mode 100644 tests/components/wilight/test_light.py diff --git a/CODEOWNERS b/CODEOWNERS index d91591a4526..eb9c736bc5e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -474,6 +474,7 @@ homeassistant/components/weather/* @fabaff homeassistant/components/webostv/* @bendavid homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wiffi/* @mampfes +homeassistant/components/wilight/* @leofig-rj homeassistant/components/withings/* @vangorra homeassistant/components/wled/* @frenck homeassistant/components/wolflink/* @adamkrol93 diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py new file mode 100644 index 00000000000..8821190bd32 --- /dev/null +++ b/homeassistant/components/wilight/__init__.py @@ -0,0 +1,125 @@ +"""The WiLight integration.""" +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .parent_device import WiLightParent + +# List the platforms that you want to support. +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the WiLight with Config Flow component.""" + + hass.data[DOMAIN] = {} + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up a wilight config entry.""" + + parent = WiLightParent(hass, entry) + + if not await parent.async_setup(): + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = parent + + # Set up all platforms for this device/entry. + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload WiLight config entry.""" + + # Unload entities for this entry/device. + await asyncio.gather( + *( + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ) + ) + + # Cleanup + parent = hass.data[DOMAIN][entry.entry_id] + await parent.async_reset() + del hass.data[DOMAIN][entry.entry_id] + + return True + + +class WiLightDevice(Entity): + """Representation of a WiLight device. + + Contains the common logic for WiLight entities. + """ + + def __init__(self, api_device, index, item_name): + """Initialize the device.""" + # WiLight specific attributes for every component type + self._device_id = api_device.device_id + self._sw_version = api_device.swversion + self._client = api_device.client + self._model = api_device.model + self._name = item_name + self._index = index + self._unique_id = f"{self._device_id}_{self._index}" + self._status = {} + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return a name for this WiLight item.""" + return self._name + + @property + def unique_id(self): + """Return the unique ID for this WiLight item.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + "name": self._name, + "identifiers": {(DOMAIN, self._unique_id)}, + "model": self._model, + "manufacturer": "WiLight", + "sw_version": self._sw_version, + "via_device": (DOMAIN, self._device_id), + } + + @property + def available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def handle_event_callback(self, states): + """Propagate changes through ha.""" + self._status = states + self.async_write_ha_state() + + async def async_update(self): + """Synchronize state with api_device.""" + await self._client.status(self._index) + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback(self.handle_event_callback, self._index) + await self._client.status(self._index) diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py new file mode 100644 index 00000000000..724bbb6547d --- /dev/null +++ b/homeassistant/components/wilight/config_flow.py @@ -0,0 +1,106 @@ +"""Config flow to configure WiLight.""" +import logging +from urllib.parse import urlparse + +import pywilight + +from homeassistant.components import ssdp +from homeassistant.config_entries import CONN_CLASS_LOCAL_PUSH, ConfigFlow +from homeassistant.const import CONF_HOST + +from .const import DOMAIN # pylint: disable=unused-import + +CONF_SERIAL_NUMBER = "serial_number" +CONF_MODEL_NAME = "model_name" + +WILIGHT_MANUFACTURER = "All Automacao Ltda" + +# List the components supported by this integration. +ALLOWED_WILIGHT_COMPONENTS = ["light"] + +_LOGGER = logging.getLogger(__name__) + + +class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a WiLight config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the WiLight flow.""" + self._host = None + self._serial_number = None + self._title = None + self._model_name = None + self._wilight_components = [] + self._components_text = "" + + def _wilight_update(self, host, serial_number, model_name): + self._host = host + self._serial_number = serial_number + self._title = f"WL{serial_number}" + self._model_name = model_name + self._wilight_components = pywilight.get_components_from_model(model_name) + self._components_text = ", ".join(self._wilight_components) + return self._components_text != "" + + def _get_entry(self): + data = { + CONF_HOST: self._host, + CONF_SERIAL_NUMBER: self._serial_number, + CONF_MODEL_NAME: self._model_name, + } + return self.async_create_entry(title=self._title, data=data) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered WiLight.""" + # Filter out basic information + if ( + ssdp.ATTR_SSDP_LOCATION not in discovery_info + or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info + or ssdp.ATTR_UPNP_SERIAL not in discovery_info + or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info + or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info + ): + return self.async_abort(reason="not_wilight_device") + # Filter out non-WiLight devices + if discovery_info[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER: + return self.async_abort(reason="not_wilight_device") + + host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] + model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME] + + if not self._wilight_update(host, serial_number, model_name): + return self.async_abort(reason="not_wilight_device") + + # Check if all components of this WiLight are allowed in this version of the HA integration + component_ok = all( + wilight_component in ALLOWED_WILIGHT_COMPONENTS + for wilight_component in self._wilight_components + ) + + if not component_ok: + return self.async_abort(reason="not_supported_device") + + await self.async_set_unique_id(self._serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = {"name": self._title} + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Handle user-confirmation of discovered WiLight.""" + if user_input is not None: + return self._get_entry() + + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "name": self._title, + "components": self._components_text, + }, + errors={}, + ) diff --git a/homeassistant/components/wilight/const.py b/homeassistant/components/wilight/const.py new file mode 100644 index 00000000000..a3d77da44ef --- /dev/null +++ b/homeassistant/components/wilight/const.py @@ -0,0 +1,14 @@ +"""Constants for the WiLight integration.""" + +DOMAIN = "wilight" + +# Item types +ITEM_LIGHT = "light" + +# Light types +LIGHT_ON_OFF = "light_on_off" +LIGHT_DIMMER = "light_dimmer" +LIGHT_COLOR = "light_rgb" + +# Light service support +SUPPORT_NONE = 0 diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py new file mode 100644 index 00000000000..e4bf504165d --- /dev/null +++ b/homeassistant/components/wilight/light.py @@ -0,0 +1,179 @@ +"""Support for WiLight lights.""" + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import WiLightDevice +from .const import ( + DOMAIN, + ITEM_LIGHT, + LIGHT_COLOR, + LIGHT_DIMMER, + LIGHT_ON_OFF, + SUPPORT_NONE, +) + + +def entities_from_discovered_wilight(hass, api_device): + """Parse configuration and add WiLight light entities.""" + entities = [] + for item in api_device.items: + if item["type"] != ITEM_LIGHT: + continue + index = item["index"] + item_name = item["name"] + if item["sub_type"] == LIGHT_ON_OFF: + entity = WiLightLightOnOff(api_device, index, item_name) + elif item["sub_type"] == LIGHT_DIMMER: + entity = WiLightLightDimmer(api_device, index, item_name) + elif item["sub_type"] == LIGHT_COLOR: + entity = WiLightLightColor(api_device, index, item_name) + else: + continue + entities.append(entity) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up WiLight lights from a config entry.""" + parent = hass.data[DOMAIN][entry.entry_id] + + # Handle a discovered WiLight device. + entities = entities_from_discovered_wilight(hass, parent.api) + async_add_entities(entities) + + +class WiLightLightOnOff(WiLightDevice, LightEntity): + """Representation of a WiLights light on-off.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_NONE + + @property + def is_on(self): + """Return true if device is on.""" + return self._status.get("on") + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._client.turn_on(self._index) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._index) + + +class WiLightLightDimmer(WiLightDevice, LightEntity): + """Representation of a WiLights light dimmer.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._status.get("brightness", 0)) + + @property + def is_on(self): + """Return true if device is on.""" + return self._status.get("on") + + async def async_turn_on(self, **kwargs): + """Turn the device on,set brightness if needed.""" + # Dimmer switches use a range of [0, 255] to control + # brightness. Level 255 might mean to set it to previous value + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + await self._client.set_brightness(self._index, brightness) + else: + await self._client.turn_on(self._index) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._index) + + +def wilight_to_hass_hue(value): + """Convert wilight hue 1..255 to hass 0..360 scale.""" + return min(360, round((value * 360) / 255, 3)) + + +def hass_to_wilight_hue(value): + """Convert hass hue 0..360 to wilight 1..255 scale.""" + return min(255, round((value * 255) / 360)) + + +def wilight_to_hass_saturation(value): + """Convert wilight saturation 1..255 to hass 0..100 scale.""" + return min(100, round((value * 100) / 255, 3)) + + +def hass_to_wilight_saturation(value): + """Convert hass saturation 0..100 to wilight 1..255 scale.""" + return min(255, round((value * 255) / 100)) + + +class WiLightLightColor(WiLightDevice, LightEntity): + """Representation of a WiLights light rgb.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._status.get("brightness", 0)) + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + return [ + wilight_to_hass_hue(int(self._status.get("hue", 0))), + wilight_to_hass_saturation(int(self._status.get("saturation", 0))), + ] + + @property + def is_on(self): + """Return true if device is on.""" + return self._status.get("on") + + async def async_turn_on(self, **kwargs): + """Turn the device on,set brightness if needed.""" + # Brightness use a range of [0, 255] to control + # Hue use a range of [0, 360] to control + # Saturation use a range of [0, 100] to control + if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + hue = hass_to_wilight_hue(kwargs[ATTR_HS_COLOR][0]) + saturation = hass_to_wilight_saturation(kwargs[ATTR_HS_COLOR][1]) + await self._client.set_hsb_color(self._index, hue, saturation, brightness) + elif ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR not in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + await self._client.set_brightness(self._index, brightness) + elif ATTR_BRIGHTNESS not in kwargs and ATTR_HS_COLOR in kwargs: + hue = hass_to_wilight_hue(kwargs[ATTR_HS_COLOR][0]) + saturation = hass_to_wilight_saturation(kwargs[ATTR_HS_COLOR][1]) + await self._client.set_hs_color(self._index, hue, saturation) + else: + await self._client.turn_on(self._index) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._index) diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json new file mode 100644 index 00000000000..bb20da2b1ce --- /dev/null +++ b/homeassistant/components/wilight/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "wilight", + "name": "WiLight", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wilight", + "requirements": ["pywilight==0.0.65"], + "ssdp": [ + { + "manufacturer": "All Automacao Ltda" + } + ], + "codeowners": ["@leofig-rj"], + "quality_scale": "silver" +} diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py new file mode 100644 index 00000000000..9c2c0c1a1de --- /dev/null +++ b/homeassistant/components/wilight/parent_device.py @@ -0,0 +1,102 @@ +"""The WiLight Device integration.""" +import asyncio +import logging + +import pywilight +import requests + +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +_LOGGER = logging.getLogger(__name__) + + +class WiLightParent: + """Manages a single WiLight Parent Device.""" + + def __init__(self, hass, config_entry): + """Initialize the system.""" + self._host = config_entry.data[CONF_HOST] + self._hass = hass + self._api = None + + @property + def host(self): + """Return the host of this parent.""" + return self._host + + @property + def api(self): + """Return the api of this parent.""" + return self._api + + async def async_setup(self): + """Set up a WiLight Parent Device based on host parameter.""" + host = self._host + hass = self._hass + + api_device = await hass.async_add_executor_job(create_api_device, host) + + if api_device is None: + return False + + @callback + def disconnected(): + # Schedule reconnect after connection has been lost. + _LOGGER.warning("WiLight %s disconnected", api_device.device_id) + async_dispatcher_send( + hass, f"wilight_device_available_{api_device.device_id}", False + ) + + @callback + def reconnected(): + # Schedule reconnect after connection has been lost. + _LOGGER.warning("WiLight %s reconnect", api_device.device_id) + async_dispatcher_send( + hass, f"wilight_device_available_{api_device.device_id}", True + ) + + async def connect(api_device): + # Set up connection and hook it into HA for reconnect/shutdown. + _LOGGER.debug("Initiating connection to %s", api_device.device_id) + + client = await api_device.config_client( + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=asyncio.get_running_loop(), + logger=_LOGGER, + ) + + # handle shutdown of WiLight asyncio transport + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() + ) + + _LOGGER.info("Connected to WiLight device: %s", api_device.device_id) + + await connect(api_device) + + self._api = api_device + + return True + + async def async_reset(self): + """Reset api.""" + + # If the initialization was wrong. + if self._api is None: + return True + + self._api.client.stop() + + +def create_api_device(host): + """Create an API Device.""" + try: + device = pywilight.device_from_host(host) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout,) as err: + _LOGGER.error("Unable to access WiLight at %s (%s)", host, err) + return None + + return device diff --git a/homeassistant/components/wilight/strings.json b/homeassistant/components/wilight/strings.json new file mode 100644 index 00000000000..710543a5a53 --- /dev/null +++ b/homeassistant/components/wilight/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "flow_title": "WiLight: {name}", + "step": { + "confirm": { + "title": "WiLight", + "description": "Do you want to set up WiLight {name}?\n\n It supports: {components}" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_supported_device": "This WiLight is currently not supported", + "not_wilight_device": "This Device is not WiLight" + } + } +} diff --git a/homeassistant/components/wilight/translations/en.json b/homeassistant/components/wilight/translations/en.json new file mode 100644 index 00000000000..fb121e3b2fa --- /dev/null +++ b/homeassistant/components/wilight/translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured_device": "This WiLight is already configured", + "not_supported_device": "This WiLight is currently not supported", + "not_wilight_device": "This Device is not WiLight" + }, + "flow_title": "WiLight: {name}", + "step": { + "confirm": { + "title": "WiLight", + "description": "Do you want to set up WiLight {name}?\n\n It supports: {components}" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b5db34ec485..ac9ebae5264 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -199,6 +199,7 @@ FLOWS = [ "volumio", "wemo", "wiffi", + "wilight", "withings", "wled", "wolflink", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index d58842fe88e..f66c5f0999d 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -172,5 +172,10 @@ SSDP = { { "manufacturer": "Belkin International Inc." } + ], + "wilight": [ + { + "manufacturer": "All Automacao Ltda" + } ] } diff --git a/requirements_all.txt b/requirements_all.txt index 153cb8117ad..3b60480f7da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,6 +1852,9 @@ pywebpush==1.9.2 # homeassistant.components.wemo pywemo==0.4.46 +# homeassistant.components.wilight +pywilight==0.0.65 + # homeassistant.components.xeoma pyxeoma==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3f45827bc4..f2c11ca739a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -857,6 +857,9 @@ pyvolumio==0.1.1 # homeassistant.components.html5 pywebpush==1.9.2 +# homeassistant.components.wilight +pywilight==0.0.65 + # homeassistant.components.zerproc pyzerproc==0.2.5 diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py new file mode 100644 index 00000000000..9c7ba13fc7c --- /dev/null +++ b/tests/components/wilight/__init__.py @@ -0,0 +1,83 @@ +"""Tests for the WiLight component.""" +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL, +) +from homeassistant.components.wilight.config_flow import ( + CONF_MODEL_NAME, + CONF_SERIAL_NUMBER, +) +from homeassistant.components.wilight.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + +HOST = "127.0.0.1" +WILIGHT_ID = "000000000099" +SSDP_LOCATION = "http://127.0.0.1/" +UPNP_MANUFACTURER = "All Automacao Ltda" +UPNP_MODEL_NAME_P_B = "WiLight 0102001800010009-10010010" +UPNP_MODEL_NAME_DIMMER = "WiLight 0100001700020009-10010010" +UPNP_MODEL_NAME_COLOR = "WiLight 0107001800020009-11010" +UPNP_MODEL_NAME_LIGHT_FAN = "WiLight 0104001800010009-10" +UPNP_MODEL_NUMBER = "123456789012345678901234567890123456" +UPNP_SERIAL = "000000000099" +UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56" +UPNP_MANUFACTURER_NOT_WILIGHT = "Test" +CONF_COMPONENTS = "components" + +MOCK_SSDP_DISCOVERY_INFO_P_B = { + ATTR_SSDP_LOCATION: SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: UPNP_SERIAL, +} + +MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER = { + ATTR_SSDP_LOCATION: SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER_NOT_WILIGHT, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, +} + +MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER = { + ATTR_SSDP_LOCATION: SSDP_LOCATION, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, +} + +MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN = { + ATTR_SSDP_LOCATION: SSDP_LOCATION, + ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER, + ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_LIGHT_FAN, + ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER, + ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL, +} + + +async def setup_integration(hass: HomeAssistantType,) -> MockConfigEntry: + """Mock ConfigEntry in Home Assistant.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WILIGHT_ID, + data={ + CONF_HOST: HOST, + CONF_SERIAL_NUMBER: UPNP_SERIAL, + CONF_MODEL_NAME: UPNP_MODEL_NAME_P_B, + }, + ) + + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/wilight/test_config_flow.py b/tests/components/wilight/test_config_flow.py new file mode 100644 index 00000000000..1de190c51d9 --- /dev/null +++ b/tests/components/wilight/test_config_flow.py @@ -0,0 +1,157 @@ +"""Test the WiLight config flow.""" +from asynctest import patch +import pytest + +from homeassistant.components.wilight.config_flow import ( + CONF_MODEL_NAME, + CONF_SERIAL_NUMBER, +) +from homeassistant.components.wilight.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry +from tests.components.wilight import ( + CONF_COMPONENTS, + HOST, + MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN, + MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER, + MOCK_SSDP_DISCOVERY_INFO_P_B, + MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER, + UPNP_MODEL_NAME_P_B, + UPNP_SERIAL, + WILIGHT_ID, +) + + +@pytest.fixture(name="dummy_get_components_from_model_clear") +def mock_dummy_get_components_from_model(): + """Mock a clear components list.""" + components = [] + with patch( + "pywilight.get_components_from_model", return_value=components, + ): + yield components + + +async def test_show_ssdp_form(hass: HomeAssistantType) -> None: + """Test that the ssdp confirmation form is served.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + CONF_NAME: f"WL{WILIGHT_ID}", + CONF_COMPONENTS: "light", + } + + +async def test_ssdp_not_wilight_abort_1(hass: HomeAssistantType) -> None: + """Test that the ssdp aborts not_wilight.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_wilight_device" + + +async def test_ssdp_not_wilight_abort_2(hass: HomeAssistantType) -> None: + """Test that the ssdp aborts not_wilight.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_wilight_device" + + +async def test_ssdp_not_wilight_abort_3( + hass: HomeAssistantType, dummy_get_components_from_model_clear +) -> None: + """Test that the ssdp aborts not_wilight.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_wilight_device" + + +async def test_ssdp_not_supported_abort(hass: HomeAssistantType) -> None: + """Test that the ssdp aborts not_supported.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_supported_device" + + +async def test_ssdp_device_exists_abort(hass: HomeAssistantType) -> None: + """Test abort SSDP flow if WiLight already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WILIGHT_ID, + data={ + CONF_HOST: HOST, + CONF_SERIAL_NUMBER: UPNP_SERIAL, + CONF_MODEL_NAME: UPNP_MODEL_NAME_P_B, + }, + ) + + entry.add_to_hass(hass) + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_full_ssdp_flow_implementation(hass: HomeAssistantType) -> None: + """Test the full SSDP flow from start to finish.""" + + discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + CONF_NAME: f"WL{WILIGHT_ID}", + "components": "light", + } + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"WL{WILIGHT_ID}" + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_SERIAL_NUMBER] == UPNP_SERIAL + assert result["data"][CONF_MODEL_NAME] == UPNP_MODEL_NAME_P_B diff --git a/tests/components/wilight/test_init.py b/tests/components/wilight/test_init.py new file mode 100644 index 00000000000..11b86d0c367 --- /dev/null +++ b/tests/components/wilight/test_init.py @@ -0,0 +1,65 @@ +"""Tests for the WiLight integration.""" +from asynctest import patch +import pytest +import pywilight + +from homeassistant.components.wilight.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.components.wilight import ( + HOST, + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_P_B, + UPNP_MODEL_NUMBER, + UPNP_SERIAL, + setup_integration, +) + + +@pytest.fixture(name="dummy_device_from_host") +def mock_dummy_device_from_host(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_P_B, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", return_value=device, + ): + yield device + + +async def test_config_entry_not_ready(hass: HomeAssistantType) -> None: + """Test the WiLight configuration entry not ready.""" + entry = await setup_integration(hass) + + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_config_entry( + hass: HomeAssistantType, dummy_device_from_host +) -> None: + """Test the WiLight configuration entry unloading.""" + entry = await setup_integration(hass) + + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + if DOMAIN in hass.data: + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/wilight/test_light.py b/tests/components/wilight/test_light.py new file mode 100644 index 00000000000..555c1487bec --- /dev/null +++ b/tests/components/wilight/test_light.py @@ -0,0 +1,369 @@ +"""Tests for the WiLight integration.""" +from asynctest import patch +import pytest +import pywilight + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.components.wilight import ( + HOST, + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_COLOR, + UPNP_MODEL_NAME_DIMMER, + UPNP_MODEL_NAME_LIGHT_FAN, + UPNP_MODEL_NAME_P_B, + UPNP_MODEL_NUMBER, + UPNP_SERIAL, + WILIGHT_ID, + setup_integration, +) + + +@pytest.fixture(name="dummy_get_components_from_model_light") +def mock_dummy_get_components_from_model_light(): + """Mock a components list with light.""" + components = ["light"] + with patch( + "pywilight.get_components_from_model", return_value=components, + ): + yield components + + +@pytest.fixture(name="dummy_device_from_host_light_fan") +def mock_dummy_device_from_host_light_fan(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_LIGHT_FAN, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", return_value=device, + ): + yield device + + +@pytest.fixture(name="dummy_device_from_host_pb") +def mock_dummy_device_from_host_pb(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_P_B, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", return_value=device, + ): + yield device + + +@pytest.fixture(name="dummy_device_from_host_dimmer") +def mock_dummy_device_from_host_dimmer(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_DIMMER, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", return_value=device, + ): + yield device + + +@pytest.fixture(name="dummy_device_from_host_color") +def mock_dummy_device_from_host_color(): + """Mock a valid api_devce.""" + + device = pywilight.wilight_from_discovery( + f"http://{HOST}:45995/wilight.xml", + UPNP_MAC_ADDRESS, + UPNP_MODEL_NAME_COLOR, + UPNP_SERIAL, + UPNP_MODEL_NUMBER, + ) + + device.set_dummy(True) + + with patch( + "pywilight.device_from_host", return_value=device, + ): + yield device + + +async def test_loading_light( + hass: HomeAssistantType, + dummy_device_from_host_light_fan, + dummy_get_components_from_model_light, +) -> None: + """Test the WiLight configuration entry loading.""" + + # Using light_fan and removind fan from get_components_from_model + # to test light.py line 28 + entry = await setup_integration(hass) + assert entry + assert entry.unique_id == WILIGHT_ID + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # First segment of the strip + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + entry = entity_registry.async_get("light.wl000000000099_1") + assert entry + assert entry.unique_id == "WL000000000099_0" + + +async def test_on_off_light_state( + hass: HomeAssistantType, dummy_device_from_host_pb +) -> None: + """Test the change of state of the light switches.""" + await setup_integration(hass) + + # Turn on + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + + # Turn off + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + +async def test_dimmer_light_state( + hass: HomeAssistantType, dummy_device_from_host_dimmer +) -> None: + """Test the change of state of the light switches.""" + await setup_integration(hass) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 42, ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 42 + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 0, ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 100, ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 100 + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + # Turn on + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + + +async def test_color_light_state( + hass: HomeAssistantType, dummy_device_from_host_color +) -> None: + """Test the change of state of the light switches.""" + await setup_integration(hass) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_BRIGHTNESS: 42, + ATTR_HS_COLOR: [0, 100], + ATTR_ENTITY_ID: "light.wl000000000099_1", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 42 + state_color = [ + round(state.attributes.get(ATTR_HS_COLOR)[0]), + round(state.attributes.get(ATTR_HS_COLOR)[1]), + ] + assert state_color == [0, 100] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 0, ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_BRIGHTNESS: 100, + ATTR_HS_COLOR: [270, 50], + ATTR_ENTITY_ID: "light.wl000000000099_1", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 100 + state_color = [ + round(state.attributes.get(ATTR_HS_COLOR)[0]), + round(state.attributes.get(ATTR_HS_COLOR)[1]), + ] + assert state_color == [270, 50] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_OFF + + # Turn on + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + + # Hue = 0, Saturation = 100 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_HS_COLOR: [0, 100], ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + state_color = [ + round(state.attributes.get(ATTR_HS_COLOR)[0]), + round(state.attributes.get(ATTR_HS_COLOR)[1]), + ] + assert state_color == [0, 100] + + # Brightness = 60 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 60, ATTR_ENTITY_ID: "light.wl000000000099_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.wl000000000099_1") + assert state + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 60 -- GitLab