Add sensor platform to bosch_alarm (#142151)

* add sensor platform to bosch_alarm

* add icon translations for sensors

* translate entity names

* translate entity names

* translate entity names

* update snapshots

* translate ready to arm sensor

* translate ready to arm sensor

* update tests

* update translations

* remove history sensor, we will replace it with an events sensor later

* fix tests

* fix tests

* fix tests

* update tests

* fix sensor links

* only call async_add_entities once

* convert area alarms to sensors based on type

* add sensor for alarms

* add icons

* cleanup area sensor

* add available

* loop over dict

* use entity description

* use entity description

* clean up entity descriptions

* observe_alarms and observe_ready

* refactor alarm_control_panel to use base entity

* remove more old sensors

* add unit of measurement

* update test snapshots

* use correct observer
This commit is contained in:
Sanjay Govind 2025-04-08 23:55:43 +12:00 committed by GitHub
parent 36192ebc3a
commit 894cc7cc4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 400 additions and 43 deletions

View File

@ -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]

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"faulting_points": {
"default": "mdi:alert-circle-outline"
}
}
}
}

View File

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

View File

@ -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)

View File

@ -55,5 +55,13 @@
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
},
"entity": {
"sensor": {
"faulting_points": {
"name": "Faulting points",
"unit_of_measurement": "points"
}
}
}
}

View File

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

View File

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

View File

@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.area1_faulting_points',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.area1_faulting_points',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensor[b5512][sensor.area1_faulting_points-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.area1_faulting_points',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.area1_faulting_points',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.area1_faulting_points',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.area1_faulting_points',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---

View File

@ -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"