From fba7118d443bfb0f6f058be06bee71f40f0fbe93 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 24 Jun 2021 20:25:21 +0200 Subject: [PATCH] Add Color Palette Select entities to WLED (#51994) * Add Color Palette Select entities to WLED * Update with dev changes, disable by default --- homeassistant/components/wled/__init__.py | 3 +- homeassistant/components/wled/select.py | 100 +++++++++ tests/components/wled/test_select.py | 259 ++++++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/wled/select.py create mode 100644 tests/components/wled/test_select.py diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 77b97472747..29c6b98b381 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import WLEDDataUpdateCoordinator -PLATFORMS = (LIGHT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN) +PLATFORMS = (LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py new file mode 100644 index 00000000000..6628334266b --- /dev/null +++ b/homeassistant/components/wled/select.py @@ -0,0 +1,100 @@ +"""Support for LED selects.""" +from __future__ import annotations + +from functools import partial + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import WLEDDataUpdateCoordinator +from .helpers import wled_exception_handler +from .models import WLEDEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WLED select based on a config entry.""" + coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + update_segments = partial( + async_update_segments, + coordinator, + {}, + async_add_entities, + ) + coordinator.async_add_listener(update_segments) + update_segments() + + +class WLEDPaletteSelect(WLEDEntity, SelectEntity): + """Defines a WLED Palette select.""" + + _attr_icon = "mdi:palette-outline" + _segment: int + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: + """Initialize WLED .""" + super().__init__(coordinator=coordinator) + + # Segment 0 uses a simpler name, which is more natural for when using + # a single segment / using WLED with one big LED strip. + self._attr_name = ( + f"{coordinator.data.info.name} Segment {segment} Color Palette" + ) + if segment == 0: + self._attr_name = f"{coordinator.data.info.name} Color Palette" + + self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}" + self._attr_options = [ + palette.name for palette in self.coordinator.data.palettes + ] + self._segment = segment + + @property + def available(self) -> bool: + """Return True if entity is available.""" + try: + self.coordinator.data.state.segments[self._segment] + except IndexError: + return False + + return super().available + + @property + def current_option(self) -> str | None: + """Return the current selected color palette.""" + return self.coordinator.data.state.segments[self._segment].palette.name + + @wled_exception_handler + async def async_select_option(self, option: str) -> None: + """Set WLED segment to the selected color palette.""" + await self.coordinator.wled.segment(segment_id=self._segment, palette=option) + + +@callback +def async_update_segments( + coordinator: WLEDDataUpdateCoordinator, + current: dict[int, WLEDPaletteSelect], + async_add_entities, +) -> None: + """Update segments.""" + segment_ids = {segment.segment_id for segment in coordinator.data.state.segments} + current_ids = set(current) + + new_entities = [] + + # Process new segments, add them to Home Assistant + for segment_id in segment_ids - current_ids: + current[segment_id] = WLEDPaletteSelect(coordinator, segment_id) + new_entities.append(current[segment_id]) + + if new_entities: + async_add_entities(new_entities) diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py new file mode 100644 index 00000000000..2a0817b7c12 --- /dev/null +++ b/tests/components/wled/test_select.py @@ -0,0 +1,259 @@ +"""Tests for the WLED select platform.""" +import json +from unittest.mock import MagicMock + +import pytest +from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS +from homeassistant.components.wled.const import DOMAIN, SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ICON, + SERVICE_SELECT_OPTION, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture + + +@pytest.fixture +async def enable_all(hass: HomeAssistant) -> None: + """Enable all disabled by default select entities.""" + registry = er.async_get(hass) + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + SELECT_DOMAIN, + DOMAIN, + "aabbccddeeff_palette_0", + suggested_object_id="wled_rgb_light_color_palette", + disabled_by=None, + ) + + registry.async_get_or_create( + SELECT_DOMAIN, + DOMAIN, + "aabbccddeeff_palette_1", + suggested_object_id="wled_rgb_light_segment_1_color_palette", + disabled_by=None, + ) + + +async def test_select_state( + hass: HomeAssistant, enable_all: None, init_integration: MockConfigEntry +) -> None: + """Test the creation and values of the WLED selects.""" + entity_registry = er.async_get(hass) + + # First segment of the strip + state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:palette-outline" + assert state.attributes.get(ATTR_OPTIONS) == [ + "Analogous", + "April Night", + "Autumn", + "Based on Primary", + "Based on Set", + "Beach", + "Beech", + "Breeze", + "C9", + "Cloud", + "Cyane", + "Default", + "Departure", + "Drywet", + "Fire", + "Forest", + "Grintage", + "Hult", + "Hult 64", + "Icefire", + "Jul", + "Landscape", + "Lava", + "Light Pink", + "Magenta", + "Magred", + "Ocean", + "Orange & Teal", + "Orangery", + "Party", + "Pastel", + "Primary Color", + "Rainbow", + "Rainbow Bands", + "Random Cycle", + "Red & Blue", + "Rewhi", + "Rivendell", + "Sakura", + "Set Colors", + "Sherbet", + "Splash", + "Sunset", + "Sunset 2", + "Tertiary", + "Tiamat", + "Vintage", + "Yelblu", + "Yellowout", + "Yelmag", + ] + assert state.state == "Random Cycle" + + entry = entity_registry.async_get("select.wled_rgb_light_segment_1_color_palette") + assert entry + assert entry.unique_id == "aabbccddeeff_palette_1" + + +async def test_segment_change_state( + hass: HomeAssistant, + enable_all: None, + init_integration: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Test the option change of state of the WLED segments.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", + ATTR_OPTION: "Some Other Palette", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.segment.call_count == 1 + mock_wled.segment.assert_called_with( + segment_id=1, + palette="Some Other Palette", + ) + + +@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +async def test_dynamically_handle_segments( + hass: HomeAssistant, + enable_all: None, + init_integration: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Test if a new/deleted segment is dynamically added/removed.""" + segment0 = hass.states.get("select.wled_rgb_light_color_palette") + segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert segment0 + assert segment0.state == "Default" + assert not segment1 + + return_value = mock_wled.update.return_value + mock_wled.update.return_value = WLEDDevice( + json.loads(load_fixture("wled/rgb.json")) + ) + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + segment0 = hass.states.get("select.wled_rgb_light_color_palette") + segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert segment0 + assert segment0.state == "Default" + assert segment1 + assert segment1.state == "Random Cycle" + + # Test adding if segment shows up again, including the master entity + mock_wled.update.return_value = return_value + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + segment0 = hass.states.get("select.wled_rgb_light_color_palette") + segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert segment0 + assert segment0.state == "Default" + assert segment1 + assert segment1.state == STATE_UNAVAILABLE + + +async def test_select_error( + hass: HomeAssistant, + enable_all: None, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.segment.side_effect = WLEDError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", + ATTR_OPTION: "Whatever", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert state + assert state.state == "Random Cycle" + assert "Invalid response from API" in caplog.text + assert mock_wled.segment.call_count == 1 + mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + + +async def test_select_connection_error( + hass: HomeAssistant, + enable_all: None, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.segment.side_effect = WLEDConnectionError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", + ATTR_OPTION: "Whatever", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") + assert state + assert state.state == STATE_UNAVAILABLE + assert "Error communicating with API" in caplog.text + assert mock_wled.segment.call_count == 1 + mock_wled.segment.assert_called_with(segment_id=1, palette="Whatever") + + +@pytest.mark.parametrize( + "entity_id", + ( + "select.wled_rgb_light_color_palette", + "select.wled_rgb_light_segment_1_color_palette", + ), +) +async def test_disabled_by_default_selects( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the disabled by default WLED selects.""" + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION