Add binary sensors to bosch_alarm (#142147)

* Add binary sensors to bosch_alarm

* make one device per sensor, remove device class guessing

* fix tests

* update tests

* Apply suggested changes

* add binary sensors

* make fault sensors diagnostic

* update tests

* update binary sensors to use base entity

* fix strings

* fix icons

* add state translations for area ready sensors

* use constants in tests

* apply changes from review

* remove fault prefix, use default translation for battery low

* update tests
This commit is contained in:
Sanjay Govind 2025-05-15 06:56:08 +12:00 committed by GitHub
parent 460f02ede5
commit dbdffbba23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 3415 additions and 1 deletions

View File

@ -16,6 +16,7 @@ from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
] ]

View File

@ -0,0 +1,220 @@
"""Support for Bosch Alarm Panel binary sensors."""
from __future__ import annotations
from dataclasses import dataclass
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity, BoschAlarmEntity, BoschAlarmPointEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmFaultEntityDescription(BinarySensorEntityDescription):
"""Describes Bosch Alarm sensor entity."""
fault: int
FAULT_TYPES = [
BoschAlarmFaultEntityDescription(
key="panel_fault_battery_low",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.BATTERY,
fault=ALARM_PANEL_FAULTS.BATTERY_LOW,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_battery_mising",
translation_key="panel_fault_battery_mising",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.BATTERY_MISING,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_ac_fail",
translation_key="panel_fault_ac_fail",
entity_registry_enabled_default=True,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.AC_FAIL,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_phone_line_failure",
translation_key="panel_fault_phone_line_failure",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
fault=ALARM_PANEL_FAULTS.PHONE_LINE_FAILURE,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_parameter_crc_fail_in_pif",
translation_key="panel_fault_parameter_crc_fail_in_pif",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.PARAMETER_CRC_FAIL_IN_PIF,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_communication_fail_since_rps_hang_up",
translation_key="panel_fault_communication_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.COMMUNICATION_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_sdi_fail_since_rps_hang_up",
translation_key="panel_fault_sdi_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.SDI_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_user_code_tamper_since_rps_hang_up",
translation_key="panel_fault_user_code_tamper_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.USER_CODE_TAMPER_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_fail_to_call_rps_since_rps_hang_up",
translation_key="panel_fault_fail_to_call_rps_since_rps_hang_up",
entity_registry_enabled_default=False,
fault=ALARM_PANEL_FAULTS.FAIL_TO_CALL_RPS_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_point_bus_fail_since_rps_hang_up",
translation_key="panel_fault_point_bus_fail_since_rps_hang_up",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.POINT_BUS_FAIL_SINCE_RPS_HANG_UP,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_log_overflow",
translation_key="panel_fault_log_overflow",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.LOG_OVERFLOW,
),
BoschAlarmFaultEntityDescription(
key="panel_fault_log_threshold",
translation_key="panel_fault_log_threshold",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.PROBLEM,
fault=ALARM_PANEL_FAULTS.LOG_THRESHOLD,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors for alarm points and the connection status."""
panel = config_entry.runtime_data
entities: list[BinarySensorEntity] = [
PointSensor(panel, point_id, config_entry.unique_id or config_entry.entry_id)
for point_id in panel.points
]
entities.extend(
PanelFaultsSensor(
panel,
config_entry.unique_id or config_entry.entry_id,
fault_type,
)
for fault_type in FAULT_TYPES
)
entities.extend(
AreaReadyToArmSensor(
panel, area_id, config_entry.unique_id or config_entry.entry_id, "away"
)
for area_id in panel.areas
)
entities.extend(
AreaReadyToArmSensor(
panel, area_id, config_entry.unique_id or config_entry.entry_id, "home"
)
for area_id in panel.areas
)
async_add_entities(entities)
PARALLEL_UPDATES = 0
class PanelFaultsSensor(BoschAlarmEntity, BinarySensorEntity):
"""A binary sensor entity for each fault type in a bosch alarm panel."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: BoschAlarmFaultEntityDescription
def __init__(
self,
panel: Panel,
unique_id: str,
entity_description: BoschAlarmFaultEntityDescription,
) -> None:
"""Set up a binary sensor entity for each fault type in a bosch alarm panel."""
super().__init__(panel, unique_id, True)
self.entity_description = entity_description
self._fault_type = entity_description.fault
self._attr_unique_id = f"{unique_id}_fault_{entity_description.key}"
@property
def is_on(self) -> bool:
"""Return if this fault has occurred."""
return self._fault_type in self.panel.panel_faults_ids
class AreaReadyToArmSensor(BoschAlarmAreaEntity, BinarySensorEntity):
"""A binary sensor entity showing if a panel is ready to arm."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, panel: Panel, area_id: int, unique_id: str, arm_type: str
) -> None:
"""Set up a binary sensor entity for the arming status in a bosch alarm panel."""
super().__init__(panel, area_id, unique_id, False, False, True)
self.panel = panel
self._arm_type = arm_type
self._attr_translation_key = f"area_ready_to_arm_{arm_type}"
self._attr_unique_id = f"{self._area_unique_id}_ready_to_arm_{arm_type}"
@property
def is_on(self) -> bool:
"""Return if this panel is ready to arm."""
if self._arm_type == "away":
return self._area.all_ready
if self._arm_type == "home":
return self._area.all_ready or self._area.part_ready
return False
class PointSensor(BoschAlarmPointEntity, BinarySensorEntity):
"""A binary sensor entity for a point in a bosch alarm panel."""
_attr_name = None
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
"""Set up a binary sensor entity for a point in a bosch alarm panel."""
super().__init__(panel, point_id, unique_id)
self._attr_unique_id = self._point_unique_id
@property
def is_on(self) -> bool:
"""Return if this point sensor is on."""
return self._point.is_open()

View File

@ -17,9 +17,13 @@ class BoschAlarmEntity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, panel: Panel, unique_id: str) -> None: def __init__(
self, panel: Panel, unique_id: str, observe_faults: bool = False
) -> None:
"""Set up a entity for a bosch alarm panel.""" """Set up a entity for a bosch alarm panel."""
self.panel = panel self.panel = panel
self._observe_faults = observe_faults
self._attr_should_poll = False
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model}", name=f"Bosch {panel.model}",
@ -34,10 +38,14 @@ class BoschAlarmEntity(Entity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Observe state changes.""" """Observe state changes."""
self.panel.connection_status_observer.attach(self.schedule_update_ha_state) self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
if self._observe_faults:
self.panel.faults_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes.""" """Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state) self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
if self._observe_faults:
self.panel.faults_observer.attach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity): class BoschAlarmAreaEntity(BoschAlarmEntity):
@ -88,6 +96,33 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
self._area.status_observer.detach(self.schedule_update_ha_state) self._area.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmPointEntity(BoschAlarmEntity):
"""A base entity for point related entities within a bosch alarm panel."""
def __init__(self, panel: Panel, point_id: int, unique_id: str) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._point_id = point_id
self._point_unique_id = f"{unique_id}_point_{point_id}"
self._point = panel.points[point_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._point_unique_id)},
name=self._point.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()
self._point.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()
self._point.status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmDoorEntity(BoschAlarmEntity): class BoschAlarmDoorEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel.""" """A base entity for area related entities within a bosch alarm panel."""

View File

@ -24,6 +24,44 @@
"on": "mdi:lock-open" "on": "mdi:lock-open"
} }
} }
},
"binary_sensor": {
"panel_fault_parameter_crc_fail_in_pif": {
"default": "mdi:alert-circle"
},
"panel_fault_phone_line_failure": {
"default": "mdi:alert-circle"
},
"panel_fault_sdi_fail_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_user_code_tamper_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_point_bus_fail_since_rps_hang_up": {
"default": "mdi:alert-circle"
},
"panel_fault_log_overflow": {
"default": "mdi:alert-circle"
},
"panel_fault_log_threshold": {
"default": "mdi:alert-circle"
},
"area_ready_to_arm_away": {
"default": "mdi:shield",
"state": {
"on": "mdi:shield-lock"
}
},
"area_ready_to_arm_home": {
"default": "mdi:shield",
"state": {
"on": "mdi:shield-home"
}
}
} }
} }
} }

View File

@ -60,6 +60,52 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"panel_fault_battery_mising": {
"name": "Battery missing"
},
"panel_fault_ac_fail": {
"name": "AC Failure"
},
"panel_fault_parameter_crc_fail_in_pif": {
"name": "CRC failure in panel configuration"
},
"panel_fault_phone_line_failure": {
"name": "Phone line failure"
},
"panel_fault_sdi_fail_since_rps_hang_up": {
"name": "SDI failure since RPS hang up"
},
"panel_fault_user_code_tamper_since_rps_hang_up": {
"name": "User code tamper since RPS hang up"
},
"panel_fault_fail_to_call_rps_since_rps_hang_up": {
"name": "Failure to call RPS since RPS hang up"
},
"panel_fault_point_bus_fail_since_rps_hang_up": {
"name": "Point bus failure since RPS hang up"
},
"panel_fault_log_overflow": {
"name": "Log overflow"
},
"panel_fault_log_threshold": {
"name": "Log threshold reached"
},
"area_ready_to_arm_away": {
"name": "Area ready to arm away",
"state": {
"on": "Ready",
"off": "Not ready"
}
},
"area_ready_to_arm_home": {
"name": "Area ready to arm home",
"state": {
"on": "Ready",
"off": "Not ready"
}
}
},
"switch": { "switch": {
"secured": { "secured": {
"name": "Secured" "name": "Secured"

View File

@ -171,6 +171,7 @@ def mock_panel(
client.model = model_name client.model = model_name
client.faults = [] client.faults = []
client.events = [] client.events = []
client.panel_faults_ids = []
client.firmware_version = "1.0.0" client.firmware_version = "1.0.0"
client.protocol_version = "1.0.0" client.protocol_version = "1.0.0"
client.serial_number = serial_number client.serial_number = serial_number

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
"""Tests for Bosch Alarm component."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch
from bosch_alarm_mode2.const import ALARM_PANEL_FAULTS
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON, 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.BINARY_SENSOR]
):
yield
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_panel: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the binary sensor state."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize("model", ["b5512"])
async def test_panel_faults(
hass: HomeAssistant,
mock_panel: AsyncMock,
area: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that fault sensor state changes after inducing a fault."""
await setup_integration(hass, mock_config_entry)
entity_id = "binary_sensor.bosch_b5512_us1b_battery"
assert hass.states.get(entity_id).state == STATE_OFF
mock_panel.panel_faults_ids = [ALARM_PANEL_FAULTS.BATTERY_LOW]
await call_observable(hass, mock_panel.faults_observer)
assert hass.states.get(entity_id).state == STATE_ON
@pytest.mark.parametrize("model", ["b5512"])
async def test_area_ready_to_arm(
hass: HomeAssistant,
mock_panel: AsyncMock,
area: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that fault sensor state changes after inducing a fault."""
await setup_integration(hass, mock_config_entry)
entity_id = "binary_sensor.area1_area_ready_to_arm_away"
entity_id_2 = "binary_sensor.area1_area_ready_to_arm_home"
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(entity_id_2).state == STATE_ON
area.all_ready = False
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id_2).state == STATE_ON
area.part_ready = False
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(entity_id_2).state == STATE_OFF