diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index ddd736b47c0..602c801701d 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] type BoschAlarmConfigEntry = ConfigEntry[Panel] diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index a1d8a7b90f4..2854298f815 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschAlarmConfigEntry -from .const import DOMAIN +from .entity import BoschAlarmAreaEntity async def async_setup_entry( @@ -35,7 +34,7 @@ async def async_setup_entry( ) -class AreaAlarmControlPanel(AlarmControlPanelEntity): +class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): """An alarm control panel entity for a bosch alarm panel.""" _attr_has_entity_name = True @@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - self.panel = panel - self._area = panel.areas[area_id] - self._area_id = area_id - self._attr_unique_id = f"{unique_id}_area_{area_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=self._area.name, - manufacturer="Bosch Security Systems", - via_device=( - DOMAIN, - unique_id, - ), - ) + super().__init__(panel, area_id, unique_id, False, False, True) + self._attr_unique_id = self._area_unique_id @property def alarm_state(self) -> AlarmControlPanelState | None: @@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.panel.area_arm_all(self._area_id) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.panel.connection_status() - - async def async_added_to_hass(self) -> None: - """Run when entity attached to hass.""" - await super().async_added_to_hass() - self._area.status_observer.attach(self.schedule_update_ha_state) - self.panel.connection_status_observer.attach(self.schedule_update_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity removed from hass.""" - await super().async_will_remove_from_hass() - self._area.status_observer.detach(self.schedule_update_ha_state) - self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py new file mode 100644 index 00000000000..f74634125c4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/entity.py @@ -0,0 +1,88 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.sensor import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +class BoschAlarmEntity(Entity): + """A base entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + + def __init__(self, panel: Panel, unique_id: str) -> None: + """Set up a entity for a bosch alarm panel.""" + self.panel = panel + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmAreaEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + observe_alarms: bool, + observe_ready: bool, + observe_status: bool, + ) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._area_id = area_id + self._area_unique_id = f"{unique_id}_area_{area_id}" + self._observe_alarms = observe_alarms + self._observe_ready = observe_ready + self._observe_status = observe_status + self._area = panel.areas[area_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._area_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.attach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.attach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.detach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.detach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json new file mode 100644 index 00000000000..1e207310713 --- /dev/null +++ b/homeassistant/components/bosch_alarm/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "faulting_points": { + "default": "mdi:alert-circle-outline" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 75c331ede40..3a64667a407 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -62,9 +62,9 @@ rules: entity-category: todo entity-device-class: todo entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py new file mode 100644 index 00000000000..3d61c72a883 --- /dev/null +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -0,0 +1,86 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Area + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSensorEntityDescription(SensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + value_fn: Callable[[Area], int] + observe_alarms: bool = False + observe_ready: bool = False + observe_status: bool = False + + +SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + BoschAlarmSensorEntityDescription( + key="faulting_points", + translation_key="faulting_points", + value_fn=lambda area: area.faults, + observe_ready=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up bosch alarm sensors.""" + + panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id + + async_add_entities( + BoschAreaSensor(panel, area_id, unique_id, template) + for area_id in panel.areas + for template in SENSOR_TYPES + ) + + +PARALLEL_UPDATES = 0 + + +class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): + """An area sensor entity for a bosch alarm panel.""" + + entity_description: BoschAlarmSensorEntityDescription + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + entity_description: BoschAlarmSensorEntityDescription, + ) -> None: + """Set up an area sensor entity for a bosch alarm panel.""" + super().__init__( + panel, + area_id, + unique_id, + entity_description.observe_alarms, + entity_description.observe_ready, + entity_description.observe_status, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index aad55eb04b1..6b916dad4fa 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -55,5 +55,13 @@ "authentication_failed": { "message": "Incorrect credentials for panel." } + }, + "entity": { + "sensor": { + "faulting_points": { + "name": "Faulting points", + "unit_of_measurement": "points" + } + } } } diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 8358624b003..02ec592d061 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -131,7 +131,8 @@ def area() -> Generator[Area]: mock.alarm_observer = AsyncMock(spec=Observable) mock.ready_observer = AsyncMock(spec=Observable) mock.alarms = [] - mock.faults = [] + mock.alarms_ids = [] + mock.faults = 0 mock.all_ready = True mock.part_ready = True mock.is_triggered.return_value = False diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr index 23ea722325f..459ddf7a213 100644 --- a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -11,8 +11,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -108,8 +107,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -204,8 +202,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..def2c503a6a --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_sensor[amax_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[amax_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '1234567890_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py new file mode 100644 index 00000000000..02153a9656e --- /dev/null +++ b/tests/components/bosch_alarm/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_faulting_points( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that area faulting point count changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_faulting_points" + assert hass.states.get(entity_id).state == "0" + + area.faults = 1 + await call_observable(hass, area.ready_observer) + + assert hass.states.get(entity_id).state == "1"