Add mode selector to Twinkly (#134041)

This commit is contained in:
Sven Naumann 2025-01-02 11:54:29 +01:00 committed by GitHub
parent add4e1a708
commit 0b32342bf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 201 additions and 1 deletions

View File

@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import TwinklyCoordinator
PLATFORMS = [Platform.LIGHT]
PLATFORMS = [Platform.LIGHT, Platform.SELECT]
_LOGGER = logging.getLogger(__name__)

View File

@ -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 @@ class TwinklyCoordinator(DataUpdateCoordinator[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 @@ class TwinklyCoordinator(DataUpdateCoordinator[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:

View File

@ -0,0 +1,49 @@
"""The Twinkly select component."""
from __future__ import annotations
import logging
from ttls.client import TWINKLY_MODES
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import TwinklyConfigEntry, TwinklyCoordinator
from .entity import TwinklyEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TwinklyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a mode select from a config entry."""
entity = TwinklyModeSelect(config_entry.runtime_data)
async_add_entities([entity], update_before_add=True)
class TwinklyModeSelect(TwinklyEntity, SelectEntity):
"""Twinkly Mode Selection."""
_attr_name = "Mode"
_attr_options = TWINKLY_MODES
def __init__(self, coordinator: TwinklyCoordinator) -> None:
"""Initialize TwinklyModeSelect."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.device_info["mac"]}_mode"
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()

View File

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

View File

@ -0,0 +1,3 @@
{
"mode": "color"
}

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.tree_1_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'select.tree_1_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'color',
})
# ---

View File

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