diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 179ddd5cad6..0e631cc4a93 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -28,6 +28,7 @@ from homeassistant.const import ( 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 @@ -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) @@ -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): @@ -95,9 +100,7 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b 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)) diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index 9f98be09f0d..bc021c2c364 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -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 @@ -13,10 +13,11 @@ from homeassistant.components.binary_sensor import ( 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 @@ -24,6 +25,10 @@ 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, @@ -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: @@ -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", @@ -138,7 +137,10 @@ class RiscoLocalBinarySensor(RiscoBinarySensor): 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: @@ -150,3 +152,41 @@ class RiscoLocalBinarySensor(RiscoBinarySensor): 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 diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py index 2325d88c03f..71cbd04f391 100644 --- a/tests/components/risco/test_binary_sensor.py +++ b/tests/components/risco/test_binary_sensor.py @@ -13,6 +13,8 @@ from .util import TEST_SITE_UUID, zone_mock 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 @@ -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={}), @@ -126,6 +132,8 @@ 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): @@ -133,6 +141,8 @@ async def test_local_setup(hass, two_zone_local, setup_risco_local): 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")}) @@ -157,6 +167,7 @@ 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 @@ -164,6 +175,22 @@ async def _check_local_state( 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: @@ -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: