diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 4d1494d9d17..216379cb4a0 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -11,7 +11,12 @@ from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.SELECT, +] async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: diff --git a/homeassistant/components/casper_glow/const.py b/homeassistant/components/casper_glow/const.py index 37b5b7656ff..c7e8d86729b 100644 --- a/homeassistant/components/casper_glow/const.py +++ b/homeassistant/components/casper_glow/const.py @@ -12,5 +12,7 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS) DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0] +DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES) + # Interval between periodic state polls to catch externally-triggered changes. STATE_POLL_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/casper_glow/coordinator.py b/homeassistant/components/casper_glow/coordinator.py index 6b363d0445b..576dfeda11e 100644 --- a/homeassistant/components/casper_glow/coordinator.py +++ b/homeassistant/components/casper_glow/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from .const import STATE_POLL_INTERVAL +from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,15 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]): ) self.title = title + # The device API couples brightness and dimming time into a + # single command (set_brightness_and_dimming_time), so both + # values must be tracked here for cross-entity use. + self.last_brightness_pct: int = ( + device.state.brightness_level + if device.state.brightness_level is not None + else SORTED_BRIGHTNESS_LEVELS[0] + ) + @callback def _needs_poll( self, diff --git a/homeassistant/components/casper_glow/icons.json b/homeassistant/components/casper_glow/icons.json index c291e1abc22..6d8c1d83474 100644 --- a/homeassistant/components/casper_glow/icons.json +++ b/homeassistant/components/casper_glow/icons.json @@ -12,6 +12,11 @@ "resume": { "default": "mdi:play" } + }, + "select": { + "dimming_time": { + "default": "mdi:timer-outline" + } } } } diff --git a/homeassistant/components/casper_glow/light.py b/homeassistant/components/casper_glow/light.py index a8e29b2a7a3..686ccee4a7d 100644 --- a/homeassistant/components/casper_glow/light.py +++ b/homeassistant/components/casper_glow/light.py @@ -71,6 +71,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity): self._attr_color_mode = ColorMode.BRIGHTNESS if state.brightness_level is not None: self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level) + self.coordinator.last_brightness_pct = state.brightness_level @callback def _async_handle_state_update(self, state: GlowState) -> None: @@ -97,6 +98,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity): ) ) self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct) + self.coordinator.last_brightness_pct = brightness_pct async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 7f73eb17602..c139f7df4f8 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -52,8 +52,10 @@ rules: docs-troubleshooting: done docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo + entity-category: done + entity-device-class: + status: exempt + comment: No applicable device classes for binary_sensor, button, light, or select entities. entity-disabled-by-default: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/casper_glow/select.py b/homeassistant/components/casper_glow/select.py new file mode 100644 index 00000000000..61d1446a9d3 --- /dev/null +++ b/homeassistant/components/casper_glow/select.py @@ -0,0 +1,92 @@ +"""Casper Glow integration select platform for dimming time.""" + +from __future__ import annotations + +from pycasperglow import GlowState + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DIMMING_TIME_OPTIONS +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform for Casper Glow.""" + async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)]) + + +class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity): + """Select entity for Casper Glow dimming time.""" + + _attr_translation_key = "dimming_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_options = list(DIMMING_TIME_OPTIONS) + _attr_unit_of_measurement = UnitOfTime.MINUTES + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the dimming time select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time" + + @property + def current_option(self) -> str | None: + """Return the currently selected dimming time from the coordinator.""" + if self.coordinator.last_dimming_time_minutes is None: + return None + return str(self.coordinator.last_dimming_time_minutes) + + async def async_added_to_hass(self) -> None: + """Restore last known dimming time and register state update callback.""" + await super().async_added_to_hass() + if self.coordinator.last_dimming_time_minutes is None and ( + last_state := await self.async_get_last_state() + ): + if last_state.state in DIMMING_TIME_OPTIONS: + self.coordinator.last_dimming_time_minutes = int(last_state.state) + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.brightness_level is not None: + self.coordinator.last_brightness_pct = state.brightness_level + if ( + state.configured_dimming_time_minutes is not None + and self.coordinator.last_dimming_time_minutes is None + ): + self.coordinator.last_dimming_time_minutes = ( + state.configured_dimming_time_minutes + ) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Set the dimming time.""" + await self._async_command( + self._device.set_brightness_and_dimming_time( + self.coordinator.last_brightness_pct, int(option) + ) + ) + self.coordinator.last_dimming_time_minutes = int(option) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json index a9d70090170..27a25b6ed4f 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -39,6 +39,11 @@ "resume": { "name": "Resume dimming" } + }, + "select": { + "dimming_time": { + "name": "Dimming time" + } } }, "exceptions": { diff --git a/tests/components/casper_glow/conftest.py b/tests/components/casper_glow/conftest.py index e41f85458ef..a9e5bbf006c 100644 --- a/tests/components/casper_glow/conftest.py +++ b/tests/components/casper_glow/conftest.py @@ -1,6 +1,6 @@ """Casper Glow session fixtures.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -51,6 +51,21 @@ def mock_casper_glow() -> Generator[MagicMock]: yield mock_device +@pytest.fixture +def fire_callbacks( + mock_casper_glow: MagicMock, +) -> Callable[[GlowState], None]: + """Return a helper that fires all registered device callbacks with a given state.""" + + def _fire(state: GlowState) -> None: + for cb in ( + call[0][0] for call in mock_casper_glow.register_callback.call_args_list + ): + cb(state) + + return _fire + + @pytest.fixture async def config_entry( hass: HomeAssistant, diff --git a/tests/components/casper_glow/snapshots/test_select.ambr b/tests/components/casper_glow/snapshots/test_select.ambr new file mode 100644 index 00000000000..5cfba34c22b --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_select.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_entities[select.jar_dimming_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '15', + '30', + '45', + '60', + '90', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.jar_dimming_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dimming time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimming time', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dimming_time', + 'unique_id': 'aa:bb:cc:dd:ee:ff_dimming_time', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[select.jar_dimming_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Jar Dimming time', + 'options': list([ + '15', + '30', + '45', + '60', + '90', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.jar_dimming_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_light.py b/tests/components/casper_glow/test_light.py index b606d18dbbd..7375d2ed7f2 100644 --- a/tests/components/casper_glow/test_light.py +++ b/tests/components/casper_glow/test_light.py @@ -1,5 +1,6 @@ """Test the Casper Glow light platform.""" +from collections.abc import Callable from unittest.mock import MagicMock, patch from pycasperglow import CasperGlowError, GlowState @@ -82,21 +83,19 @@ async def test_turn_off( async def test_state_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that the entity updates state when the device fires a callback.""" state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN - callback = mock_casper_glow.register_callback.call_args[0][0] - - callback(GlowState(is_on=True)) + fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON - callback(GlowState(is_on=False)) + fire_callbacks(GlowState(is_on=False)) state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_OFF @@ -115,25 +114,6 @@ async def test_color_mode( assert ColorMode.BRIGHTNESS in state.attributes["supported_color_modes"] -async def test_turn_on_with_brightness( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, -) -> None: - """Test turning on the light with brightness.""" - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 255}, - blocking=True, - ) - - mock_casper_glow.turn_on.assert_called_once_with() - mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with( - 100, DEFAULT_DIMMING_TIME_MINUTES - ) - - @pytest.mark.parametrize( ("ha_brightness", "device_pct"), [ @@ -169,11 +149,10 @@ async def test_brightness_snap_to_nearest( async def test_brightness_update_via_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that brightness updates via device callback.""" - callback = mock_casper_glow.register_callback.call_args[0][0] - callback(GlowState(is_on=True, brightness_level=80)) + fire_callbacks(GlowState(is_on=True, brightness_level=80)) state = hass.states.get(ENTITY_ID) assert state is not None @@ -181,18 +160,29 @@ async def test_brightness_update_via_callback( assert state.attributes.get(ATTR_BRIGHTNESS) == 153 -async def test_turn_on_error( +@pytest.mark.usefixtures("config_entry") +@pytest.mark.parametrize( + ("service", "mock_method"), + [ + (SERVICE_TURN_ON, "turn_on"), + (SERVICE_TURN_OFF, "turn_off"), + ], +) +async def test_command_error( hass: HomeAssistant, - config_entry: MockConfigEntry, mock_casper_glow: MagicMock, + service: str, + mock_method: str, ) -> None: - """Test that a turn on error raises HomeAssistantError without marking entity unavailable.""" - mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed") + """Test that a device error raises HomeAssistantError without marking entity unavailable.""" + getattr(mock_casper_glow, mock_method).side_effect = CasperGlowError( + "Connection failed" + ) with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, ) @@ -202,27 +192,11 @@ async def test_turn_on_error( assert state.state == STATE_UNKNOWN -async def test_turn_off_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - mock_casper_glow: MagicMock, -) -> None: - """Test that a turn off error raises HomeAssistantError.""" - mock_casper_glow.turn_off.side_effect = CasperGlowError("Connection failed") - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, - ) - - async def test_state_update_via_callback_after_command_failure( hass: HomeAssistant, config_entry: MockConfigEntry, mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that device callbacks correctly update state even after a command failure.""" mock_casper_glow.turn_on.side_effect = CasperGlowError("Connection failed") @@ -241,8 +215,7 @@ async def test_state_update_via_callback_after_command_failure( assert state.state == STATE_UNKNOWN # Device sends a push state update — entity reflects true device state - callback = mock_casper_glow.register_callback.call_args[0][0] - callback(GlowState(is_on=True)) + fire_callbacks(GlowState(is_on=True)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py new file mode 100644 index 00000000000..7878b12259d --- /dev/null +++ b/tests/components/casper_glow/test_select.py @@ -0,0 +1,199 @@ +"""Test the Casper Glow select platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +from pycasperglow import CasperGlowError, GlowState +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.casper_glow.const import ( + DIMMING_TIME_OPTIONS, + SORTED_BRIGHTNESS_LEVELS, +) +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform + +ENTITY_ID = "select.jar_dimming_time" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all select entities match the snapshot.""" + with patch("homeassistant.components.casper_glow.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_state_from_callback( + hass: HomeAssistant, + config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that the select entity shows dimming time reported by device callback.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[2])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[2] + + +async def test_select_option( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test selecting a dimming time option.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + mock_casper_glow.set_brightness_and_dimming_time.assert_called_once_with( + SORTED_BRIGHTNESS_LEVELS[0], int(DIMMING_TIME_OPTIONS[1]) + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + # A subsequent device callback must not overwrite the user's selection. + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[0])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + +async def test_select_option_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, +) -> None: + """Test that a set_brightness_and_dimming_time error raises HomeAssistantError.""" + mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_select_state_update_via_callback_after_command_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that device callbacks correctly update state even after a command failure.""" + mock_casper_glow.set_brightness_and_dimming_time.side_effect = CasperGlowError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, "option": DIMMING_TIME_OPTIONS[1]}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Device sends a push state update — entity reflects true state + fire_callbacks( + GlowState(configured_dimming_time_minutes=int(DIMMING_TIME_OPTIONS[1])) + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[1] + + +async def test_select_ignores_remaining_time_updates( + hass: HomeAssistant, + config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that callbacks with only remaining time do not change the select state.""" + fire_callbacks(GlowState(dimming_time_minutes=44)) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_restore_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, +) -> None: + """Test that the dimming time is restored from the last known state on restart.""" + mock_restore_cache(hass, (State(ENTITY_ID, DIMMING_TIME_OPTIONS[3]),)) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == DIMMING_TIME_OPTIONS[3] + + # Coordinator should be seeded with the restored value. + assert mock_config_entry.runtime_data.last_dimming_time_minutes == int( + DIMMING_TIME_OPTIONS[3] + ) + + +@pytest.mark.parametrize( + "restored_state", + [STATE_UNKNOWN, "invalid", "999"], +) +async def test_restore_state_ignores_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + restored_state: str, +) -> None: + """Test that invalid or unsupported restored states are ignored.""" + mock_restore_cache(hass, (State(ENTITY_ID, restored_state),)) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + assert mock_config_entry.runtime_data.last_dimming_time_minutes is None