diff --git a/CODEOWNERS b/CODEOWNERS index c420d297afa..ff7dcab2cb3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -442,6 +442,7 @@ homeassistant/components/sighthound/* @robmarkcole homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb +homeassistant/components/siren/* @home-assistant/core @raman325 homeassistant/components/sisyphus/* @jkeljo homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slack/* @bachya diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index acd98465207..eef341a9c8b 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -22,6 +22,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "number", "select", "sensor", + "siren", "switch", "vacuum", "water_heater", diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py new file mode 100644 index 00000000000..b810e48f954 --- /dev/null +++ b/homeassistant/components/demo/siren.py @@ -0,0 +1,83 @@ +"""Demo platform that offers a fake siren device.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.siren import SirenEntity +from homeassistant.components.siren.const import ( + SUPPORT_DURATION, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +SUPPORT_FLAGS = SUPPORT_TURN_OFF | SUPPORT_TURN_ON + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the Demo siren devices.""" + async_add_entities( + [ + DemoSiren(name="Siren"), + DemoSiren( + name="Siren with all features", + available_tones=["fire", "alarm"], + support_volume_set=True, + support_duration=True, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo siren devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSiren(SirenEntity): + """Representation of a demo siren device.""" + + def __init__( + self, + name: str, + available_tones: str | None = None, + support_volume_set: bool = False, + support_duration: bool = False, + is_on: bool = True, + ) -> None: + """Initialize the siren device.""" + self._attr_name = name + self._attr_should_poll = False + self._attr_supported_features = SUPPORT_FLAGS + self._attr_is_on = is_on + if available_tones is not None: + self._attr_supported_features |= SUPPORT_TONES + if support_volume_set: + self._attr_supported_features |= SUPPORT_VOLUME_SET + if support_duration: + self._attr_supported_features |= SUPPORT_DURATION + self._attr_available_tones = available_tones + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py new file mode 100644 index 00000000000..c9550574bb1 --- /dev/null +++ b/homeassistant/components/siren/__init__.py @@ -0,0 +1,135 @@ +"""Component to interface with various sirens/chimes.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any, TypedDict, cast, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import ServiceCall +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + ATTR_AVAILABLE_TONES, + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + +TURN_ON_SCHEMA = { + vol.Optional(ATTR_TONE): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_DURATION): cv.positive_int, + vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, +} + + +class SirenTurnOnServiceParameters(TypedDict, total=False): + """Represent possible parameters to siren.turn_on service data dict type.""" + + tone: int | str + duration: int + volume_level: float + + +def filter_turn_on_params( + siren: SirenEntity, params: SirenTurnOnServiceParameters +) -> SirenTurnOnServiceParameters: + """Filter out params not supported by the siren.""" + supported_features = siren.supported_features or 0 + + if not supported_features & SUPPORT_TONES: + params.pop(ATTR_TONE, None) + if not supported_features & SUPPORT_DURATION: + params.pop(ATTR_DURATION, None) + if not supported_features & SUPPORT_VOLUME_SET: + params.pop(ATTR_VOLUME_LEVEL, None) + + return params + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up siren devices.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + async def async_handle_turn_on_service( + siren: SirenEntity, call: ServiceCall + ) -> None: + """Handle turning a siren on.""" + await siren.async_turn_on( + **filter_turn_on_params( + siren, cast(SirenTurnOnServiceParameters, dict(call.data)) + ) + ) + + component.async_register_entity_service( + SERVICE_TURN_ON, TURN_ON_SCHEMA, async_handle_turn_on_service, [SUPPORT_TURN_ON] + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, {}, "async_turn_off", [SUPPORT_TURN_OFF] + ) + component.async_register_entity_service( + SERVICE_TOGGLE, {}, "async_toggle", [SUPPORT_TURN_ON & SUPPORT_TURN_OFF] + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class SirenEntity(ToggleEntity): + """Representation of a siren device.""" + + _attr_available_tones: list[int | str] | None = None + + @final + @property + def capability_attributes(self) -> dict[str, Any] | None: + """Return capability attributes.""" + supported_features = self.supported_features or 0 + + if supported_features & SUPPORT_TONES and self.available_tones is not None: + return {ATTR_AVAILABLE_TONES: self.available_tones} + + return None + + @property + def available_tones(self) -> list[int | str] | None: + """ + Return a list of available tones. + + Requires SUPPORT_TONES. + """ + return self._attr_available_tones diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py new file mode 100644 index 00000000000..2faab9ed8e8 --- /dev/null +++ b/homeassistant/components/siren/const.py @@ -0,0 +1,17 @@ +"""Constants for the siren component.""" + +from typing import Final + +DOMAIN: Final = "siren" + +ATTR_TONE: Final = "tone" + +ATTR_AVAILABLE_TONES: Final = "available_tones" +ATTR_DURATION: Final = "duration" +ATTR_VOLUME_LEVEL: Final = "volume_level" + +SUPPORT_TURN_ON: Final = 1 +SUPPORT_TURN_OFF: Final = 2 +SUPPORT_TONES: Final = 4 +SUPPORT_VOLUME_SET: Final = 8 +SUPPORT_DURATION: Final = 16 diff --git a/homeassistant/components/siren/manifest.json b/homeassistant/components/siren/manifest.json new file mode 100644 index 00000000000..454835c33b0 --- /dev/null +++ b/homeassistant/components/siren/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "siren", + "name": "Siren", + "documentation": "https://www.home-assistant.io/integrations/siren", + "codeowners": ["@home-assistant/core", "@raman325"], + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/siren/services.yaml b/homeassistant/components/siren/services.yaml new file mode 100644 index 00000000000..8c5ed3be974 --- /dev/null +++ b/homeassistant/components/siren/services.yaml @@ -0,0 +1,41 @@ +# Describes the format for available siren services + +turn_on: + description: Turn siren on. + target: + entity: + domain: siren + fields: + tone: + description: The tone to emit when turning the siren on. Must be supported by the integration. + example: fire + required: false + selector: + text: + volume_level: + description: The volume level of the noise to emit when turning the siren on. Must be supported by the integration. + example: 0.5 + required: false + selector: + number: + min: 0 + max: 1 + step: 0.05 + duration: + description: The duration in seconds of the noise to emit when turning the siren on. Must be supported by the integration. + example: 15 + required: false + selector: + text: + +turn_off: + description: Turn siren off. + target: + entity: + domain: siren + +toggle: + description: Toggles a siren. + target: + entity: + domain: siren diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 00110e11fbc..797729542f4 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -93,6 +93,7 @@ NO_IOT_CLASS = [ "search", "select", "sensor", + "siren", "stt", "switch", "system_health", diff --git a/tests/components/demo/test_siren.py b/tests/components/demo/test_siren.py new file mode 100644 index 00000000000..74c39c668e9 --- /dev/null +++ b/tests/components/demo/test_siren.py @@ -0,0 +1,108 @@ +"""The tests for the demo siren component.""" +from unittest.mock import call, patch + +import pytest + +from homeassistant.components.siren.const import ( + ATTR_AVAILABLE_TONES, + ATTR_VOLUME_LEVEL, + DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.setup import async_setup_component + +ENTITY_SIREN = "siren.siren" +ENTITY_SIREN_WITH_ALL_FEATURES = "siren.siren_with_all_features" + + +@pytest.fixture(autouse=True) +async def setup_demo_siren(hass): + """Initialize setup demo siren.""" + assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass): + """Test the initial parameters.""" + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + assert ATTR_AVAILABLE_TONES not in state.attributes + + +def test_all_setup_params(hass): + """Test the setup with all parameters.""" + state = hass.states.get(ENTITY_SIREN_WITH_ALL_FEATURES) + assert state.attributes.get(ATTR_AVAILABLE_TONES) == ["fire", "alarm"] + + +async def test_turn_on(hass): + """Test turn on device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + +async def test_turn_off(hass): + """Test turn off device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + +async def test_toggle(hass): + """Test toggle device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + +async def test_turn_on_strip_attributes(hass): + """Test attributes are stripped from turn_on service call when not supported.""" + with patch( + "homeassistant.components.demo.siren.DemoSiren.async_turn_on" + ) as svc_call: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_SIREN, ATTR_VOLUME_LEVEL: 1}, + blocking=True, + ) + assert svc_call.called + assert svc_call.call_args_list[0] == call(**{ATTR_ENTITY_ID: [ENTITY_SIREN]}) diff --git a/tests/components/siren/__init__.py b/tests/components/siren/__init__.py new file mode 100644 index 00000000000..a246822bdc5 --- /dev/null +++ b/tests/components/siren/__init__.py @@ -0,0 +1 @@ +"""Tests for the siren component.""" diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py new file mode 100644 index 00000000000..e3b8bded6c8 --- /dev/null +++ b/tests/components/siren/test_init.py @@ -0,0 +1,37 @@ +"""The tests for the siren component.""" +from unittest.mock import MagicMock + +from homeassistant.components.siren import SirenEntity + + +class MockSirenEntity(SirenEntity): + """Mock siren device to use in tests.""" + + _attr_is_on = True + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return 0 + + +async def test_sync_turn_on(hass): + """Test if async turn_on calls sync turn_on.""" + siren = MockSirenEntity() + siren.hass = hass + + siren.turn_on = MagicMock() + await siren.async_turn_on() + + assert siren.turn_on.called + + +async def test_sync_turn_off(hass): + """Test if async turn_off calls sync turn_off.""" + siren = MockSirenEntity() + siren.hass = hass + + siren.turn_off = MagicMock() + await siren.async_turn_off() + + assert siren.turn_off.called