4
0
Эх сурвалжийг харах

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 жил өмнө
parent
commit
b60b482b95

+ 4 - 1
DEVICES.md

@@ -378,6 +378,10 @@ of device.
 
 
 - BCom Majic IPBox intercom camera
 - BCom Majic IPBox intercom camera
 
 
+### Alarm control panels
+
+- ZX-G30 alarm system
+
 ### Miscellaneous
 ### Miscellaneous
 
 
 - generic PIR motion sensor
 - generic PIR motion sensor
@@ -404,7 +408,6 @@ of device.
 - Universal remote control (supports sensors only)
 - Universal remote control (supports sensors only)
 - Yieryi water quality monitor (also matches unbranded PH-W3988 device)
 - Yieryi water quality monitor (also matches unbranded PH-W3988 device)
 - ZN-2C09 9-in-1 air quality monitor
 - 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
 - ZY-M100-WiFi mmWave human presence sensor
 
 
 ### Devices supported via Bluetooth hubs
 ### 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
 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)
 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.
 - **sensor** (required, boolean) the dp to attach to the sensor.
 
 
-### button
+### `button`
 - **button** (required, boolean) the dp to attach to the button.  Any
 - **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
 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
 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
 for a button press, map this to the desired dps_val if a different
 value is required.
 value is required.
 
 
-### climate
+### `climate`
 - **aux_heat** (optional, boolean) a dp to control the aux heat switch if the device has one.
 - **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_temperature** (optional, number) a dp that reports the current temperature.
 - **current_humidity** (optional, number) a dp that reports the current humidity (%).
 - **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.
 - **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.
 - **max_temperature** (optional, number) a dp that specifies the maximum temperature that can be set.
 
 
-### cover
+### `cover`
 
 
 Either **position** or **open** should be specified.
 Either **position** or **open** should be specified.
 
 
@@ -545,7 +549,7 @@ Either **position** or **open** should be specified.
    Special values are `opening, closing`
    Special values are `opening, closing`
 - **open** (optional, boolean): a dp that reports if the cover is open. Only used if **position** is not available.
 - **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
 - **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.
 - **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.
    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.
 - **direction** (optional, string): a dp to control the spin direction of the fan.
    Valid values are `forward, reverse`.
    Valid values are `forward, reverse`.
 
 
-### humidifier
+### `humidifier`
 Humidifer can also cover dehumidifiers (use class to specify which).
 Humidifer can also cover dehumidifiers (use class to specify which).
 
 
 - **switch** (optional, boolean): a dp to control the power state of the fan
 - **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
 - **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
 - **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
 - **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.
 - **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.
 - **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.
 - **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.
    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
 - **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_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.
 - **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.
 - **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.
 - **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.
 - **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.
 - **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.
     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.
 - **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
     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.
 - **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.
 - **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.
 - **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.
     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.
 - **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.
 - **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.
 - **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
     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.
 - **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
     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.
 - **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.
 - **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
   - id: mw27s3tus4bb7nz3
     name: Dual-network security system
     name: Dual-network security system
 primary_entity:
 primary_entity:
-  entity: button
-  name: Disarm
-  icon: "mdi:shield-off"
+  entity: alarm_control_panel
   dps:
   dps:
     - id: 1
     - id: 1
       type: string
       type: string
-      name: button
+      name: alarm_state
       mapping:
       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:
 secondary_entities:
   - entity: button
   - 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
     name: Away arm
     icon: "mdi:shield-lock"
     icon: "mdi:shield-lock"
     dps:
     dps:
@@ -25,6 +77,8 @@ secondary_entities:
           - dps_val: "arm"
           - dps_val: "arm"
             value: true
             value: true
   - entity: button
   - entity: button
+    deprecated: alarm_control_panel
+    category: config
     name: Home arm
     name: Home arm
     icon: "mdi:shield-home"
     icon: "mdi:shield-home"
     dps:
     dps:
@@ -68,13 +122,13 @@ secondary_entities:
   - entity: binary_sensor
   - entity: binary_sensor
     name: Tamper
     name: Tamper
     class: tamper
     class: tamper
-    category: config
+    category: diagnostic
     dps:
     dps:
       - id: 9
       - id: 9
         type: boolean
         type: boolean
         name: sensor
         name: sensor
   - entity: switch
   - entity: switch
-    name: Arm/disarm voice prompt
+    name: Voice prompt
     category: config
     category: config
     icon: "mdi:account-voice"
     icon: "mdi:account-voice"
     dps:
     dps:
@@ -92,7 +146,6 @@ secondary_entities:
   - entity: sensor
   - entity: sensor
     name: Battery
     name: Battery
     class: battery
     class: battery
-    category: config
     dps:
     dps:
       - id: 16
       - id: 16
         type: integer
         type: integer
@@ -101,7 +154,7 @@ secondary_entities:
   - entity: binary_sensor
   - entity: binary_sensor
     name: Low battery alarm
     name: Low battery alarm
     class: battery
     class: battery
-    category: config
+    category: diagnostic
     dps:
     dps:
       - id: 17
       - id: 17
         type: boolean
         type: boolean
@@ -124,7 +177,7 @@ secondary_entities:
         name: value
         name: value
         unit: sec
         unit: sec
   - entity: switch
   - entity: switch
-    name: Countdown with tick tone
+    name: Tick down
     category: config
     category: config
     icon: "mdi:timer"
     icon: "mdi:timer"
     dps:
     dps:

+ 28 - 1
tests/const.py

@@ -1564,7 +1564,7 @@ MOEBOT_PAYLOAD = {
 
 
 TOMPD63LW_SOCKET_PAYLOAD = {
 TOMPD63LW_SOCKET_PAYLOAD = {
     "1": 139470,
     "1": 139470,
-    "6": "CHoAQgQADlwAAA==",
+    "6": "CPQAFEkAAuk=",
     "9": 0,
     "9": 0,
     "11": False,
     "11": False,
     "12": False,
     "12": False,
@@ -1601,3 +1601,30 @@ THERMEX_IF50V_PAYLOAD = {
     "105": "2",
     "105": "2",
     "106": 0,
     "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 homeassistant.helpers.entity import EntityCategory
 
 
 from custom_components.tuya_local.binary_sensor import TuyaLocalBinarySensor
 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.button import TuyaLocalButton
 from custom_components.tuya_local.camera import TuyaLocalCamera
 from custom_components.tuya_local.camera import TuyaLocalCamera
 from custom_components.tuya_local.climate import TuyaLocalClimate
 from custom_components.tuya_local.climate import TuyaLocalClimate
@@ -27,6 +28,7 @@ from custom_components.tuya_local.helpers.device_config import (
 )
 )
 
 
 DEVICE_TYPES = {
 DEVICE_TYPES = {
+    "alarm_control_panel": TuyaLocalAlarmControlPanel,
     "binary_sensor": TuyaLocalBinarySensor,
     "binary_sensor": TuyaLocalBinarySensor,
     "button": TuyaLocalButton,
     "button": TuyaLocalButton,
     "camera": TuyaLocalCamera,
     "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 = {
 KNOWN_DPS = {
+    "alarm_control_panel": {"required": ["alarm_state"], "optional": []},
     "binary_sensor": {"required": ["sensor"], "optional": []},
     "binary_sensor": {"required": ["sensor"], "optional": []},
     "button": {"required": ["button"], "optional": []},
     "button": {"required": ["button"], "optional": []},
     "camera": {
     "camera": {