bosch_shc: add select platform (Outdoor Siren sound level) (#183117)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-09-30 12:08:03 +01:00
committed by GitHub
co-authored by Claude Sonnet 5
parent cf6bbf52b6
commit 06aabd6e5e
5 changed files with 229 additions and 0 deletions
@@ -20,6 +20,7 @@ PLATFORMS = [
Platform.COVER,
Platform.EVENT,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
@@ -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)
@@ -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"
+25
View File
@@ -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",
+84
View File
@@ -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