From 7afc6ca69373e7a49ac14c5556bdc9cdb2a2ca7b Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:49:20 +0100 Subject: [PATCH 1/5] Update __init__.py Add Platform SELECT --- homeassistant/components/twinkly/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index aaad731d26466..cd29ffaf42394 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN from .coordinator import TwinklyCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.LIGHT, Platform.SELECT] _LOGGER = logging.getLogger(__name__) From 8c6f8a62533234c5c50f78e5f794d6e1f2961ebd Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:52:21 +0100 Subject: [PATCH 2/5] modify twinkly coordinator to also get current_mode --- homeassistant/components/twinkly/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py index 8a5e3e087aea5..627fb0b39ba2a 100644 --- a/homeassistant/components/twinkly/coordinator.py +++ b/homeassistant/components/twinkly/coordinator.py @@ -27,6 +27,7 @@ class TwinklyData: is_on: bool movies: dict[int, str] current_movie: int | None + current_mode: str | None class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]): @@ -66,6 +67,8 @@ async def _async_update_data(self) -> TwinklyData: device_info = await self.client.get_details() brightness = await self.client.get_brightness() is_on = await self.client.is_on() + mode_data = await self.client.get_mode() + current_mode = mode_data.get("mode") if self.supports_effects: movies = (await self.client.get_saved_movies())["movies"] except (TimeoutError, ClientError) as exception: @@ -87,6 +90,7 @@ async def _async_update_data(self) -> TwinklyData: is_on, {movie["id"]: movie["name"] for movie in movies}, current_movie.get("id"), + current_mode, ) def _async_update_device_info(self, name: str) -> None: From a6fc725c08f16b400371cdf41ab23ad617fd6c23 Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:53:30 +0100 Subject: [PATCH 3/5] twinkly add select.py for mode selection --- homeassistant/components/twinkly/select.py | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 homeassistant/components/twinkly/select.py diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py new file mode 100644 index 0000000000000..1a4d0ff9b916a --- /dev/null +++ b/homeassistant/components/twinkly/select.py @@ -0,0 +1,64 @@ +"""The Twinkly select component.""" +from __future__ import annotations + +import logging +from typing import Any + +from ttls.client import TWINKLY_MODES +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import TwinklyConfigEntry, TwinklyCoordinator +from .const import ( + DEV_MODEL, + DEV_NAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TwinklyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Setups a mode select from a config entry.""" + entity = TwinklyModeSelect(config_entry.runtime_data) + async_add_entities([entity], update_before_add=True) + +class TwinklyModeSelect(CoordinatorEntity[TwinklyCoordinator], SelectEntity): + """Twinkly Mode Selection.""" + + _attr_has_entity_name = True + _attr_name = "Mode" + _attr_options = TWINKLY_MODES + + def __init__(self, coordinator: TwinklyCoordinator) -> None: + """Initialize TwinklyModeSelect.""" + super().__init__(coordinator) + device_info = coordinator.data.device_info + mac = device_info["mac"] + + self._attr_unique_id = f"{mac}_mode" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + connections={(CONNECTION_NETWORK_MAC, mac)}, + manufacturer="LEDWORKS", + model=device_info[DEV_MODEL], + name=device_info[DEV_NAME], + sw_version=coordinator.software_version, + ) + self.client = coordinator.client + + @property + def current_option(self) -> str | None: + """Return current mode.""" + return self.coordinator.data.current_mode + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.client.set_mode(option) + await self.coordinator.async_refresh() From 3796bd08c5a218c65cdd0c92e583efc3a6304aae Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:20:42 +0000 Subject: [PATCH 4/5] ruff format and check --- homeassistant/components/twinkly/select.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 1a4d0ff9b916a..130d247acb02c 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -1,10 +1,11 @@ """The Twinkly select component.""" + from __future__ import annotations import logging -from typing import Any from ttls.client import TWINKLY_MODES + from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -12,14 +13,11 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TwinklyConfigEntry, TwinklyCoordinator -from .const import ( - DEV_MODEL, - DEV_NAME, - DOMAIN, -) +from .const import DEV_MODEL, DEV_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, config_entry: TwinklyConfigEntry, @@ -29,9 +27,10 @@ async def async_setup_entry( entity = TwinklyModeSelect(config_entry.runtime_data) async_add_entities([entity], update_before_add=True) + class TwinklyModeSelect(CoordinatorEntity[TwinklyCoordinator], SelectEntity): """Twinkly Mode Selection.""" - + _attr_has_entity_name = True _attr_name = "Mode" _attr_options = TWINKLY_MODES @@ -41,7 +40,7 @@ def __init__(self, coordinator: TwinklyCoordinator) -> None: super().__init__(coordinator) device_info = coordinator.data.device_info mac = device_info["mac"] - + self._attr_unique_id = f"{mac}_mode" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, From 6e958eed88e5edd1672d61590753ad44b370c226 Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Fri, 27 Dec 2024 10:16:30 +0000 Subject: [PATCH 5/5] twinkly added tests for mode select --- tests/components/twinkly/conftest.py | 1 + .../components/twinkly/fixtures/get_mode.json | 3 + .../twinkly/snapshots/test_select.ambr | 66 ++++++++++++++++ tests/components/twinkly/test_select.py | 77 +++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 tests/components/twinkly/fixtures/get_mode.json create mode 100644 tests/components/twinkly/snapshots/test_select.ambr create mode 100644 tests/components/twinkly/test_select.py diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py index c66be97a25791..33b8f82a48815 100644 --- a/tests/components/twinkly/conftest.py +++ b/tests/components/twinkly/conftest.py @@ -57,6 +57,7 @@ def mock_twinkly_client() -> Generator[AsyncMock]: client.get_current_movie.return_value = load_json_object_fixture( "get_current_movie.json", DOMAIN ) + client.get_mode.return_value = load_json_object_fixture("get_mode.json", DOMAIN) client.is_on.return_value = True client.get_brightness.return_value = {"mode": "enabled", "value": 10} client.host = "192.168.0.123" diff --git a/tests/components/twinkly/fixtures/get_mode.json b/tests/components/twinkly/fixtures/get_mode.json new file mode 100644 index 0000000000000..38aed254a62b0 --- /dev/null +++ b/tests/components/twinkly/fixtures/get_mode.json @@ -0,0 +1,3 @@ +{ + "mode": "color" +} diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr new file mode 100644 index 0000000000000..21e09d6b02274 --- /dev/null +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_select_entities[select.tree_1_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'color', + 'demo', + 'effect', + 'movie', + 'off', + 'playlist', + 'rt', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.tree_1_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'twinkly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:2d:13:3b:aa:bb_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.tree_1_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tree 1 Mode', + 'options': list([ + 'color', + 'demo', + 'effect', + 'movie', + 'off', + 'playlist', + 'rt', + ]), + }), + 'context': , + 'entity_id': 'select.tree_1_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'color', + }) +# --- diff --git a/tests/components/twinkly/test_select.py b/tests/components/twinkly/test_select.py new file mode 100644 index 0000000000000..103fbe0f634e2 --- /dev/null +++ b/tests/components/twinkly/test_select.py @@ -0,0 +1,77 @@ +"""Tests for the Twinkly select component.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_select_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the created select entities.""" + with patch("homeassistant.components.twinkly.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """Test selecting a mode.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.tree_1_mode") + assert state is not None + assert state.state == "color" + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.tree_1_mode", + ATTR_OPTION: "movie", + }, + blocking=True, + ) + + mock_twinkly_client.set_mode.assert_called_once_with("movie") + mock_twinkly_client.interview.assert_not_called() + + +async def test_mode_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of unavailable mode data.""" + await setup_integration(hass, mock_config_entry) + + mock_twinkly_client.get_mode.side_effect = Exception + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("select.tree_1_mode") + assert state.state == STATE_UNAVAILABLE