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 <github@bnck.me>

* Update homeassistant/components/totalconnect/button.py

Co-authored-by: Jan-Philipp Benecke <github@bnck.me>

* Update homeassistant/components/totalconnect/button.py

Co-authored-by: Jan-Philipp Benecke <github@bnck.me>

* remove unused logging

* Update homeassistant/components/totalconnect/button.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/totalconnect/button.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <github@bnck.me>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Austin Mroczek 2024-04-29 00:47:05 -07:00 committed by GitHub
parent 0425b7aa6d
commit 8153ff78bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 487 additions and 1 deletions

View File

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

View File

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

View File

@ -55,6 +55,17 @@
"partition": {
"name": "Partition {partition_id}"
}
},
"button": {
"clear_bypass": {
"name": "Clear bypass"
},
"bypass_all": {
"name": "Bypass all"
},
"bypass": {
"name": "Bypass"
}
}
}
}

View File

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

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.fire_bypass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.fire_bypass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entity_registry[button.gas_bypass-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.gas_bypass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.gas_bypass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entity_registry[button.motion_bypass-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.motion_bypass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.motion_bypass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entity_registry[button.security_bypass-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'button.security_bypass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.security_bypass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entity_registry[button.test_bypass_all-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_bypass_all',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_bypass_all',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entity_registry[button.test_clear_bypass-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_clear_bypass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_clear_bypass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

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