Browse Source

Add support for siren platform.

Siren supports volume, duration and tone, turn on and turn off (not implemented, as initial device does not appear to support direct control).  It doesn't contain any standard attributes, however where available we return the standard values as extra attributes so they can be read as well as set via the turn_on service.

- Support Orion Grid Connect outdoor siren using it, along with a battery sensor and charge and tamper binary sensors.

Issue #198
Jason Rumney 3 years ago
parent
commit
ea1b356b80

+ 1 - 0
ACKNOWLEDGEMENTS.md

@@ -108,3 +108,4 @@ Further device support has been made with the assistance of users.  Please consi
 - [OmegaKill](https://github.com/OmegaKill) for assistance supporting Be Cool heatpumps.
 - [OmegaKill](https://github.com/OmegaKill) for assistance supporting Be Cool heatpumps.
 - [djusHa](https://github.com/djusHa) for contributing support for essentials portable air purifier.
 - [djusHa](https://github.com/djusHa) for contributing support for essentials portable air purifier.
 - [alexmaras](https://github.com/alexmaras) for contributing support for Catit Pixi smart fountain.
 - [alexmaras](https://github.com/alexmaras) for contributing support for Catit Pixi smart fountain.
+- [jamiergrs](https://github.com/jamiergrs) for assistance supporting Orion Grid Connect outdoor sirens.

+ 3 - 0
README.md

@@ -210,6 +210,9 @@ Other brands may work with the above configurations
 ### Locks
 ### Locks
 - Orion Grid Connect Smart Lock
 - Orion Grid Connect Smart Lock
 
 
+### Sirens
+- Orion Grid Connect Outdoor Siren
+
 ### Miscellaneous
 ### Miscellaneous
 - Qoto 03 Smart Water Valve / Sprinkler Controller
 - Qoto 03 Smart Water Valve / Sprinkler Controller
 - SD123 HPR01 Human Presence Radar
 - SD123 HPR01 Human Presence Radar

+ 64 - 0
custom_components/tuya_local/devices/orion_outdoor_siren.yaml

@@ -0,0 +1,64 @@
+name: Orion Outdoor Siren
+product:
+  - id: im2eqqhj72suwwko
+    model: SWS08HA
+primary_entity:
+  entity: siren
+  dps:
+    - id: 1
+      name: tone
+      type: string
+      mapping:
+        - dps_val: alarm_sound
+          value: sound
+        - dps_val: alarm_light
+          value: light
+        - dps_val: alarm_sound_light
+          value: sound+light
+        - dps_val: normal
+          value: normal
+    - id: 5
+      name: volume_level
+      type: string
+      mapping:
+        - dps_val: mute
+          value: 0.0
+        - dps_val: low
+          value: 0.33
+        - dps_val: middle
+          value: 0.67
+        - dps_val: high
+          value: 1.0
+    - id: 7
+      name: duration
+      type: integer
+      range:
+        min: 1
+        max: 10
+      unit: min
+secondary_entities:
+  - entity: binary_sensor
+    name: Charging
+    category: diagnostic
+    class: battery_charging
+    dps:
+      - id: 6
+        name: sensor
+        type: boolean
+  - entity: sensor
+    name: Battery
+    category: diagnostic
+    class: battery
+    dps:
+      - id: 15
+        name: sensor
+        type: integer
+        unit: "%"
+  - entity: binary_sensor
+    name: Tamper Detect
+    category: diagnostic
+    class: tamper
+    dps:
+      - id: 20
+        name: sensor
+        type: boolean

+ 76 - 0
custom_components/tuya_local/generic/siren.py

@@ -0,0 +1,76 @@
+"""
+Platform to control Tuya sirens.
+"""
+from homeassistant.components.siren import (
+    SirenEntity,
+    SirenEntityDescription,
+    SirenEntityFeature,
+)
+
+from ..device import TuyaLocalDevice
+from ..helpers.device_config import TuyaEntityConfig
+from ..helpers.mixin import TuyaLocalEntity
+
+
+class TuyaLocalSiren(TuyaLocalEntity, SirenEntity):
+    """Representation of a Tuya siren"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialize the siren.
+        Args:
+           device (TuyaLocalDevice): The device API instance.
+           config (TuyaEntityConfig): The config for this entity.
+        """
+        dps_map = self._init_begin(device, config)
+        self._tone_dp = dps_map.get("tone", None)
+        self._volume_dp = dps_map.get("volume_level", None)
+        self._duration_dp = dps_map.get("duration", None)
+        self._init_end(dps_map)
+        # All control of features is through the turn_on service, so we need to
+        # support that, even if the siren does not support direct control
+        support = SirenEntityFeature.TURN_ON
+        if self._tone_dp:
+            support |= SirenEntityFeature.TONES
+            self.entity_description = SirenEntityDescription
+            self.entity_description.available_tones = self._tone_dp.values(device)
+
+        if self._volume_dp:
+            support |= SirenEntityFeature.VOLUME_SET
+        if self._duration_dp:
+            support |= SirenEntityFeature.DURATION
+        self._attr_supported_features = support
+
+    async def async_turn_on(self, **kwargs) -> None:
+        tone = kwargs.get("tone", None)
+        duration = kwargs.get("duration", None)
+        volume = kwargs.get("volume", None)
+        set_dps = {}
+
+        if tone is not None and self._tone_dp:
+            set_dps = {
+                **set_dps,
+                **self._tone_dp.get_values_to_set(self._device, tone),
+            }
+        if duration is not None and self._duration_dp:
+            set_dps = {
+                **set_dps,
+                **self._duration_dp.get_values_to_set(self._device, duration),
+            }
+
+        if volume is not None and self._volume_dp:
+            # Volume is a float, range 0.0-1.0 in Home Assistant
+            # In tuya it is likely an integer or a fixed list of values.
+            # For integer, expect scale and step to do the conversion,
+            # for fixed values, we need to snap to closest value.
+            if self._volume_dp.values(self._device) is not None:
+                volume = min(
+                    self._volume_dp.values(self._device), key=lambda x: abs(x - volume)
+                )
+
+            set_dps = {
+                **set_dps,
+                **self._volume_dp.get_values_to_set(self._device, volume),
+            }
+
+        await self._device.async_set_properties(set_dps)

+ 16 - 0
custom_components/tuya_local/siren.py

@@ -0,0 +1,16 @@
+"""
+Setup for Tuya siren devices
+"""
+from .generic.siren import TuyaLocalSiren
+from .helpers.config import async_tuya_setup_platform
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "siren",
+        TuyaLocalSiren,
+    )

+ 9 - 0
tests/const.py

@@ -1474,3 +1474,12 @@ AVATTO_BLINDS_PAYLOAD = {
     "9": 0,
     "9": 0,
     "11": 0,
     "11": 0,
 }
 }
+
+ORION_SIREN_PAYLOAD = {
+    "1": "normal",
+    "5": "middle",
+    "6": True,
+    "7": 10,
+    "15": 0,
+    "20": True,
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -14,6 +14,7 @@ from custom_components.tuya_local.generic.lock import TuyaLocalLock
 from custom_components.tuya_local.generic.number import TuyaLocalNumber
 from custom_components.tuya_local.generic.number import TuyaLocalNumber
 from custom_components.tuya_local.generic.select import TuyaLocalSelect
 from custom_components.tuya_local.generic.select import TuyaLocalSelect
 from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
 from custom_components.tuya_local.generic.sensor import TuyaLocalSensor
+from custom_components.tuya_local.generic.siren import TuyaLocalSiren
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 from custom_components.tuya_local.generic.switch import TuyaLocalSwitch
 from custom_components.tuya_local.generic.vacuum import TuyaLocalVacuum
 from custom_components.tuya_local.generic.vacuum import TuyaLocalVacuum
 
 
@@ -34,6 +35,7 @@ DEVICE_TYPES = {
     "switch": TuyaLocalSwitch,
     "switch": TuyaLocalSwitch,
     "select": TuyaLocalSelect,
     "select": TuyaLocalSelect,
     "sensor": TuyaLocalSensor,
     "sensor": TuyaLocalSensor,
+    "siren": TuyaLocalSiren,
     "vacuum": TuyaLocalVacuum,
     "vacuum": TuyaLocalVacuum,
 }
 }
 
 

+ 153 - 0
tests/devices/test_orion_outdoor_siren.py

@@ -0,0 +1,153 @@
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.components.siren import SirenEntityFeature
+from homeassistant.const import PERCENTAGE
+
+from ..const import ORION_SIREN_PAYLOAD
+from ..helpers import assert_device_properties_set
+from ..mixins.binary_sensor import MultiBinarySensorTests
+from ..mixins.sensor import BasicSensorTests
+from .base_device_tests import TuyaDeviceTestCase
+
+TONE_DP = "1"
+VOLUME_DP = "5"
+CHARGING_DP = "6"
+DURATION_DP = "7"
+BATTERY_DP = "15"
+TAMPER_DP = "20"
+
+
+class TestOrionSiren(MultiBinarySensorTests, BasicSensorTests, TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("orion_outdoor_siren.yaml", ORION_SIREN_PAYLOAD)
+        self.subject = self.entities.get("siren")
+        self.setUpMultiBinarySensors(
+            [
+                {
+                    "dps": CHARGING_DP,
+                    "name": "binary_sensor_charging",
+                    "device_class": BinarySensorDeviceClass.BATTERY_CHARGING,
+                },
+                {
+                    "dps": TAMPER_DP,
+                    "name": "binary_sensor_tamper_detect",
+                    "device_class": BinarySensorDeviceClass.TAMPER,
+                },
+            ]
+        )
+        self.setUpBasicSensor(
+            BATTERY_DP,
+            self.entities.get("sensor_battery"),
+            unit=PERCENTAGE,
+            device_class=SensorDeviceClass.BATTERY,
+        )
+        self.mark_secondary(
+            ["sensor_battery", "binary_sensor_charging", "binary_sensor_tamper_detect"]
+        )
+
+    def test_supported_features(self):
+        """Test the supported features of the siren"""
+        self.assertEqual(
+            self.subject.supported_features,
+            SirenEntityFeature.TURN_ON
+            | SirenEntityFeature.TONES
+            | SirenEntityFeature.DURATION
+            | SirenEntityFeature.VOLUME_SET,
+        )
+
+    def test_available_tones(self):
+        """Test the available tones from the siren"""
+        self.assertCountEqual(
+            self.subject.available_tones,
+            [
+                "sound",
+                "light",
+                "sound+light",
+                "normal",
+            ],
+        )
+
+    async def test_set_to_sound(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {TONE_DP: "alarm_sound"}
+        ):
+            await self.subject.async_turn_on(tone="sound")
+
+    async def test_set_to_light(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {TONE_DP: "alarm_light"}
+        ):
+            await self.subject.async_turn_on(tone="light")
+
+    async def test_set_to_sound_light(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {TONE_DP: "alarm_sound_light"}
+        ):
+            await self.subject.async_turn_on(tone="sound+light")
+
+    async def test_set_to_normal(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {TONE_DP: "normal"}
+        ):
+            await self.subject.async_turn_on(tone="normal")
+
+    async def test_set_volume_low(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {VOLUME_DP: "low"}
+        ):
+            await self.subject.async_turn_on(volume=0.3)
+
+    async def test_set_volume_mid(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {VOLUME_DP: "middle"}
+        ):
+            await self.subject.async_turn_on(volume=0.7)
+
+    async def test_set_volume_high(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {VOLUME_DP: "high"}
+        ):
+            await self.subject.async_turn_on(volume=1.0)
+
+    async def test_set_volume_mute(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device, {VOLUME_DP: "mute"}
+        ):
+            await self.subject.async_turn_on(volume=0.0)
+
+    async def test_set_duration(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(self.subject._device, {DURATION_DP: 5}):
+            await self.subject.async_turn_on(duration=5)
+
+    async def test_set_multi(self):
+        """Test turning on the siren with various parameters"""
+        async with assert_device_properties_set(
+            self.subject._device,
+            {TONE_DP: "alarm_sound", DURATION_DP: 4, VOLUME_DP: "high"},
+        ):
+            await self.subject.async_turn_on(tone="sound", duration=4, volume=0.9)
+
+    def test_extra_attributes(self):
+        """Test reading the extra attributes from the siren"""
+        self.dps[TONE_DP] = "alarm_light"
+        self.dps[VOLUME_DP] = "middle"
+        self.dps[DURATION_DP] = 3
+        self.assertDictEqual(
+            self.subject.extra_state_attributes,
+            {
+                "tone": "light",
+                "volume_level": 0.67,
+                "duration": 3,
+            },
+        )

+ 0 - 1
tests/test_lock.py

@@ -3,7 +3,6 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
 from unittest.mock import AsyncMock, Mock
 from unittest.mock import AsyncMock, Mock
 
 
 from custom_components.tuya_local.const import (
 from custom_components.tuya_local.const import (
-    CONF_LOCK,
     CONF_DEVICE_ID,
     CONF_DEVICE_ID,
     CONF_TYPE,
     CONF_TYPE,
     DOMAIN,
     DOMAIN,

+ 66 - 0
tests/test_siren.py

@@ -0,0 +1,66 @@
+"""Tests for the siren entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.generic.siren import TuyaLocalSiren
+from custom_components.tuya_local.siren import async_setup_entry
+
+
+async def test_init_entry(hass):
+    """Test initialisation"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "orion_outdoor_siren",
+            CONF_DEVICE_ID: "dummy",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["siren"]) == TuyaLocalSiren
+    m_add_entities.assert_called_once()
+
+
+async def test_init_entry_fails_if_device_has_no_siren(hass):
+    """Test initialisation when device as no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "simple_switch", CONF_DEVICE_ID: "dummy"},
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when config does not exist"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={CONF_TYPE: "non_existing", CONF_DEVICE_ID: "dummy"},
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()