Просмотр исходного кода

Add support for alarm_control_panel platform

- Convert ZX-G30 to use alarm_control_panel and deprecate individual buttons

Preparation for issue #796
Update for PR #490
Jason Rumney 2 лет назад
Родитель
Сommit
b60b482b95

+ 4 - 1
DEVICES.md

@@ -378,6 +378,10 @@ of device.
 
 - BCom Majic IPBox intercom camera
 
+### Alarm control panels
+
+- ZX-G30 alarm system
+
 ### Miscellaneous
 
 - generic PIR motion sensor
@@ -404,7 +408,6 @@ of device.
 - Universal remote control (supports sensors only)
 - Yieryi water quality monitor (also matches unbranded PH-W3988 device)
 - ZN-2C09 9-in-1 air quality monitor
-- ZX-G30 alarm system (not as an alarm_control_panel, as individual inputs and sensors)
 - ZY-M100-WiFi mmWave human presence sensor
 
 ### Devices supported via Bluetooth hubs

+ 114 - 0
custom_components/tuya_local/alarm_control_panel.py

@@ -0,0 +1,114 @@
+"""
+Setup for different kinds of Tuya alarm control panels.
+"""
+from homeassistant.components.alarm_control_panel import (
+    AlarmControlPanelEntity,
+)
+from homeassistant.components.alarm_control_panel.const import (
+    AlarmControlPanelEntityFeature as Feature,
+)
+from homeassistant.const import (
+    STATE_ALARM_ARMED_AWAY,
+    STATE_ALARM_ARMED_CUSTOM_BYPASS,
+    STATE_ALARM_ARMED_HOME,
+    STATE_ALARM_ARMED_NIGHT,
+    STATE_ALARM_ARMED_VACATION,
+    STATE_ALARM_DISARMED,
+    STATE_ALARM_TRIGGERED,
+)
+
+import logging
+
+from .device import TuyaLocalDevice
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+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,
+        "alarm_control_panel",
+        TuyaLocalAlarmControlPanel,
+    )
+
+
+class TuyaLocalAlarmControlPanel(TuyaLocalEntity, AlarmControlPanelEntity):
+    """Representation of a Tuya Alarm Control Panel"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the alarm control panel.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._alarm_state_dp = dps_map.get("alarm_state")
+        self._trigger_dp = dps_map.get("trigger")
+
+        self._init_end(dps_map)
+        if not self._alarm_state_dp:
+            raise AttributeError(f"{config.name} is missing an alarm_state dp")
+
+        alarm_states = self._alarm_state_dp.values(device)
+        if STATE_ALARM_ARMED_HOME in alarm_states:
+            self._attr_supported_features |= Feature.ARM_HOME
+        if STATE_ALARM_ARMED_AWAY in alarm_states:
+            self._attr_supported_features |= Feature.ARM_AWAY
+        if STATE_ALARM_ARMED_NIGHT in alarm_states:
+            self._attr_supported_features |= Feature.ARM_NIGHT
+        if STATE_ALARM_ARMED_VACATION in alarm_states:
+            self._attr_supported_features |= Feature.ARM_VACATION
+        if STATE_ALARM_ARMED_CUSTOM_BYPASS in alarm_states:
+            self._attr_supported_features |= Feature.ARM_CUSTOM_BYPASS
+        if self._trigger_dp:
+            self._attr_supported_features |= Feature.TRIGGER
+        # Code support not implemented
+        self._attr_code_format = None
+
+    @property
+    def state(self):
+        """Return the current alarm state."""
+        if self._trigger_dp and self._trigger_dp.get_value(self._device):
+            return STATE_ALARM_TRIGGERED
+        return self._alarm_state_dp.get_value(self._device)
+
+    async def _alarm_send_command(self, cmd):
+        if cmd in self._alarm_state_dp.values(self._device):
+            await self._alarm_state_dp.async_set_value(self._device, cmd)
+        else:
+            raise NotImplementedError()
+
+    async def async_alarm_disarm(self, code=None):
+        """Send disarm command"""
+        await self._alarm_send_command(STATE_ALARM_DISARMED)
+
+    async def async_alarm_arm_home(self, code=None):
+        await self._alarm_send_command(STATE_ALARM_ARMED_HOME)
+
+    async def async_alarm_arm_away(self, code=None):
+        """Send away command"""
+        await self._alarm_send_command(STATE_ALARM_ARMED_AWAY)
+
+    async def async_alarm_arm_night(self, code=None):
+        """Send away command"""
+        await self._alarm_send_command(STATE_ALARM_ARMED_NIGHT)
+
+    async def async_alarm_arm_vacation(self, code=None):
+        """Send away command"""
+        await self._alarm_send_command(STATE_ALARM_ARMED_VACATION)
+
+    async def async_alarm_arm_custom_bypass(self, code=None):
+        await self._alarm_send_command(STATE_ALARM_ARMED_CUSTOM_BYPASS)
+
+    async def async_alarm_trigger(self, code=None):
+        if not self._trigger_dp:
+            raise NotImplementedError()
+        await self._trigger_dp.async_set_value(self._device, True)

+ 24 - 20
custom_components/tuya_local/devices/README.md

@@ -494,17 +494,21 @@ Note that "on" and "off" require quotes in yaml, otherwise it they are interpret
 Many entity types support a class attribute which may change the UI behaviour, icons etc.  See the
 HA documentation for the entity type to see what is valid (these may expand over time)
 
-### binary_sensor
+### `alarm_control_panel`
+- **alarm_state** (required, string) the alarm state, used to report and change the current state of the alarm. Expects values from the set `disarmed`, `armed_home`, `armed_away`, `armed_night`, `armed_vacation`, `armed_custom_bypass`, `pending`, `arming`, `disarming`, `triggered`.  Other states are allowed for read-only status, but only the armed... and disarmed states are available as commands.
+- **trigger** (optional, boolean) used to trigger the alarm remotely for test or panic button etc.
+
+### `binary_sensor`
 - **sensor** (required, boolean) the dp to attach to the sensor.
 
-### button
+### `button`
 - **button** (required, boolean) the dp to attach to the button.  Any
 read value will be ignored, but the dp is expected to be present for
 device detection unless set to optional.  A value of true will be sent
 for a button press, map this to the desired dps_val if a different
 value is required.
 
-### climate
+### `climate`
 - **aux_heat** (optional, boolean) a dp to control the aux heat switch if the device has one.
 - **current_temperature** (optional, number) a dp that reports the current temperature.
 - **current_humidity** (optional, number) a dp that reports the current humidity (%).
@@ -533,7 +537,7 @@ value is required.
 - **min_temperature** (optional, number) a dp that specifies the minimum temperature that can be set.   Some devices provide this, otherwise a fixed range on the temperature dp can be used.
 - **max_temperature** (optional, number) a dp that specifies the maximum temperature that can be set.
 
-### cover
+### `cover`
 
 Either **position** or **open** should be specified.
 
@@ -545,7 +549,7 @@ Either **position** or **open** should be specified.
    Special values are `opening, closing`
 - **open** (optional, boolean): a dp that reports if the cover is open. Only used if **position** is not available.
 
-### fan
+### `fan`
 - **switch** (optional, boolean): a dp to control the power state of the fan
 - **preset_mode** (optional, mapping of strings): a dp to control different modes of the fan.
    Values `"off", low, medium, high` used to be handled specially by HA as deprecated speed aliases.  If these are the only "presets", consider mapping them as **speed** values instead, as voice assistants will respond to phrases like "turn the fan up/down" for speed.
@@ -555,14 +559,14 @@ Either **position** or **open** should be specified.
 - **direction** (optional, string): a dp to control the spin direction of the fan.
    Valid values are `forward, reverse`.
 
-### humidifier
+### `humidifier`
 Humidifer can also cover dehumidifiers (use class to specify which).
 
 - **switch** (optional, boolean): a dp to control the power state of the fan
 - **mode** (optional, mapping of strings): a dp to control preset modes of the device
 - **humidity** (optional, number):  a dp to control the target humidity of the device
 
-### light
+### `light`
 - **switch** (optional, boolean): a dp to control the on/off state of the light
 - **brightness** (optional, number 0-255): a dp to control the dimmer if available.
 - **color_temp** (optional, number): a dp to control the color temperature if available.
@@ -576,7 +580,7 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **effect** (optional, mapping of strings): a dp to control effects / presets supported by the light.
    Note: If the light mixes in color modes in the same dp, `color_mode` should be used instead.  If the light contains both a separate dp for effects/scenes/presets and a mix of color_modes and effects (commonly scene and music) in the `color_mode` dp, then a separate select entity should be used for the dedicated dp to ensure the effects from `color_mode` are selectable.
 
-### lock
+### `lock`
 - **lock** (optional, boolean): a dp to control the lock state: true = locked, false = unlocked
 - **unlock_fingerprint** (optional, integer): a dp to identify the fingerprint used to unlock the lock.
 - **unlock_password** (optional, integer): a dp to identify the password used to unlock the lock.
@@ -588,7 +592,7 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **approve_unlock** (optional, boolean): a dp to unlock the lock in response to a request.
 - **jammed** (optional, boolean): a dp to signal that the lock is jammed.
 
-### number
+### `number`
 - **value** (required, number): a dp to control the number that is set.
 - **unit** (optional, string): a dp that reports the units returned by the number.
     This may be useful for devices that switch between C and F, otherwise a fixed unit attribute on the **value** dp can be used.
@@ -597,18 +601,24 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **maximum** (optional, number): a dp that reports the maximum the number can be set to.
     This may be used as an alternative to a range setting on the **value** dp if the range is dynamic
 
-### select
+### `select`
 - **option** (required, mapping of strings): a dp to control the option that is selected.
 
-### sensor
+### `sensor`
 - **sensor** (required, number or string): a dp that returns the current value of the sensor.
 - **unit** (optional, string): a dp that returns the unit returned by the sensor.
     This may be useful for devices that switch between C and F, otherwise a fixed unit attribute on the **sensor** dp can be used.
 
-### switch
+### `siren`
+- **tone** (required, mapping of strings): a dp to report and control the siren tone. As this is used to turn on and off the siren, it is required. If this does not fit your siren, the underlying implementation will need to be modified.
+The value "off" will be used for turning off the siren, and will be filtered from the list of available tones. One value must be marked as `default: true` so that the `turn_on` service with no commands works.
+- **volume** (optional, float in range 0.0-1.0): a dp to control the volume of the siren (probably needs a scale and step applied, since Tuya devices will probably use an integer, or strings with fixed values).
+- **duration** (optional, integer): a dp to control how long the siren will sound for.
+
+### `switch`
 - **switch** (required, boolean): a dp to control the switch state.
 
-### vacuum
+### `vacuum`
 - **status** (required, mapping of strings): a dp to report and control the status of the vacuum.
 - **command** (optional, mapping of strings): a dp to control the statuss of the vacuum.  If supplied, the status dp is only used to report the state.
     Special values: `return_to_base, clean_spot`, others are sent as general commands
@@ -621,13 +631,7 @@ Humidifer can also cover dehumidifiers (use class to specify which).
 - **error** (optional, bitfield): a dp that reports error status.
     As this is mapped to a single "fault" state, you could consider separate binary_sensors to report on individual errors
 
-### siren
-- **tone** (required, mapping of strings): a dp to report and control the siren tone. As this is used to turn on and off the siren, it is required. If this does not fit your siren, the underlying implementation will need to be modified.
-The value "off" will be used for turning off the siren, and will be filtered from the list of available tones. One value must be marked as `default: true` so that the `turn_on` service with no commands works.
-- **volume** (optional, float in range 0.0-1.0): a dp to control the volume of the siren (probably needs a scale and step applied, since Tuya devices will probably use an integer, or strings with fixed values).
-- **duration** (optional, integer): a dp to control how long the siren will sound for.
-
-### water_heater
+### `water_heater`
 - **current_temperature** (optional, number): a dp that reports the current water temperature.
 
 - **operation_mode** (optional, mapping of strings): a dp to report and control the operation mode of the water heater.  If `away` is one of the modes, another mode must be marked as `default: true` to that the `away_mode_off` service knows which mode to switch out of away mode to.

+ 64 - 11
custom_components/tuya_local/devices/zx_g30_alarm.yaml

@@ -3,18 +3,70 @@ products:
   - id: mw27s3tus4bb7nz3
     name: Dual-network security system
 primary_entity:
-  entity: button
-  name: Disarm
-  icon: "mdi:shield-off"
+  entity: alarm_control_panel
   dps:
     - id: 1
       type: string
-      name: button
+      name: alarm_state
       mapping:
-        - dps_val: "disarmed"
-          value: true
+        - dps_val: disarmed
+          value: disarmed
+        - dps_val: arm
+          value: armed_away
+        - dps_val: home
+          value: armed_home
+    - id: 20
+      type: boolean
+      name: unknown_20
+    - id: 21
+      type: boolean
+      name: unknown_21
+    - id: 22
+      type: integer
+      name: unknown_22
+    - id: 23
+      type: string
+      name: unknown_23
+    - id: 24
+      type: string
+      name: unknown_24
+    - id: 32
+      type: string
+      name: unknown_32
+    - id: 34
+      type: boolean
+      name: unknown_34
+    - id: 35
+      type: boolean
+      name: unknown_35
+    - id: 36
+      type: string
+      name: unknown_36
+    - id: 37
+      type: string
+      name: unknown_37
+    - id: 39
+      type: string
+      name: unknown_39
+    - id: 40
+      type: string
+      name: unknown_40
 secondary_entities:
   - entity: button
+    deprecated: alarm_control_panel
+    category: config
+    name: Disarm
+    icon: "mdi:shield-off"
+    dps:
+      - id: 1
+        type: string
+        name: button
+        mapping:
+          - dps_val: "disarmed"
+            value: true
+  - entity: button
+    deprecated: alarm_control_panel
+    category: config
     name: Away arm
     icon: "mdi:shield-lock"
     dps:
@@ -25,6 +77,8 @@ secondary_entities:
           - dps_val: "arm"
             value: true
   - entity: button
+    deprecated: alarm_control_panel
+    category: config
     name: Home arm
     icon: "mdi:shield-home"
     dps:
@@ -68,13 +122,13 @@ secondary_entities:
   - entity: binary_sensor
     name: Tamper
     class: tamper
-    category: config
+    category: diagnostic
     dps:
       - id: 9
         type: boolean
         name: sensor
   - entity: switch
-    name: Arm/disarm voice prompt
+    name: Voice prompt
     category: config
     icon: "mdi:account-voice"
     dps:
@@ -92,7 +146,6 @@ secondary_entities:
   - entity: sensor
     name: Battery
     class: battery
-    category: config
     dps:
       - id: 16
         type: integer
@@ -101,7 +154,7 @@ secondary_entities:
   - entity: binary_sensor
     name: Low battery alarm
     class: battery
-    category: config
+    category: diagnostic
     dps:
       - id: 17
         type: boolean
@@ -124,7 +177,7 @@ secondary_entities:
         name: value
         unit: sec
   - entity: switch
-    name: Countdown with tick tone
+    name: Tick down
     category: config
     icon: "mdi:timer"
     dps:

+ 28 - 1
tests/const.py

@@ -1564,7 +1564,7 @@ MOEBOT_PAYLOAD = {
 
 TOMPD63LW_SOCKET_PAYLOAD = {
     "1": 139470,
-    "6": "CHoAQgQADlwAAA==",
+    "6": "CPQAFEkAAuk=",
     "9": 0,
     "11": False,
     "12": False,
@@ -1601,3 +1601,30 @@ THERMEX_IF50V_PAYLOAD = {
     "105": "2",
     "106": 0,
 }
+
+ZXG30_ALARM_PAYLOAD = {
+    "1": "home",
+    "2": 0,
+    "3": 3,
+    "4": True,
+    "9": False,
+    "10": False,
+    "15": True,
+    "16": 100,
+    "17": True,
+    "20": False,
+    "21": False,
+    "22": 1,
+    "23": "2",
+    "24": "Normal",
+    "27": True,
+    "28": 10,
+    "29": True,
+    "32": "normal",
+    "34": False,
+    "35": False,
+    "36": "3",
+    "37": "0",
+    "39": "0",
+    "40": "1",
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -5,6 +5,7 @@ from uuid import uuid4
 from homeassistant.helpers.entity import EntityCategory
 
 from custom_components.tuya_local.binary_sensor import TuyaLocalBinarySensor
+from custom_components.tuya_local.alarm_control_panel import TuyaLocalAlarmControlPanel
 from custom_components.tuya_local.button import TuyaLocalButton
 from custom_components.tuya_local.camera import TuyaLocalCamera
 from custom_components.tuya_local.climate import TuyaLocalClimate
@@ -27,6 +28,7 @@ from custom_components.tuya_local.helpers.device_config import (
 )
 
 DEVICE_TYPES = {
+    "alarm_control_panel": TuyaLocalAlarmControlPanel,
     "binary_sensor": TuyaLocalBinarySensor,
     "button": TuyaLocalButton,
     "camera": TuyaLocalCamera,

+ 88 - 0
tests/devices/test_zx_g30_alarm.py

@@ -0,0 +1,88 @@
+"""Tests for the ZX G30 Alarm Control Panel."""
+from homeassistant.components.alarm_control_panel import (
+    AlarmControlPanelEntityFeature as Feature,
+)
+from homeassistant.const import (
+    STATE_ALARM_ARMED_AWAY,
+    STATE_ALARM_ARMED_HOME,
+    STATE_ALARM_DISARMED,
+)
+
+from ..const import ZXG30_ALARM_PAYLOAD
+from ..helpers import assert_device_properties_set
+from .base_device_tests import TuyaDeviceTestCase
+
+ALARMSTATE_DP = "1"
+EXITDELAY_DP = "2"
+SIRENDURATION_DP = "3"
+SIRENTONE_DP = "4"
+TAMPER_DP = "9"
+VOICE_DP = "10"
+POWER_DP = "15"
+BATTERY_DP = "16"
+LOWBATT_DP = "17"
+NOTIFY_DP = "27"
+ENTRYDELAY_DP = "28"
+TICKDOWN_DP = "29"
+
+
+class TestZXG30Alarm(TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("zx_g30_alarm.yaml", ZXG30_ALARM_PAYLOAD)
+        self.subject = self.entities["alarm_control_panel"]
+        self.mark_secondary(
+            [
+                "button_disarm",
+                "button_away_arm",
+                "button_home_arm",
+                "number_exit_delay",
+                "binary_sensor_tamper",
+                "switch_voice_prompt",
+                "switch_ac_power",
+                "binary_sensor_low_battery_alarm",
+                "switch_alarm_notification",
+                "number_entry_delay",
+                "switch_tick_down",
+            ]
+        )
+
+    def test_supported_features(self):
+        self.assertEqual(
+            self.subject.supported_features,
+            Feature.ARM_AWAY | Feature.ARM_HOME,
+        )
+
+    def test_state(self):
+        self.dps[ALARMSTATE_DP] = "disarmed"
+        self.assertEqual(self.subject.state, STATE_ALARM_DISARMED)
+
+    async def test_arm_home(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {ALARMSTATE_DP: "home"},
+        ):
+            await self.subject.async_alarm_arm_home()
+
+    async def test_arm_away(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {ALARMSTATE_DP: "arm"},
+        ):
+            await self.subject.async_alarm_arm_away()
+
+    async def test_disarm(self):
+        async with assert_device_properties_set(
+            self.subject._device,
+            {ALARMSTATE_DP: "disarmed"},
+        ):
+            await self.subject.async_alarm_disarm()
+
+    async def test_arm_vacation_fails_when_not_supported(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_alarm_arm_vacation()
+
+    async def test_trigger_fails_when_not_supported(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_alarm_trigger()

+ 85 - 0
tests/test_alarm_control_panel.py

@@ -0,0 +1,85 @@
+"""Tests for the alarm_control_panel entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+import pytest
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.alarm_control_panel import (
+    async_setup_entry,
+    TuyaLocalAlarmControlPanel,
+)
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test initialisation"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "zx_g30_alarm",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    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"]["alarm_control_panel"])
+        == TuyaLocalAlarmControlPanel
+    )
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_alarm_control_panel(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "kogan_heater",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    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()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "non_existing",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    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()

+ 1 - 0
tests/test_device_config.py

@@ -23,6 +23,7 @@ from .const import (
 )
 
 KNOWN_DPS = {
+    "alarm_control_panel": {"required": ["alarm_state"], "optional": []},
     "binary_sensor": {"required": ["sensor"], "optional": []},
     "button": {"required": ["button"], "optional": []},
     "camera": {