Skip to content

Commit

Permalink
Add alarmed binary sensor to Risco integration (home-assistant#77315)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Hjelmare <[email protected]>
  • Loading branch information
OnFreund and MartinHjelmare authored Oct 25, 2022
1 parent 623abb4 commit 64eb316
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 20 deletions.
11 changes: 7 additions & 4 deletions homeassistant/components/risco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

Expand All @@ -50,7 +51,6 @@ class LocalData:
"""A data class for local data passed to the platforms."""

system: RiscoLocal
zone_updates: dict[int, Callable[[], Any]] = field(default_factory=dict)
partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict)


Expand All @@ -59,6 +59,11 @@ def is_local(entry: ConfigEntry) -> bool:
return entry.data.get(CONF_TYPE) == TYPE_LOCAL


def zone_update_signal(zone_id: int) -> str:
"""Return a signal for the dispatch of a zone update."""
return f"risco_zone_update_{zone_id}"


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Risco from a config entry."""
if is_local(entry):
Expand Down Expand Up @@ -95,9 +100,7 @@ async def _default(command: str, result: str, *params: list[str]) -> None:

async def _zone(zone_id: int, zone: Zone) -> None:
_LOGGER.debug("Risco zone update for %d", zone_id)
callback = local_data.zone_updates.get(zone_id)
if callback:
callback()
async_dispatcher_send(hass, zone_update_signal(zone_id))

entry.async_on_unload(risco.add_zone_handler(_zone))

Expand Down
72 changes: 56 additions & 16 deletions homeassistant/components/risco/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Support for Risco alarm zones."""
from __future__ import annotations

from collections.abc import Callable, Mapping
from collections.abc import Mapping
from typing import Any

from pyrisco.common import Zone
Expand All @@ -13,17 +13,22 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import LocalData, RiscoDataUpdateCoordinator, is_local
from . import LocalData, RiscoDataUpdateCoordinator, is_local, zone_update_signal
from .const import DATA_COORDINATOR, DOMAIN
from .entity import RiscoEntity, binary_sensor_unique_id

SERVICE_BYPASS_ZONE = "bypass_zone"
SERVICE_UNBYPASS_ZONE = "unbypass_zone"


def _unique_id_for_local(system_id: str, zone_id: int) -> str:
return f"{system_id}_zone_{zone_id}_local"


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
Expand All @@ -39,9 +44,11 @@ async def async_setup_entry(
if is_local(config_entry):
local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
RiscoLocalBinarySensor(
local_data.system.id, zone_id, zone, local_data.zone_updates
)
RiscoLocalBinarySensor(local_data.system.id, zone_id, zone)
for zone_id, zone in local_data.system.zones.items()
)
async_add_entities(
RiscoLocalAlarmedBinarySensor(local_data.system.id, zone_id, zone)
for zone_id, zone in local_data.system.zones.items()
)
else:
Expand Down Expand Up @@ -118,18 +125,10 @@ class RiscoLocalBinarySensor(RiscoBinarySensor):

_attr_should_poll = False

def __init__(
self,
system_id: str,
zone_id: int,
zone: Zone,
zone_updates: dict[int, Callable[[], Any]],
) -> None:
def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None:
"""Init the zone."""
super().__init__(zone_id=zone_id, zone=zone)
self._system_id = system_id
self._zone_updates = zone_updates
self._attr_unique_id = f"{system_id}_zone_{zone_id}_local"
self._attr_unique_id = _unique_id_for_local(system_id, zone_id)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Risco",
Expand All @@ -138,7 +137,10 @@ def __init__(

async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
self._zone_updates[self._zone_id] = self.async_write_ha_state
signal = zone_update_signal(self._zone_id)
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self.async_write_ha_state)
)

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
Expand All @@ -150,3 +152,41 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None:

async def _bypass(self, bypass: bool) -> None:
await self._zone.bypass(bypass)


class RiscoLocalAlarmedBinarySensor(BinarySensorEntity):
"""Representation whether a zone in Risco local is currently triggering an alarm."""

_attr_should_poll = False

def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None:
"""Init the zone."""
super().__init__()
self._zone_id = zone_id
self._zone = zone
self._attr_has_entity_name = True
self._attr_name = "Alarmed"
device_unique_id = _unique_id_for_local(system_id, zone_id)
self._attr_unique_id = device_unique_id + "_alarmed"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_unique_id)},
manufacturer="Risco",
name=self._zone.name,
)

async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
signal = zone_update_signal(self._zone_id)
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self.async_write_ha_state)
)

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
return {"zone_id": self._zone_id}

@property
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
return self._zone.alarmed
49 changes: 49 additions & 0 deletions tests/components/risco/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

FIRST_ENTITY_ID = "binary_sensor.zone_0"
SECOND_ENTITY_ID = "binary_sensor.zone_1"
FIRST_ALARMED_ENTITY_ID = FIRST_ENTITY_ID + "_alarmed"
SECOND_ALARMED_ENTITY_ID = SECOND_ENTITY_ID + "_alarmed"


@pytest.fixture
Expand All @@ -23,10 +25,14 @@ def two_zone_local():
zone_mocks[0], "id", new_callable=PropertyMock(return_value=0)
), patch.object(
zone_mocks[0], "name", new_callable=PropertyMock(return_value="Zone 0")
), patch.object(
zone_mocks[0], "alarmed", new_callable=PropertyMock(return_value=False)
), patch.object(
zone_mocks[1], "id", new_callable=PropertyMock(return_value=1)
), patch.object(
zone_mocks[1], "name", new_callable=PropertyMock(return_value="Zone 1")
), patch.object(
zone_mocks[1], "alarmed", new_callable=PropertyMock(return_value=False)
), patch(
"homeassistant.components.risco.RiscoLocal.partitions",
new_callable=PropertyMock(return_value={}),
Expand Down Expand Up @@ -126,13 +132,17 @@ async def test_error_on_connect(hass, connect_with_error, local_config_entry):
registry = er.async_get(hass)
assert not registry.async_is_registered(FIRST_ENTITY_ID)
assert not registry.async_is_registered(SECOND_ENTITY_ID)
assert not registry.async_is_registered(FIRST_ALARMED_ENTITY_ID)
assert not registry.async_is_registered(SECOND_ALARMED_ENTITY_ID)


async def test_local_setup(hass, two_zone_local, setup_risco_local):
"""Test entity setup."""
registry = er.async_get(hass)
assert registry.async_is_registered(FIRST_ENTITY_ID)
assert registry.async_is_registered(SECOND_ENTITY_ID)
assert registry.async_is_registered(FIRST_ALARMED_ENTITY_ID)
assert registry.async_is_registered(SECOND_ALARMED_ENTITY_ID)

registry = dr.async_get(hass)
device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0_local")})
Expand All @@ -157,13 +167,30 @@ async def _check_local_state(
new_callable=PropertyMock(return_value=bypassed),
):
await callback(zone_id, zones[zone_id])
await hass.async_block_till_done()

expected_triggered = STATE_ON if triggered else STATE_OFF
assert hass.states.get(entity_id).state == expected_triggered
assert hass.states.get(entity_id).attributes["bypassed"] == bypassed
assert hass.states.get(entity_id).attributes["zone_id"] == zone_id


async def _check_alarmed_local_state(
hass, zones, alarmed, entity_id, zone_id, callback
):
with patch.object(
zones[zone_id],
"alarmed",
new_callable=PropertyMock(return_value=alarmed),
):
await callback(zone_id, zones[zone_id])
await hass.async_block_till_done()

expected_alarmed = STATE_ON if alarmed else STATE_OFF
assert hass.states.get(entity_id).state == expected_alarmed
assert hass.states.get(entity_id).attributes["zone_id"] == zone_id


@pytest.fixture
def _mock_zone_handler():
with patch("homeassistant.components.risco.RiscoLocal.add_zone_handler") as mock:
Expand Down Expand Up @@ -204,6 +231,28 @@ async def test_local_states(
)


async def test_alarmed_local_states(
hass, two_zone_local, _mock_zone_handler, setup_risco_local
):
"""Test the various alarm states."""
callback = _mock_zone_handler.call_args.args[0]

assert callback is not None

await _check_alarmed_local_state(
hass, two_zone_local, True, FIRST_ALARMED_ENTITY_ID, 0, callback
)
await _check_alarmed_local_state(
hass, two_zone_local, False, FIRST_ALARMED_ENTITY_ID, 0, callback
)
await _check_alarmed_local_state(
hass, two_zone_local, True, SECOND_ALARMED_ENTITY_ID, 1, callback
)
await _check_alarmed_local_state(
hass, two_zone_local, False, SECOND_ALARMED_ENTITY_ID, 1, callback
)


async def test_local_bypass(hass, two_zone_local, setup_risco_local):
"""Test bypassing a zone."""
with patch.object(two_zone_local[0], "bypass") as mock:
Expand Down

0 comments on commit 64eb316

Please sign in to comment.