Add siren platform (#48309)

* Add siren platform

* add more supported flags and an ability to set siren duration

* tone can be int or string

* fix typing

* fix typehinting

* fix typehints

* implement a proposed approach based on discussion

* Address comments

* fix tests

* Small fix

* Update homeassistant/components/demo/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/demo/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/demo/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/demo/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/demo/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* typing

* use class attributes

* fix naming

* remove device from service description

* Filter out params from turn on service

* fix tests

* fix bugs and tests

* add test

* Combine is_on test with turn on/off/toggle service tests

* Update homeassistant/components/siren/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* fix filtering of turn_on attributes

* none check

* remove services and attributes for volume level, default duration, and default tone

* Update homeassistant/components/siren/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/siren/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/siren/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* import final

* Update homeassistant/components/siren/__init__.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Fix typing and used TypedDict for service parameters

* remove is_on function

* remove class name redundancy

* remove extra service descriptions

* switch to positive_int

* fix schema for tone

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Raman Gupta 2021-07-11 16:51:11 -04:00 committed by GitHub
parent 11d7efb785
commit 0f076610fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 432 additions and 0 deletions

View file

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

View file

@ -22,6 +22,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
"number",
"select",
"sensor",
"siren",
"switch",
"vacuum",
"water_heater",

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
{
"domain": "siren",
"name": "Siren",
"documentation": "https://www.home-assistant.io/integrations/siren",
"codeowners": ["@home-assistant/core", "@raman325"],
"quality_scale": "internal"
}

View file

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

View file

@ -93,6 +93,7 @@ NO_IOT_CLASS = [
"search",
"select",
"sensor",
"siren",
"stt",
"switch",
"system_health",

View file

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

View file

@ -0,0 +1 @@
"""Tests for the siren component."""

View file

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