From 8153ff78bfd8dd82e460d39fd4e12ef59eed8023 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 29 Apr 2024 00:47:05 -0700 Subject: [PATCH] Add Button for TotalConnect (#114530) * add button for totalconnect * test button for totalconnect * change to zone.can_be_bypassed * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * remove unused logging * Update homeassistant/components/totalconnect/button.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/totalconnect/button.py Co-authored-by: Joost Lekkerkerker * fix button and test * Revert "bump total_connect_client to 2023.12.1" This reverts commit 189b7dcd89cf3cc8309dacc92ba47927cfbbdef3. * bump total_connect_client to 2023.12.1 * use ZoneEntity for Bypass button * use LocationEntity for PanelButton * fix typing * add translation_key for panel buttons * mock clear_bypass instead of disarm * use paramaterize * use snapshot * sentence case in strings * remove un-needed stuff * Update homeassistant/components/totalconnect/button.py * Apply suggestions from code review * Fix --------- Co-authored-by: Jan-Philipp Benecke Co-authored-by: Joost Lekkerkerker --- .../components/totalconnect/__init__.py | 2 +- .../components/totalconnect/button.py | 101 +++++++ .../components/totalconnect/strings.json | 11 + tests/components/totalconnect/common.py | 19 ++ .../totalconnect/snapshots/test_button.ambr | 277 ++++++++++++++++++ tests/components/totalconnect/test_button.py | 78 +++++ 6 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/totalconnect/button.py create mode 100644 tests/components/totalconnect/snapshots/test_button.ambr create mode 100644 tests/components/totalconnect/test_button.py diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index e10858c6c12..76e0a09af39 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN -PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py new file mode 100644 index 00000000000..ec2d0a604c7 --- /dev/null +++ b/homeassistant/components/totalconnect/button.py @@ -0,0 +1,101 @@ +"""Interfaces with TotalConnect buttons.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from total_connect_client.location import TotalConnectLocation +from total_connect_client.zone import TotalConnectZone + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TotalConnectDataUpdateCoordinator +from .const import DOMAIN +from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity + + +@dataclass(frozen=True, kw_only=True) +class TotalConnectButtonEntityDescription(ButtonEntityDescription): + """TotalConnect button description.""" + + press_fn: Callable[[TotalConnectLocation], None] + + +PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = ( + TotalConnectButtonEntityDescription( + key="clear_bypass", + translation_key="clear_bypass", + press_fn=lambda location: location.clear_bypass(), + ), + TotalConnectButtonEntityDescription( + key="bypass_all", + translation_key="bypass_all", + press_fn=lambda location: location.zone_bypass_all(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up TotalConnect buttons based on a config entry.""" + buttons: list = [] + coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + for location_id, location in coordinator.client.locations.items(): + buttons.extend( + TotalConnectPanelButton(coordinator, location, description) + for description in PANEL_BUTTONS + ) + + buttons.extend( + TotalConnectZoneBypassButton(coordinator, zone, location_id) + for zone in location.zones.values() + if zone.can_be_bypassed + ) + + async_add_entities(buttons) + + +class TotalConnectZoneBypassButton(TotalConnectZoneEntity, ButtonEntity): + """Represent a TotalConnect zone bypass button.""" + + _attr_translation_key = "bypass" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: TotalConnectDataUpdateCoordinator, + zone: TotalConnectZone, + location_id: str, + ) -> None: + """Initialize the TotalConnect status.""" + super().__init__(coordinator, zone, location_id, "bypass") + + def press(self) -> None: + """Press the bypass button.""" + self._zone.bypass() + + +class TotalConnectPanelButton(TotalConnectLocationEntity, ButtonEntity): + """Generic TotalConnect panel button.""" + + entity_description: TotalConnectButtonEntityDescription + + def __init__( + self, + coordinator: TotalConnectDataUpdateCoordinator, + location: TotalConnectLocation, + entity_description: TotalConnectButtonEntityDescription, + ) -> None: + """Initialize the TotalConnect button.""" + super().__init__(coordinator, location) + self.entity_description = entity_description + self._attr_unique_id = f"{location.location_id}_{entity_description.key}" + + def press(self) -> None: + """Press the button.""" + self.entity_description.press_fn(self._location) diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 03656b60084..e2e5ed7c490 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -55,6 +55,17 @@ "partition": { "name": "Partition {partition_id}" } + }, + "button": { + "clear_bypass": { + "name": "Clear bypass" + }, + "bypass_all": { + "name": "Bypass all" + }, + "bypass": { + "name": "Bypass" + } } } } diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 0dde43a9710..1ceb893112c 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -206,6 +206,17 @@ ZONE_7 = { "CanBeBypassed": 0, } +# ZoneType security that cannot be bypassed is a Button on the alarm panel +ZONE_8 = { + "ZoneID": 8, + "ZoneDescription": "Button", + "ZoneStatus": ZoneStatus.FAULT, + "ZoneTypeId": ZoneType.SECURITY, + "PartitionId": "1", + "CanBeBypassed": 0, +} + + ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6, ZONE_7] ZONES = {"ZoneInfo": ZONE_INFO} @@ -318,6 +329,14 @@ RESPONSE_USER_CODE_INVALID = { "ResultData": "testing user code invalid", } RESPONSE_SUCCESS = {"ResultCode": ResultCode.SUCCESS.value} +RESPONSE_ZONE_BYPASS_SUCCESS = { + "ResultCode": ResultCode.SUCCESS.value, + "ResultData": "None", +} +RESPONSE_ZONE_BYPASS_FAILURE = { + "ResultCode": ResultCode.FAILED_TO_BYPASS_ZONE.value, + "ResultData": "None", +} USERNAME = "username@me.com" PASSWORD = "password" diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr new file mode 100644 index 00000000000..af3318591c6 --- /dev/null +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_entity_registry[button.fire_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.fire_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_2_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.fire_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fire Bypass', + }), + 'context': , + 'entity_id': 'button.fire_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.gas_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.gas_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_3_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.gas_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Bypass', + }), + 'context': , + 'entity_id': 'button.gas_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.motion_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.motion_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_4_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.motion_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Bypass', + }), + 'context': , + 'entity_id': 'button.motion_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.security_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.security_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_1_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.security_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Bypass', + }), + 'context': , + 'entity_id': 'button.security_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.test_bypass_all-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_bypass_all', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass all', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_all', + 'unique_id': '123456_bypass_all', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.test_bypass_all-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test Bypass all', + }), + 'context': , + 'entity_id': 'button.test_bypass_all', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.test_clear_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_clear_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clear_bypass', + 'unique_id': '123456_clear_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.test_clear_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test Clear bypass', + }), + 'context': , + 'entity_id': 'button.test_clear_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py new file mode 100644 index 00000000000..03b08316be2 --- /dev/null +++ b/tests/components/totalconnect/test_button.py @@ -0,0 +1,78 @@ +"""Tests for the TotalConnect buttons.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from total_connect_client.exceptions import FailedToBypassZone + +from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ( + RESPONSE_ZONE_BYPASS_FAILURE, + RESPONSE_ZONE_BYPASS_SUCCESS, + TOTALCONNECT_REQUEST, + setup_platform, +) + +from tests.common import snapshot_platform + +ZONE_BYPASS_ID = "button.security_bypass" +PANEL_CLEAR_ID = "button.test_clear_bypass" +PANEL_BYPASS_ID = "button.test_bypass_all" + + +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test the button is registered in entity registry.""" + entry = await setup_platform(hass, BUTTON) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize("entity_id", [ZONE_BYPASS_ID, PANEL_BYPASS_ID]) +async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: + """Test pushing a bypass button.""" + responses = [RESPONSE_ZONE_BYPASS_FAILURE, RESPONSE_ZONE_BYPASS_SUCCESS] + await setup_platform(hass, BUTTON) + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + # try to bypass, but fails + with pytest.raises(FailedToBypassZone): + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_request.call_count == 1 + + # try to bypass, works this time + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_request.call_count == 2 + + +async def test_clear_button(hass: HomeAssistant) -> None: + """Test pushing the clear bypass button.""" + data = {ATTR_ENTITY_ID: PANEL_CLEAR_ID} + await setup_platform(hass, BUTTON) + TOTALCONNECT_REQUEST = ( + "total_connect_client.location.TotalConnectLocation.clear_bypass" + ) + + with patch(TOTALCONNECT_REQUEST) as mock_request: + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data=data, + blocking=True, + ) + assert mock_request.call_count == 1