From 16c915864b872a5cbf5b87288275a8d385d5e358 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sun, 7 May 2023 01:27:33 -0700 Subject: [PATCH] Add diagnostic sensors for TotalConnect (#73152) * add diagnostic sensors * test binary_sensor.py file * add tests for binary sensor * fix zone type checks and error on unknown * improve entity tests * hide entities by default * Revert "hide entities by default" This reverts commit 9808d732471385e45ccc5f7c3aea93bfecbdfa6f. * Update homeassistant/components/totalconnect/binary_sensor.py Co-authored-by: Paulus Schoutsen * update binary_sensor per comments * update test * move to _attr_extra_state_attributes * no spaces in unique_id * update per balloob suggestions * fix typing * fix black and mypy * Apply suggestions from code review Co-authored-by: G Johansson * add more to binary_sensor tests * remove unused import --------- Co-authored-by: Paulus Schoutsen Co-authored-by: G Johansson --- .coveragerc | 1 - .../components/totalconnect/binary_sensor.py | 188 +++++++++++++----- tests/components/totalconnect/common.py | 49 ++++- .../totalconnect/test_binary_sensor.py | 86 ++++++++ 4 files changed, 274 insertions(+), 50 deletions(-) create mode 100644 tests/components/totalconnect/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 1bb8bdcb687..834232c9025 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1293,7 +1293,6 @@ omit = homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/__init__.py - homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 32e0b3573f5..ef252d54e4e 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -1,74 +1,78 @@ """Interfaces with TotalConnect sensors.""" +import logging + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +LOW_BATTERY = "low_battery" +TAMPER = "tamper" +POWER = "power" +ZONE = "zone" + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up TotalConnect device sensors based on a config entry.""" - sensors = [] + sensors: list = [] client_locations = hass.data[DOMAIN][entry.entry_id].client.locations for location_id, location in client_locations.items(): - for zone_id, zone in location.zones.items(): - sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) + sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location)) + sensors.append(TotalConnectAlarmTamperBinarySensor(location)) + sensors.append(TotalConnectAlarmPowerBinarySensor(location)) + + for zone in location.zones.values(): + sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone)) + + if not zone.is_type_button(): + sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone)) + sensors.append(TotalConnectTamperBinarySensor(location_id, zone)) async_add_entities(sensors, True) -class TotalConnectBinarySensor(BinarySensorEntity): +class TotalConnectZoneBinarySensor(BinarySensorEntity): """Represent an TotalConnect zone.""" - def __init__(self, zone_id, location_id, zone): + def __init__(self, location_id, zone): """Initialize the TotalConnect status.""" - self._zone_id = zone_id self._location_id = location_id self._zone = zone - self._name = self._zone.description - self._unique_id = f"{location_id} {zone_id}" - self._is_on = None - self._is_tampered = None - self._is_low_battery = None + self._attr_name = f"{zone.description}{self.entity_description.name}" + self._attr_unique_id = ( + f"{location_id}_{zone.zoneid}_{self.entity_description.key}" + ) + self._attr_is_on = None + self._attr_extra_state_attributes = { + "zone_id": self._zone.zoneid, + "location_id": self._location_id, + "partition": self._zone.partition, + } - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property - def name(self): - """Return the name of the device.""" - return self._name +class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect security zone.""" - def update(self) -> None: - """Return the state of the device.""" - self._is_tampered = self._zone.is_tampered() - self._is_low_battery = self._zone.is_low_battery() - - if self._zone.is_faulted() or self._zone.is_triggered(): - self._is_on = True - else: - self._is_on = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._is_on + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=ZONE, name="" + ) @property def device_class(self): - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._zone.is_type_security(): - return BinarySensorDeviceClass.DOOR + """Return the class of this zone.""" if self._zone.is_type_fire(): return BinarySensorDeviceClass.SMOKE if self._zone.is_type_carbon_monoxide(): @@ -77,16 +81,108 @@ class TotalConnectBinarySensor(BinarySensorEntity): return BinarySensorDeviceClass.MOTION if self._zone.is_type_medical(): return BinarySensorDeviceClass.SAFETY + # "security" type is a generic category so test for it last + if self._zone.is_type_security(): + return BinarySensorDeviceClass.DOOR + + _LOGGER.error( + "TotalConnect zone %s reported an unexpected device class", + self._zone.zoneid, + ) return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = { - "zone_id": self._zone_id, - "location_id": self._location_id, - "low_battery": self._is_low_battery, - "tampered": self._is_tampered, - "partition": self._zone.partition, + def update(self): + """Return the state of the device.""" + if self._zone.is_faulted() or self._zone.is_triggered(): + self._attr_is_on = True + else: + self._attr_is_on = False + + +class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_low_battery() + + +class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_tampered() + + +class TotalConnectAlarmBinarySensor(BinarySensorEntity): + """Represent an TotalConnect alarm device binary sensors.""" + + def __init__(self, location): + """Initialize the TotalConnect alarm device binary sensor.""" + self._location = location + self._attr_name = f"{location.location_name}{self.entity_description.name}" + self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}" + self._attr_is_on = None + self._attr_extra_state_attributes = { + "location_id": self._location.location_id, } - return attributes + + +class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect Alarm low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_low_battery() + + +class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_cover_tampered() + + +class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm power status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {POWER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = not self._location.is_ac_loss() diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 65b10718fd5..54f321c6770 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -148,12 +148,55 @@ PARTITIONS_UNKNOWN = {"PartitionInfo": PARTITION_INFO_UNKNOWN} ZONE_NORMAL = { "ZoneID": "1", - "ZoneDescription": "Normal", - "ZoneStatus": ZoneStatus.NORMAL, + "ZoneDescription": "Security", + "ZoneStatus": ZoneStatus.FAULT, + "ZoneTypeId": ZoneType.SECURITY, "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_2 = { + "ZoneID": "2", + "ZoneDescription": "Fire", + "ZoneStatus": ZoneStatus.LOW_BATTERY, + "ZoneTypeId": ZoneType.FIRE_SMOKE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_3 = { + "ZoneID": "3", + "ZoneDescription": "Gas", + "ZoneStatus": ZoneStatus.TAMPER, + "ZoneTypeId": ZoneType.CARBON_MONOXIDE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_4 = { + "ZoneID": "4", + "ZoneDescription": "Motion", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.INTERIOR_FOLLOWER, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_5 = { + "ZoneID": "5", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.PROA7_MEDICAL, + "PartitionId": "1", + "CanBeBypassed": 0, +} +# 99 is an unknown ZoneType +ZONE_6 = { + "ZoneID": "6", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": 99, + "PartitionId": "1", + "CanBeBypassed": 0, } -ZONE_INFO = [ZONE_NORMAL] +ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6] ZONES = {"ZoneInfo": ZONE_INFO} METADATA_DISARMED = { diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py new file mode 100644 index 00000000000..966daeb5a63 --- /dev/null +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -0,0 +1,86 @@ +"""Tests for the TotalConnect binary sensor.""" +from unittest.mock import patch + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR, + BinarySensorDeviceClass, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import LOCATION_ID, RESPONSE_DISARMED, ZONE_NORMAL, setup_platform + +ZONE_ENTITY_ID = "binary_sensor.security" +ZONE_LOW_BATTERY_ID = "binary_sensor.security_low_battery" +ZONE_TAMPER_ID = "binary_sensor.security_tamper" +PANEL_BATTERY_ID = "binary_sensor.test_low_battery" +PANEL_TAMPER_ID = "binary_sensor.test_tamper" +PANEL_POWER_ID = "binary_sensor.test_power" + + +async def test_entity_registry(hass: HomeAssistant) -> None: + """Test the binary sensor is registered in entity registry.""" + await setup_platform(hass, BINARY_SENSOR) + entity_registry = er.async_get(hass) + + # ensure zone 1 plus two diagnostic zones are created + entry = entity_registry.async_get(ZONE_ENTITY_ID) + entry_low_battery = entity_registry.async_get(ZONE_LOW_BATTERY_ID) + entry_tamper = entity_registry.async_get(ZONE_TAMPER_ID) + + assert entry.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_zone" + assert ( + entry_low_battery.unique_id + == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_low_battery" + ) + assert entry_tamper.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_tamper" + + # ensure panel diagnostic zones are created + panel_battery = entity_registry.async_get(PANEL_BATTERY_ID) + panel_tamper = entity_registry.async_get(PANEL_TAMPER_ID) + panel_power = entity_registry.async_get(PANEL_POWER_ID) + + assert panel_battery.unique_id == f"{LOCATION_ID}_low_battery" + assert panel_tamper.unique_id == f"{LOCATION_ID}_tamper" + assert panel_power.unique_id == f"{LOCATION_ID}_power" + + +async def test_state_and_attributes(hass: HomeAssistant) -> None: + """Test the binary sensor attributes are correct.""" + + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.request", + return_value=RESPONSE_DISARMED, + ): + await setup_platform(hass, BINARY_SENSOR) + + state = hass.states.get(ZONE_ENTITY_ID) + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == ZONE_NORMAL["ZoneDescription"] + ) + assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR + + state = hass.states.get(f"{ZONE_ENTITY_ID}_low_battery") + assert state.state == STATE_OFF + state = hass.states.get(f"{ZONE_ENTITY_ID}_tamper") + assert state.state == STATE_OFF + + # Zone 2 is fire with low battery + state = hass.states.get("binary_sensor.fire") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.SMOKE + state = hass.states.get("binary_sensor.fire_low_battery") + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.fire_tamper") + assert state.state == STATE_OFF + + # Zone 3 is gas with tamper + state = hass.states.get("binary_sensor.gas") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.GAS + state = hass.states.get("binary_sensor.gas_low_battery") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.gas_tamper") + assert state.state == STATE_ON