From 296f678d529f899eee9918ddfb528015f9e316fe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 8 Nov 2021 08:56:27 -0800 Subject: [PATCH] Add Evil Genius Labs integration (#58720) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + CODEOWNERS | 1 + .../components/evil_genius_labs/__init__.py | 99 ++++++ .../evil_genius_labs/config_flow.py | 84 +++++ .../components/evil_genius_labs/const.py | 3 + .../components/evil_genius_labs/light.py | 120 +++++++ .../components/evil_genius_labs/manifest.json | 9 + .../components/evil_genius_labs/strings.json | 15 + .../evil_genius_labs/translations/en.json | 15 + .../components/evil_genius_labs/util.py | 21 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/evil_genius_labs/__init__.py | 1 + tests/components/evil_genius_labs/conftest.py | 49 +++ .../evil_genius_labs/fixtures/data.json | 331 ++++++++++++++++++ .../evil_genius_labs/fixtures/info.json | 30 ++ .../evil_genius_labs/test_config_flow.py | 85 +++++ .../components/evil_genius_labs/test_init.py | 13 + .../components/evil_genius_labs/test_light.py | 76 ++++ 21 files changed, 971 insertions(+) create mode 100644 homeassistant/components/evil_genius_labs/__init__.py create mode 100644 homeassistant/components/evil_genius_labs/config_flow.py create mode 100644 homeassistant/components/evil_genius_labs/const.py create mode 100644 homeassistant/components/evil_genius_labs/light.py create mode 100644 homeassistant/components/evil_genius_labs/manifest.json create mode 100644 homeassistant/components/evil_genius_labs/strings.json create mode 100644 homeassistant/components/evil_genius_labs/translations/en.json create mode 100644 homeassistant/components/evil_genius_labs/util.py create mode 100644 tests/components/evil_genius_labs/__init__.py create mode 100644 tests/components/evil_genius_labs/conftest.py create mode 100644 tests/components/evil_genius_labs/fixtures/data.json create mode 100644 tests/components/evil_genius_labs/fixtures/info.json create mode 100644 tests/components/evil_genius_labs/test_config_flow.py create mode 100644 tests/components/evil_genius_labs/test_init.py create mode 100644 tests/components/evil_genius_labs/test_light.py diff --git a/.strict-typing b/.strict-typing index ac5d2b6a8ac..4c9e626afa6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -42,6 +42,7 @@ homeassistant.components.efergy.* homeassistant.components.elgato.* homeassistant.components.esphome.* homeassistant.components.energy.* +homeassistant.components.evil_genius_labs.* homeassistant.components.fastdotcom.* homeassistant.components.fitbit.* homeassistant.components.flunearyou.* diff --git a/CODEOWNERS b/CODEOWNERS index 84ab3a80c5e..ad69391ad29 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,7 @@ homeassistant/components/epson/* @pszafer homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter @jesserockz +homeassistant/components/evil_genius_labs/* @balloob homeassistant/components/evohome/* @zxdavb homeassistant/components/ezviz/* @RenierM26 @baqs homeassistant/components/faa_delays/* @ntilley905 diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py new file mode 100644 index 00000000000..78445a42e7d --- /dev/null +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -0,0 +1,99 @@ +"""The Evil Genius Labs integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +from async_timeout import timeout +import pyevilgenius + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + update_coordinator, +) +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + +PLATFORMS = ["light"] + +UPDATE_INTERVAL = 10 + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Evil Genius Labs from a config entry.""" + coordinator = EvilGeniusUpdateCoordinator( + hass, + entry.title, + pyevilgenius.EvilGeniusDevice( + entry.data["host"], aiohttp_client.async_get_clientsession(hass) + ), + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class EvilGeniusUpdateCoordinator(update_coordinator.DataUpdateCoordinator[dict]): + """Update coordinator for Evil Genius data.""" + + info: dict + + def __init__( + self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + ) -> None: + """Initialize the data update coordinator.""" + self.client = client + super().__init__( + hass, + logging.getLogger(__name__), + name=name, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + @property + def device_name(self) -> str: + """Return the device name.""" + return cast(str, self.data["name"]["value"]) + + async def _async_update_data(self) -> dict: + """Update Evil Genius data.""" + if not hasattr(self, "info"): + async with timeout(5): + self.info = await self.client.get_info() + + async with timeout(5): + return cast(dict, await self.client.get_data()) + + +class EvilGeniusEntity(update_coordinator.CoordinatorEntity): + """Base entity for Evil Genius.""" + + coordinator: EvilGeniusUpdateCoordinator + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + info = self.coordinator.info + return DeviceInfo( + identifiers={(DOMAIN, info["wiFiChipId"])}, + connections={(dr.CONNECTION_NETWORK_MAC, info["macAddress"])}, + name=self.coordinator.device_name, + manufacturer="Evil Genius Labs", + sw_version=info["coreVersion"].replace("_", "."), + configuration_url=self.coordinator.client.url, + ) diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py new file mode 100644 index 00000000000..f4f7b464904 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for Evil Genius Labs integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import pyevilgenius +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + hub = pyevilgenius.EvilGeniusDevice( + data["host"], aiohttp_client.async_get_clientsession(hass) + ) + + try: + data = await hub.get_data() + info = await hub.get_info() + except aiohttp.ClientError as err: + raise CannotConnect from err + + return {"title": data["name"]["value"], "unique_id": info["wiFiChipId"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Evil Genius Labs.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host"): str, + } + ), + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["unique_id"]) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("host", default=user_input["host"]): str, + } + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/evil_genius_labs/const.py b/homeassistant/components/evil_genius_labs/const.py new file mode 100644 index 00000000000..c335e5eaee2 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/const.py @@ -0,0 +1,3 @@ +"""Constants for the Evil Genius Labs integration.""" + +DOMAIN = "evil_genius_labs" diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py new file mode 100644 index 00000000000..cb837668a4c --- /dev/null +++ b/homeassistant/components/evil_genius_labs/light.py @@ -0,0 +1,120 @@ +"""Light platform for Evil Genius Light.""" +from __future__ import annotations + +from typing import Any, cast + +from async_timeout import timeout + +from homeassistant.components import light +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EvilGeniusEntity, EvilGeniusUpdateCoordinator +from .const import DOMAIN +from .util import update_when_done + +HA_NO_EFFECT = "None" +FIB_NO_EFFECT = "Solid Color" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Evil Genius light platform.""" + coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([EvilGeniusLight(coordinator)]) + + +class EvilGeniusLight(EvilGeniusEntity, light.LightEntity): + """Evil Genius Labs light.""" + + _attr_supported_features = ( + light.SUPPORT_BRIGHTNESS | light.SUPPORT_EFFECT | light.SUPPORT_COLOR + ) + _attr_supported_color_modes = {light.COLOR_MODE_RGB} + _attr_color_mode = light.COLOR_MODE_RGB + + def __init__(self, coordinator: EvilGeniusUpdateCoordinator) -> None: + """Initialize the Evil Genius light.""" + super().__init__(coordinator) + self._attr_unique_id = self.coordinator.info["wiFiChipId"] + self._attr_effect_list = [ + pattern + for pattern in self.coordinator.data["pattern"]["options"] + if pattern != FIB_NO_EFFECT + ] + self._attr_effect_list.insert(0, HA_NO_EFFECT) + + @property + def name(self) -> str: + """Return name.""" + return cast(str, self.coordinator.data["name"]["value"]) + + @property + def is_on(self) -> bool: + """Return if light is on.""" + return cast(int, self.coordinator.data["power"]["value"]) == 1 + + @property + def brightness(self) -> int: + """Return brightness.""" + return cast(int, self.coordinator.data["brightness"]["value"]) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value [int, int, int].""" + return cast( + "tuple[int, int, int]", + tuple( + int(val) + for val in self.coordinator.data["solidColor"]["value"].split(",") + ), + ) + + @property + def effect(self) -> str: + """Return current effect.""" + value = cast( + str, + self.coordinator.data["pattern"]["options"][ + self.coordinator.data["pattern"]["value"] + ], + ) + if value == FIB_NO_EFFECT: + return HA_NO_EFFECT + return value + + @update_when_done + async def async_turn_on( + self, + **kwargs: Any, + ) -> None: + """Turn light on.""" + if (brightness := kwargs.get(light.ATTR_BRIGHTNESS)) is not None: + async with timeout(5): + await self.coordinator.client.set_path_value("brightness", brightness) + + # Setting a color will change the effect to "Solid Color" so skip setting effect + if (rgb_color := kwargs.get(light.ATTR_RGB_COLOR)) is not None: + async with timeout(5): + await self.coordinator.client.set_rgb_color(*rgb_color) + + elif (effect := kwargs.get(light.ATTR_EFFECT)) is not None: + if effect == HA_NO_EFFECT: + effect = FIB_NO_EFFECT + async with timeout(5): + await self.coordinator.client.set_path_value( + "pattern", self.coordinator.data["pattern"]["options"].index(effect) + ) + + async with timeout(5): + await self.coordinator.client.set_path_value("power", 1) + + @update_when_done + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + async with timeout(5): + await self.coordinator.client.set_path_value("power", 0) diff --git a/homeassistant/components/evil_genius_labs/manifest.json b/homeassistant/components/evil_genius_labs/manifest.json new file mode 100644 index 00000000000..698c13b43e6 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "evil_genius_labs", + "name": "Evil Genius Labs", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/evil_genius_labs", + "requirements": ["pyevilgenius==1.0.0"], + "codeowners": ["@balloob"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json new file mode 100644 index 00000000000..16c5de158a9 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/evil_genius_labs/translations/en.json b/homeassistant/components/evil_genius_labs/translations/en.json new file mode 100644 index 00000000000..b059e11aa28 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py new file mode 100644 index 00000000000..42088f69797 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/util.py @@ -0,0 +1,21 @@ +"""Utilities for Evil Genius Labs.""" +from collections.abc import Callable +from functools import wraps +from typing import Any, TypeVar, cast + +from . import EvilGeniusEntity + +CallableT = TypeVar("CallableT", bound=Callable) + + +def update_when_done(func: CallableT) -> CallableT: + """Decorate function to trigger update when function is done.""" + + @wraps(func) + async def wrapper(self: EvilGeniusEntity, *args: Any, **kwargs: Any) -> Any: + """Wrap function.""" + result = await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh() + return result + + return cast(CallableT, wrapper) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f65cf964ef3..4af6b656745 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -82,6 +82,7 @@ FLOWS = [ "environment_canada", "epson", "esphome", + "evil_genius_labs", "ezviz", "faa_delays", "fireservicerota", diff --git a/mypy.ini b/mypy.ini index ccacbca2da2..6859b8b2d48 100644 --- a/mypy.ini +++ b/mypy.ini @@ -473,6 +473,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.evil_genius_labs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fastdotcom.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f3082ca55cf..977264c1d73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1470,6 +1470,9 @@ pyephember==0.3.1 # homeassistant.components.everlights pyeverlights==0.1.0 +# homeassistant.components.evil_genius_labs +pyevilgenius==1.0.0 + # homeassistant.components.ezviz pyezviz==0.1.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d7adcf59f0..e4b6fe6f708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -867,6 +867,9 @@ pyefergy==0.1.4 # homeassistant.components.everlights pyeverlights==0.1.0 +# homeassistant.components.evil_genius_labs +pyevilgenius==1.0.0 + # homeassistant.components.ezviz pyezviz==0.1.9.4 diff --git a/tests/components/evil_genius_labs/__init__.py b/tests/components/evil_genius_labs/__init__.py new file mode 100644 index 00000000000..70b122eb460 --- /dev/null +++ b/tests/components/evil_genius_labs/__init__.py @@ -0,0 +1 @@ +"""Tests for the Evil Genius Labs integration.""" diff --git a/tests/components/evil_genius_labs/conftest.py b/tests/components/evil_genius_labs/conftest.py new file mode 100644 index 00000000000..063e31704a5 --- /dev/null +++ b/tests/components/evil_genius_labs/conftest.py @@ -0,0 +1,49 @@ +"""Test helpers for Evil Genius Labs.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(scope="session") +def data_fixture(): + """Fixture data.""" + data = json.loads(load_fixture("data.json", "evil_genius_labs")) + return {item["name"]: item for item in data} + + +@pytest.fixture(scope="session") +def info_fixture(): + """Fixture info.""" + return json.loads(load_fixture("info.json", "evil_genius_labs")) + + +@pytest.fixture +def config_entry(hass): + """Evil genius labs config entry.""" + entry = MockConfigEntry(domain="evil_genius_labs", data={"host": "192.168.1.113"}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def setup_evil_genius_labs( + hass, config_entry, data_fixture, info_fixture, platforms +): + """Test up Evil Genius Labs instance.""" + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + return_value=data_fixture, + ), patch( + "pyevilgenius.EvilGeniusDevice.get_info", + return_value=info_fixture, + ), patch( + "homeassistant.components.evil_genius_labs.PLATFORMS", platforms + ): + assert await async_setup_component(hass, "evil_genius_labs", {}) + await hass.async_block_till_done() + yield diff --git a/tests/components/evil_genius_labs/fixtures/data.json b/tests/components/evil_genius_labs/fixtures/data.json new file mode 100644 index 00000000000..555fe38da7e --- /dev/null +++ b/tests/components/evil_genius_labs/fixtures/data.json @@ -0,0 +1,331 @@ +[ + { + "name": "name", + "label": "Name", + "type": "Label", + "value": "Fibonacci256-23D4" + }, + { "name": "power", "label": "Power", "type": "Boolean", "value": 1 }, + { + "name": "brightness", + "label": "Brightness", + "type": "Number", + "value": 128, + "min": 1, + "max": 255 + }, + { + "name": "pattern", + "label": "Pattern", + "type": "Select", + "value": 70, + "options": [ + "Pride", + "Pride Fibonacci", + "Color Waves", + "Color Waves Fibonacci", + "Pride Playground", + "Pride Playground Fibonacci", + "Color Waves Playground", + "Color Waves Playground Fibonacci", + "Wheel", + "Swirl Fibonacci", + "Fire Fibonacci", + "Water Fibonacci", + "Emitter Fibonacci", + "Pacifica", + "Pacifica Fibonacci", + "Angle Palette", + "Radius Palette", + "X Axis Palette", + "Y Axis Palette", + "XY Axis Palette", + "Angle Gradient Palette", + "Radius Gradient Palette", + "X Axis Gradient Palette", + "Y Axis Gradient Palette", + "XY Axis Gradient Palette", + "Fire Noise", + "Fire Noise 2", + "Lava Noise", + "Rainbow Noise", + "Rainbow Stripe Noise", + "Party Noise", + "Forest Noise", + "Cloud Noise", + "Ocean Noise", + "Black & White Noise", + "Black & Blue Noise", + "Analog Clock", + "Spiral Analog Clock 13", + "Spiral Analog Clock 21", + "Spiral Analog Clock 34", + "Spiral Analog Clock 55", + "Spiral Analog Clock 89", + "Spiral Analog Clock 21 & 34", + "Spiral Analog Clock 13, 21 & 34", + "Spiral Analog Clock 34, 21 & 13", + "Pride Playground", + "Color Waves Playground", + "Rainbow Twinkles", + "Snow Twinkles", + "Cloud Twinkles", + "Incandescent Twinkles", + "Retro C9 Twinkles", + "Red & White Twinkles", + "Blue & White Twinkles", + "Red, Green & White Twinkles", + "Fairy Light Twinkles", + "Snow 2 Twinkles", + "Holly Twinkles", + "Ice Twinkles", + "Party Twinkles", + "Forest Twinkles", + "Lava Twinkles", + "Fire Twinkles", + "Cloud 2 Twinkles", + "Ocean Twinkles", + "Rainbow", + "Rainbow With Glitter", + "Solid Rainbow", + "Confetti", + "Sinelon", + "Beat", + "Juggle", + "Fire", + "Water", + "Strand Test", + "Solid Color" + ] + }, + { + "name": "palette", + "label": "Palette", + "type": "Select", + "value": 0, + "options": [ + "Rainbow", + "Rainbow Stripe", + "Cloud", + "Lava", + "Ocean", + "Forest", + "Party", + "Heat" + ] + }, + { + "name": "speed", + "label": "Speed", + "type": "Number", + "value": 30, + "min": 1, + "max": 255 + }, + { "name": "autoplaySection", "label": "Autoplay", "type": "Section" }, + { "name": "autoplay", "label": "Autoplay", "type": "Boolean", "value": 0 }, + { + "name": "autoplayDuration", + "label": "Autoplay Duration", + "type": "Number", + "value": 10, + "min": 0, + "max": 255 + }, + { "name": "clock", "label": "Clock", "type": "Section" }, + { "name": "showClock", "label": "Show Clock", "type": "Boolean", "value": 0 }, + { + "name": "clockBackgroundFade", + "label": "Background Fade", + "type": "Number", + "value": 240, + "min": 0, + "max": 255 + }, + { "name": "solidColorSection", "label": "Solid Color", "type": "Section" }, + { + "name": "solidColor", + "label": "Color", + "type": "Color", + "value": "0,0,255" + }, + { "name": "prideSection", "label": "Pride & ColorWaves", "type": "Section" }, + { + "name": "saturationBpm", + "label": "Saturation BPM", + "type": "Number", + "value": 87, + "min": 0, + "max": 255 + }, + { + "name": "saturationMin", + "label": "Saturation Min", + "type": "Number", + "value": 220, + "min": 0, + "max": 255 + }, + { + "name": "saturationMax", + "label": "Saturation Max", + "type": "Number", + "value": 250, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthBpm", + "label": "Brightness Depth BPM", + "type": "Number", + "value": 1, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthMin", + "label": "Brightness Depth Min", + "type": "Number", + "value": 96, + "min": 0, + "max": 255 + }, + { + "name": "brightDepthMax", + "label": "Brightness Depth Max", + "type": "Number", + "value": 224, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncBpm", + "label": "Bright Theta Inc BPM", + "type": "Number", + "value": 203, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncMin", + "label": "Bright Theta Inc Min", + "type": "Number", + "value": 25, + "min": 0, + "max": 255 + }, + { + "name": "brightThetaIncMax", + "label": "Bright Theta Inc Max", + "type": "Number", + "value": 40, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierBpm", + "label": "Time Multiplier BPM", + "type": "Number", + "value": 147, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierMin", + "label": "Time Multiplier Min", + "type": "Number", + "value": 23, + "min": 0, + "max": 255 + }, + { + "name": "msMultiplierMax", + "label": "Time Multiplier Max", + "type": "Number", + "value": 60, + "min": 0, + "max": 255 + }, + { + "name": "hueIncBpm", + "label": "Hue Inc BPM", + "type": "Number", + "value": 113, + "min": 0, + "max": 255 + }, + { + "name": "hueIncMin", + "label": "Hue Inc Min", + "type": "Number", + "value": 1, + "min": 0, + "max": 255 + }, + { + "name": "hueIncMax", + "label": "Hue Inc Max", + "type": "Number", + "value": 12, + "min": 0, + "max": 255 + }, + { + "name": "sHueBpm", + "label": "S Hue BPM", + "type": "Number", + "value": 2, + "min": 0, + "max": 255 + }, + { + "name": "sHueMin", + "label": "S Hue Min", + "type": "Number", + "value": 5, + "min": 0, + "max": 255 + }, + { + "name": "sHueMax", + "label": "S Hue Max", + "type": "Number", + "value": 9, + "min": 0, + "max": 255 + }, + { "name": "fireSection", "label": "Fire & Water", "type": "Section" }, + { + "name": "cooling", + "label": "Cooling", + "type": "Number", + "value": 49, + "min": 0, + "max": 255 + }, + { + "name": "sparking", + "label": "Sparking", + "type": "Number", + "value": 60, + "min": 0, + "max": 255 + }, + { "name": "twinklesSection", "label": "Twinkles", "type": "Section" }, + { + "name": "twinkleSpeed", + "label": "Twinkle Speed", + "type": "Number", + "value": 4, + "min": 0, + "max": 8 + }, + { + "name": "twinkleDensity", + "label": "Twinkle Density", + "type": "Number", + "value": 5, + "min": 0, + "max": 8 + } +] diff --git a/tests/components/evil_genius_labs/fixtures/info.json b/tests/components/evil_genius_labs/fixtures/info.json new file mode 100644 index 00000000000..c11ab369316 --- /dev/null +++ b/tests/components/evil_genius_labs/fixtures/info.json @@ -0,0 +1,30 @@ +{ + "millis": 62099724, + "vcc": 3005, + "wiFiChipId": "1923d4", + "flashChipId": "1640d8", + "flashChipSize": 4194304, + "flashChipRealSize": 4194304, + "sdkVersion": "2.2.2-dev(38a443e)", + "coreVersion": "2_7_4", + "bootVersion": 6, + "cpuFreqMHz": 160, + "freeHeap": 21936, + "sketchSize": 476352, + "freeSketchSpace": 1617920, + "resetReason": "External System", + "isConnected": true, + "wiFiSsidDefault": "My Wi-Fi", + "wiFiSSID": "My Wi-Fi", + "localIP": "192.168.1.113", + "gatewayIP": "192.168.1.1", + "subnetMask": "255.255.255.0", + "dnsIP": "192.168.1.1", + "hostname": "ESP-1923D4", + "macAddress": "BC:FF:4D:19:23:D4", + "autoConnect": true, + "softAPSSID": "FaryLink_1923D4", + "softAPIP": "(IP unset)", + "BSSID": "FC:EC:DA:77:1A:CE", + "softAPmacAddress": "BE:FF:4D:19:23:D4" +} diff --git a/tests/components/evil_genius_labs/test_config_flow.py b/tests/components/evil_genius_labs/test_config_flow.py new file mode 100644 index 00000000000..55e207ba7e0 --- /dev/null +++ b/tests/components/evil_genius_labs/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Evil Genius Labs config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import config_entries +from homeassistant.components.evil_genius_labs.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant, data_fixture, info_fixture) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + return_value=data_fixture, + ), patch( + "pyevilgenius.EvilGeniusDevice.get_info", + return_value=info_fixture, + ), patch( + "homeassistant.components.evil_genius_labs.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Fibonacci256-23D4" + assert result2["data"] == { + "host": "1.1.1.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + side_effect=ValueError("BOOM"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/evil_genius_labs/test_init.py b/tests/components/evil_genius_labs/test_init.py new file mode 100644 index 00000000000..c0c7d00e1e5 --- /dev/null +++ b/tests/components/evil_genius_labs/test_init.py @@ -0,0 +1,13 @@ +"""Test evil genius labs init.""" +import pytest + +from homeassistant import config_entries +from homeassistant.components.evil_genius_labs import PLATFORMS + + +@pytest.mark.parametrize("platforms", [PLATFORMS]) +async def test_setup_unload_entry(hass, setup_evil_genius_labs, config_entry): + """Test setting up and unloading a config entry.""" + assert len(hass.states.async_entity_ids()) == 1 + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/evil_genius_labs/test_light.py b/tests/components/evil_genius_labs/test_light.py new file mode 100644 index 00000000000..de053c58037 --- /dev/null +++ b/tests/components/evil_genius_labs/test_light.py @@ -0,0 +1,76 @@ +"""Test Evil Genius Labs light.""" +from unittest.mock import patch + +import pytest + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_works(hass, setup_evil_genius_labs): + """Test it works.""" + state = hass.states.get("light.fibonacci256_23d4") + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] == 128 + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_on_color(hass, setup_evil_genius_labs): + """Test turning on with a color.""" + with patch( + "pyevilgenius.EvilGeniusDevice.set_path_value" + ) as mock_set_path_value, patch( + "pyevilgenius.EvilGeniusDevice.set_rgb_color" + ) as mock_set_rgb_color: + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.fibonacci256_23d4", + "brightness": 100, + "rgb_color": (10, 20, 30), + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 2 + mock_set_path_value.mock_calls[0][1] == ("brightness", 100) + mock_set_path_value.mock_calls[1][1] == ("power", 1) + + assert len(mock_set_rgb_color.mock_calls) == 1 + mock_set_rgb_color.mock_calls[0][1] == (10, 20, 30) + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_on_effect(hass, setup_evil_genius_labs): + """Test turning on with an effect.""" + with patch("pyevilgenius.EvilGeniusDevice.set_path_value") as mock_set_path_value: + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.fibonacci256_23d4", + "effect": "Pride Playground", + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 2 + mock_set_path_value.mock_calls[0][1] == ("pattern", 4) + mock_set_path_value.mock_calls[1][1] == ("power", 1) + + +@pytest.mark.parametrize("platforms", [("light",)]) +async def test_turn_off(hass, setup_evil_genius_labs): + """Test turning off.""" + with patch("pyevilgenius.EvilGeniusDevice.set_path_value") as mock_set_path_value: + await hass.services.async_call( + "light", + "turn_off", + { + "entity_id": "light.fibonacci256_23d4", + }, + blocking=True, + ) + + assert len(mock_set_path_value.mock_calls) == 1 + mock_set_path_value.mock_calls[0][1] == ("power", 0)