1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Casper Glow - Add Select Options (#166553)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Mike O'Driscoll
2026-03-28 12:48:22 -04:00
committed by GitHub
parent 738b85c17d
commit 2285db5bb1
12 changed files with 432 additions and 56 deletions

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,11 @@
"resume": {
"default": "mdi:play"
}
},
"select": {
"dimming_time": {
"default": "mdi:timer-outline"
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,11 @@
"resume": {
"name": "Resume dimming"
}
},
"select": {
"dimming_time": {
"name": "Dimming time"
}
}
},
"exceptions": {

View File

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

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.jar_dimming_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# 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': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'select.jar_dimming_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

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

View File

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