diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 6b82109a8b29..b133db935433 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.COVER, Platform.EVENT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/bosch_shc/select.py b/homeassistant/components/bosch_shc/select.py new file mode 100644 index 000000000000..bee48f8af4b4 --- /dev/null +++ b/homeassistant/components/bosch_shc/select.py @@ -0,0 +1,109 @@ +"""Platform for select integration.""" + +from collections.abc import Callable, Coroutine, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, override + +from boschshcpy import OutdoorSirenService, SHCOutdoorSiren +from boschshcpy.device import SHCDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschConfigEntry +from .entity import SHCEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SHCSelectEntityDescription[_DeviceT: SHCDevice](SelectEntityDescription): + """Describes a SHC select entity.""" + + current_option_fn: Callable[[_DeviceT, Sequence[str] | None], str | None] + select_option_fn: Callable[[_DeviceT, str], Coroutine[Any, Any, None]] + + +def _siren_current_option( + device: SHCOutdoorSiren, options: Sequence[str] | None +) -> str | None: + """Read the Outdoor Siren's current sound level (already lowercased).""" + try: + return str(device.siren.sound_level.name.lower()) + except AttributeError, ValueError: + return None + + +async def _siren_select_option(device: SHCOutdoorSiren, option: str) -> None: + """Write the Outdoor Siren's sound level.""" + level = OutdoorSirenService.SoundLevel[option.upper()] + await device.siren.async_set_configuration(sound_level=level) + + +SIREN_SOUND_LEVEL_DESCRIPTION = SHCSelectEntityDescription[SHCOutdoorSiren]( + key="siren_sound_level", + translation_key="siren_sound_level", + entity_category=EntityCategory.CONFIG, + options=["low", "medium", "high"], + current_option_fn=_siren_current_option, + select_option_fn=_siren_select_option, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the SHC select platform.""" + session = config_entry.runtime_data + shc_info = session.information + if TYPE_CHECKING: + assert shc_info is not None and shc_info.unique_id is not None + + async_add_entities( + SHCSelect( + hass=hass, + device=siren, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + description=SIREN_SOUND_LEVEL_DESCRIPTION, + ) + for siren in session.device_helper.outdoor_sirens + if siren.siren is not None + ) + + +class SHCSelect[_DeviceT: SHCDevice](SHCEntity, SelectEntity): + """Generic SHC select entity, driven by a SHCSelectEntityDescription.""" + + entity_description: SHCSelectEntityDescription[_DeviceT] + _device: _DeviceT + + def __init__( + self, + hass: HomeAssistant, + device: _DeviceT, + parent_id: str, + entry_id: str, + description: SHCSelectEntityDescription[_DeviceT], + ) -> None: + """Initialize the select entity.""" + self.entity_description = description + super().__init__( + hass=hass, device=device, parent_id=parent_id, entry_id=entry_id + ) + self._attr_unique_id = f"{device.serial}_{description.key}" + + @property + @override + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.current_option_fn(self._device, self.options) + + @override + async def async_select_option(self, option: str) -> None: + """Select an option, writing it to the device.""" + await self.entity_description.select_option_fn(self._device, option) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index d19af93f229e..89b89e04e6bb 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -70,6 +70,16 @@ "name": "Pulse length" } }, + "select": { + "siren_sound_level": { + "name": "Siren volume", + "state": { + "high": "Loud", + "low": "Reduced", + "medium": "Medium" + } + } + }, "sensor": { "air_quality": { "name": "Air quality" diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 717368510cdf..7e70e1d1a75c 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -21,6 +21,7 @@ from boschshcpy import ( SHCMicromoduleRelay, SHCMotionDetector, SHCMotionDetector2, + SHCOutdoorSiren, SHCPresenceSimulationSystem, SHCShutterContact, SHCShutterContact2, @@ -38,6 +39,7 @@ from boschshcpy import ( ThermostatService, ) from boschshcpy.services_impl import ( + OutdoorSirenService, PresenceSimulationConfigurationService, ValveTappetService, ) @@ -90,6 +92,7 @@ _EMPTY_DEVICE_BUCKETS: dict[str, Any] = { "micromodule_shutter_controls", "motion_detectors", "motion_detectors2", + "outdoor_sirens", "roomthermostats", "shutter_contacts", "shutter_contacts2", @@ -208,6 +211,28 @@ def camera_outdoor_gen2_device( return device +def outdoor_siren_device( + device_id: str = "hdm:ZigBee:outdoorsiren1", + name: str = "Outdoor Siren", + sound_level: OutdoorSirenService.SoundLevel = OutdoorSirenService.SoundLevel.MEDIUM, +) -> SHCOutdoorSiren: + """Build a minimal device double for the outdoor_sirens bucket.""" + device = create_autospec(SHCOutdoorSiren, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "OUTDOOR_SIREN" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + siren_service = create_autospec(OutdoorSirenService, instance=True) + siren_service.sound_level = sound_level + device.siren = siren_service + return device + + def shutter_control_device( device_id: str = "hdm:ZigBee:shutter1", name: str = "Shutter", diff --git a/tests/components/bosch_shc/test_select.py b/tests/components/bosch_shc/test_select.py new file mode 100644 index 000000000000..4c86af6a421b --- /dev/null +++ b/tests/components/bosch_shc/test_select.py @@ -0,0 +1,84 @@ +"""Tests for the Bosch SHC select platform.""" + +from unittest.mock import MagicMock + +from boschshcpy.services_impl import OutdoorSirenService +import pytest + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION +from homeassistant.core import HomeAssistant + +from .conftest import outdoor_siren_device, setup_integration + +from tests.common import MockConfigEntry + +SOUND_LEVEL_ENTITY_ID = "select.outdoor_siren_siren_volume" + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "outdoor_sirens": [ + outdoor_siren_device(sound_level=OutdoorSirenService.SoundLevel.MEDIUM) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_outdoor_siren_sound_level_current_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """The Outdoor Siren's current sound level is reported.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(SOUND_LEVEL_ENTITY_ID) + assert state is not None + assert state.state == "medium" + + +@pytest.mark.parametrize( + "device_buckets", + [{"outdoor_sirens": [outdoor_siren_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_outdoor_siren_sound_level_select_option( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Selecting a sound level writes it to the device.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.outdoor_sirens[0] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SOUND_LEVEL_ENTITY_ID, "option": "high"}, + blocking=True, + ) + device.siren.async_set_configuration.assert_awaited_once_with( + sound_level=OutdoorSirenService.SoundLevel.HIGH + ) + + +@pytest.mark.parametrize( + "device_buckets", + [{"outdoor_sirens": [outdoor_siren_device()]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_outdoor_siren_no_siren_service( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """No select entity is created for a siren without the siren service.""" + mock_session.device_helper.outdoor_sirens[0].siren = None + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(SOUND_LEVEL_ENTITY_ID) is None