Add WLED integration (#28542)

* Add WLED integration

* Use f-string for uniq id in sensor platform

* Typing improvements

* Removes sensor & light platform

* Remove PARALLEL_UPDATES from integration level

* Correct type in code comment 'themselves'

* Use async_track_time_interval in async context

* Remove stale code

* Remove decorator from Flow handler

* Remove unused __init__ from config flow

* Move show form methods to sync

* Only wrap lines that can raise in try except block

* Remove domain and platform from uniq id

* Wrap light state in bool object in is_on method

* Use async_schedule_update_ha_state in async context

* Return empty dict in device state attributes instead of None

* Remove unneeded setdefault call in setup entry

* Cancel update timer on entry unload

* Restructure config flow code

* Adjust tests for new uniq id

* Correct typo AdGuard Home -> WLED in config flow file comment

* Convert internal package imports to be relative

* Reformat JSON files with Prettier

* Improve tests based on review comments

* Add test for zeroconf when no data is provided

* Cleanup and extended tests
This commit is contained in:
Franck Nijhof 2019-11-06 23:55:39 +01:00 committed by GitHub
parent 3d2ff841d3
commit 78b83c653a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1518 additions and 0 deletions

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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,
}

View File

@ -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 {},
)

View File

@ -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"

View File

@ -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,
}

View File

@ -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"]
}

View File

@ -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."
}
}
}

View File

@ -81,6 +81,7 @@ FLOWS = [
"vesync",
"wemo",
"withings",
"wled",
"wwlln",
"zha",
"zone",

View File

@ -20,6 +20,9 @@ ZEROCONF = {
],
"_hap._tcp.local.": [
"homekit_controller"
],
"_wled._tcp.local.": [
"wled"
]
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

210
tests/fixtures/wled/rgb.json vendored Normal file
View File

@ -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"
]
}

198
tests/fixtures/wled/rgbw.json vendored Normal file
View File

@ -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"
]
}