Skip to content

Commit

Permalink
Add diagnostic sensors for TotalConnect (#73152)
Browse files Browse the repository at this point in the history
* 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 9808d73.

* Update homeassistant/components/totalconnect/binary_sensor.py

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>

* 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 <goran.johansson@shiftit.se>

* add more to binary_sensor tests

* remove unused import

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
  • Loading branch information
3 people committed May 7, 2023
1 parent bf6d429 commit 16c9158
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 49 deletions.
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 142 additions & 46 deletions homeassistant/components/totalconnect/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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

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

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
class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor):
"""Represent an TotalConnect security zone."""

@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():
Expand All @@ -77,16 +81,108 @@ def device_class(self):
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()
47 changes: 45 additions & 2 deletions tests/components/totalconnect/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,55 @@

ZONE_NORMAL = {
"ZoneID": "1",
"ZoneDescription": "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 = {
Expand Down
86 changes: 86 additions & 0 deletions tests/components/totalconnect/test_binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 16c9158

Please sign in to comment.