Add bosch_alarm integration (#138497)

* Add bosch_alarm integration

* Remove other platforms for now

* update some strings not being consistant

* fix sentence-casing for strings

* remove options flow and versioning

* clean up config flow

* Add OSI license + tagged releases + ci to bosch-alarm-mode2

* Apply suggestions from code review

Co-authored-by: Josef Zweck <josef@zweck.dev>

* apply changes from review

* apply changes from review

* remove options flow

* work on fixtures

* work on fixtures

* fix errors and complete flow

* use fixtures for alarm config

* Update homeassistant/components/bosch_alarm/manifest.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* fix missing type

* mock setup entry

* remove use of patch in config flow test

* Use coordinator for managing panel data

* Use coordinator for managing panel data

* Coordinator cleanup

* remove unnecessary observers

* update listeners when error state changes

* Update homeassistant/components/bosch_alarm/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/config_flow.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* rename config flow

* Update homeassistant/components/bosch_alarm/quality_scale.yaml

Co-authored-by: Josef Zweck <josef@zweck.dev>

* add missing types

* fix quality_scale.yaml

* enable strict typing

* enable strict typing

* Add test for alarm control panel

* add more tests

* add more tests

* Update homeassistant/components/bosch_alarm/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/alarm_control_panel.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/alarm_control_panel.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/bosch_alarm/alarm_control_panel.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Add snapshot test

* add snapshot test

* add snapshot test

* update quality scale

* update quality scale

* update quality scale

* update quality scale

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* apply changes from code review

* apply changes from code review

* apply changes from code review

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* apply changes from code review

* apply changes from code review

* Fix alarm control panel device name

* Fix

* Fix

* Fix

* Fix

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Sanjay Govind 2025-03-27 01:56:44 +13:00 committed by GitHub
parent f842640249
commit dba4c197c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1196 additions and 0 deletions

View File

@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
homeassistant.components.bosch_alarm.*
homeassistant.components.braviatv.*
homeassistant.components.bring.*
homeassistant.components.brother.*

2
CODEOWNERS generated
View File

@ -216,6 +216,8 @@ build.json @home-assistant/supervisor
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/braviatv/ @bieniu @Drafteed

View File

@ -0,0 +1,62 @@
"""The Bosch Alarm integration."""
from __future__ import annotations
from ssl import SSLError
from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
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]
type BoschAlarmConfigEntry = ConfigEntry[Panel]
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
"""Set up Bosch Alarm from a config entry."""
panel = Panel(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
automation_code=entry.data.get(CONF_PASSWORD),
installer_or_user_code=entry.data.get(
CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE)
),
)
try:
await panel.connect()
except (PermissionError, ValueError) as err:
await panel.disconnect()
raise ConfigEntryNotReady from err
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
await panel.disconnect()
raise ConfigEntryNotReady("Connection failed") from err
entry.runtime_data = panel
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
model=panel.model,
sw_version=panel.firmware_version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.disconnect()
return unload_ok

View File

@ -0,0 +1,109 @@
"""Support for Bosch Alarm Panel."""
from __future__ import annotations
from bosch_alarm_mode2 import Panel
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
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
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up control panels for each area."""
panel = config_entry.runtime_data
async_add_entities(
AreaAlarmControlPanel(
panel,
area_id,
config_entry.unique_id or config_entry.entry_id,
)
for area_id in panel.areas
)
class AreaAlarmControlPanel(AlarmControlPanelEntity):
"""An alarm control panel entity for a bosch alarm panel."""
_attr_has_entity_name = True
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
_attr_name = None
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,
),
)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the alarm."""
if self._area.is_triggered():
return AlarmControlPanelState.TRIGGERED
if self._area.is_disarmed():
return AlarmControlPanelState.DISARMED
if self._area.is_arming():
return AlarmControlPanelState.ARMING
if self._area.is_pending():
return AlarmControlPanelState.PENDING
if self._area.is_part_armed():
return AlarmControlPanelState.ARMED_HOME
if self._area.is_all_armed():
return AlarmControlPanelState.ARMED_AWAY
return None
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Disarm this panel."""
await self.panel.area_disarm(self._area_id)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.panel.area_arm_part(self._area_id)
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,165 @@
"""Config flow for Bosch Alarm integration."""
from __future__ import annotations
import asyncio
import logging
import ssl
from typing import Any
from bosch_alarm_mode2 import Panel
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_CODE,
CONF_HOST,
CONF_MODEL,
CONF_PASSWORD,
CONF_PORT,
)
import homeassistant.helpers.config_validation as cv
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=7700): cv.positive_int,
}
)
STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema(
{
vol.Required(CONF_USER_CODE): str,
}
)
STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema(
{
vol.Required(CONF_INSTALLER_CODE): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_AUTH_DATA_SCHEMA_BG = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str})
async def try_connect(
data: dict[str, Any], load_selector: int = 0
) -> tuple[str, int | None]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
panel = Panel(
host=data[CONF_HOST],
port=data[CONF_PORT],
automation_code=data.get(CONF_PASSWORD),
installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)),
)
try:
await panel.connect(load_selector)
finally:
await panel.disconnect()
return (panel.model, panel.serial_number)
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bosch Alarm."""
def __init__(self) -> None:
"""Init config flow."""
self._data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
# Use load_selector = 0 to fetch the panel model without authentication.
(model, serial) = await try_connect(user_input, 0)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
asyncio.exceptions.TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._data = user_input
self._data[CONF_MODEL] = model
return await self.async_step_auth()
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the auth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
self._data.update(user_input)
try:
(model, serial_number) = await try_connect(
self._data, Panel.LOAD_EXTENDED_INFO
)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if serial_number:
await self.async_set_unique_id(str(serial_number))
self._abort_if_unique_id_configured()
else:
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)

View File

@ -0,0 +1,6 @@
"""Constants for the Bosch Alarm integration."""
DOMAIN = "bosch_alarm"
HISTORY_ATTR = "history"
CONF_INSTALLER_CODE = "installer_code"
CONF_USER_CODE = "user_code"

View File

@ -0,0 +1,11 @@
{
"domain": "bosch_alarm",
"name": "Bosch Alarm",
"codeowners": ["@mag1024", "@sanjay900"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["bosch-alarm-mode2==0.4.3"]
}

View File

@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions defined
appropriate-polling:
status: exempt
comment: |
No polling
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Device type integration
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No repairs
stale-devices:
status: exempt
comment: |
Device type integration
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
Integration does not make any HTTP requests.
strict-typing: done

View File

@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Bosch alarm panel",
"port": "The port used to connect to your Bosch alarm panel. This is usually 7700"
}
},
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "Installer code",
"user_code": "User code"
},
"data_description": {
"password": "The Mode 2 automation code from your panel",
"installer_code": "The installer code from your panel",
"user_code": "The user code from your panel"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -91,6 +91,7 @@ FLOWS = {
"bluetooth",
"bmw_connected_drive",
"bond",
"bosch_alarm",
"bosch_shc",
"braviatv",
"bring",

View File

@ -759,6 +759,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"bosch_alarm": {
"name": "Bosch Alarm",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"bosch_shc": {
"name": "Bosch SHC",
"integration_type": "hub",

10
mypy.ini generated
View File

@ -945,6 +945,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.bosch_alarm.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.braviatv.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@ -644,6 +644,9 @@ bluetooth-data-tools==1.26.1
# homeassistant.components.bond
bond-async==0.2.1
# homeassistant.components.bosch_alarm
bosch-alarm-mode2==0.4.3
# homeassistant.components.bosch_shc
boschshcpy==0.2.91

View File

@ -569,6 +569,9 @@ bluetooth-data-tools==1.26.1
# homeassistant.components.bond
bond-async==0.2.1
# homeassistant.components.bosch_alarm
bosch-alarm-mode2==0.4.3
# homeassistant.components.bosch_shc
boschshcpy==0.2.91

View File

@ -0,0 +1,22 @@
"""Tests for the Bosch Alarm component."""
from unittest.mock import AsyncMock
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
async def call_observable(hass: HomeAssistant, observable: AsyncMock) -> None:
"""Call the observable with the given event."""
for callback in observable.attach.call_args_list:
callback[0][0]()
await hass.async_block_till_done()

View File

@ -0,0 +1,131 @@
"""Define fixtures for Bosch Alarm tests."""
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
from bosch_alarm_mode2.panel import Area
from bosch_alarm_mode2.utils import Observable
import pytest
from homeassistant.components.bosch_alarm.const import (
CONF_INSTALLER_CODE,
CONF_USER_CODE,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PASSWORD, CONF_PORT
from tests.common import MockConfigEntry
@pytest.fixture(
params=[
"solution_3000",
"amax_3000",
"b5512",
]
)
def model(request: pytest.FixtureRequest) -> Generator[str]:
"""Return every device."""
return request.param
@pytest.fixture
def extra_config_entry_data(
model: str, model_name: str, config_flow_data: dict[str, Any]
) -> dict[str, Any]:
"""Return extra config entry data."""
return {CONF_MODEL: model_name} | config_flow_data
@pytest.fixture
def config_flow_data(model: str) -> dict[str, Any]:
"""Return extra config entry data."""
if model == "solution_3000":
return {CONF_USER_CODE: "1234"}
if model == "amax_3000":
return {CONF_INSTALLER_CODE: "1234", CONF_PASSWORD: "1234567890"}
if model == "b5512":
return {CONF_PASSWORD: "1234567890"}
pytest.fail("Invalid model")
@pytest.fixture
def model_name(model: str) -> str | None:
"""Return extra config entry data."""
return {
"solution_3000": "Solution 3000",
"amax_3000": "AMAX 3000",
"b5512": "B5512 (US1B)",
}.get(model)
@pytest.fixture
def serial_number(model: str) -> str | None:
"""Return extra config entry data."""
if model == "solution_3000":
return "1234567890"
return None
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.bosch_alarm.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def area() -> Generator[Area]:
"""Define a mocked area."""
mock = AsyncMock(spec=Area)
mock.name = "Area1"
mock.status_observer = AsyncMock(spec=Observable)
mock.is_triggered.return_value = False
mock.is_disarmed.return_value = True
mock.is_arming.return_value = False
mock.is_pending.return_value = False
mock.is_part_armed.return_value = False
mock.is_all_armed.return_value = False
return mock
@pytest.fixture
def mock_panel(
area: AsyncMock, model_name: str, serial_number: str | None
) -> Generator[AsyncMock]:
"""Define a fixture to set up Bosch Alarm."""
with (
patch(
"homeassistant.components.bosch_alarm.Panel", autospec=True
) as mock_panel,
patch("homeassistant.components.bosch_alarm.config_flow.Panel", new=mock_panel),
):
client = mock_panel.return_value
client.areas = {1: area}
client.model = model_name
client.firmware_version = "1.0.0"
client.serial_number = serial_number
client.connection_status_observer = AsyncMock(spec=Observable)
yield client
@pytest.fixture
def mock_config_entry(
extra_config_entry_data: dict[str, Any], serial_number: str | None
) -> MockConfigEntry:
"""Mock config entry for bosch alarm."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=serial_number,
entry_id="01JQ917ACKQ33HHM7YCFXYZX51",
data={
CONF_HOST: "0.0.0.0",
CONF_PORT: 7700,
CONF_MODEL: "bosch_alarm_test_data.model",
}
| extra_config_entry_data,
)

View File

@ -0,0 +1,154 @@
# serializer version: 1
# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-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': 'alarm_control_panel',
'entity_category': None,
'entity_id': 'alarm_control_panel.area1',
'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': None,
'platform': 'bosch_alarm',
'previous_unique_id': None,
'supported_features': <AlarmControlPanelEntityFeature: 3>,
'translation_key': None,
'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1',
'unit_of_measurement': None,
})
# ---
# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'changed_by': None,
'code_arm_required': False,
'code_format': None,
'friendly_name': 'Area1',
'supported_features': <AlarmControlPanelEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'alarm_control_panel.area1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'disarmed',
})
# ---
# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-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': 'alarm_control_panel',
'entity_category': None,
'entity_id': 'alarm_control_panel.area1',
'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': None,
'platform': 'bosch_alarm',
'previous_unique_id': None,
'supported_features': <AlarmControlPanelEntityFeature: 3>,
'translation_key': None,
'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1',
'unit_of_measurement': None,
})
# ---
# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'changed_by': None,
'code_arm_required': False,
'code_format': None,
'friendly_name': 'Area1',
'supported_features': <AlarmControlPanelEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'alarm_control_panel.area1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'disarmed',
})
# ---
# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-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': 'alarm_control_panel',
'entity_category': None,
'entity_id': 'alarm_control_panel.area1',
'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': None,
'platform': 'bosch_alarm',
'previous_unique_id': None,
'supported_features': <AlarmControlPanelEntityFeature: 3>,
'translation_key': None,
'unique_id': '1234567890_area_1',
'unit_of_measurement': None,
})
# ---
# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'changed_by': None,
'code_arm_required': False,
'code_format': None,
'friendly_name': 'Area1',
'supported_features': <AlarmControlPanelEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'alarm_control_panel.area1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'disarmed',
})
# ---

View File

@ -0,0 +1,145 @@
"""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.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
AlarmControlPanelState,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_DISARM,
STATE_UNAVAILABLE,
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.ALARM_CONTROL_PANEL]
):
yield
async def test_update_alarm_device(
hass: HomeAssistant,
mock_panel: AsyncMock,
area: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that alarm panel state changes after arming the panel."""
await setup_integration(hass, mock_config_entry)
entity_id = "alarm_control_panel.area1"
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
area.is_arming.return_value = True
area.is_disarmed.return_value = False
await hass.services.async_call(
ALARM_CONTROL_PANEL_DOMAIN,
SERVICE_ALARM_ARM_AWAY,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING
area.is_arming.return_value = False
area.is_all_armed.return_value = True
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY
await hass.services.async_call(
ALARM_CONTROL_PANEL_DOMAIN,
SERVICE_ALARM_DISARM,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
area.is_all_armed.return_value = False
area.is_disarmed.return_value = True
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
await hass.services.async_call(
ALARM_CONTROL_PANEL_DOMAIN,
SERVICE_ALARM_ARM_HOME,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
area.is_disarmed.return_value = False
area.is_arming.return_value = True
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING
area.is_arming.return_value = False
area.is_part_armed.return_value = True
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME
await hass.services.async_call(
ALARM_CONTROL_PANEL_DOMAIN,
SERVICE_ALARM_DISARM,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
area.is_part_armed.return_value = False
area.is_disarmed.return_value = True
await call_observable(hass, area.status_observer)
assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED
async def test_alarm_control_panel(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_panel: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the alarm_control_panel state."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_alarm_control_panel_availability(
hass: HomeAssistant,
mock_panel: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the alarm_control_panel availability."""
await setup_integration(hass, mock_config_entry)
assert (
hass.states.get("alarm_control_panel.area1").state
== AlarmControlPanelState.DISARMED
)
mock_panel.connection_status.return_value = False
await call_observable(hass, mock_panel.connection_status_observer)
assert hass.states.get("alarm_control_panel.area1").state == STATE_UNAVAILABLE

View File

@ -0,0 +1,212 @@
"""Tests for the bosch_alarm config flow."""
import asyncio
from typing import Any
from unittest.mock import AsyncMock
import pytest
from homeassistant import config_entries
from homeassistant.components.bosch_alarm.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_form_user(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_panel: AsyncMock,
model_name: str,
serial_number: str,
config_flow_data: dict[str, Any],
) -> None:
"""Test the config flow for bosch_alarm."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 7700},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
config_flow_data,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Bosch {model_name}"
assert (
result["data"]
== {
CONF_HOST: "1.1.1.1",
CONF_PORT: 7700,
CONF_MODEL: model_name,
}
| config_flow_data
)
assert result["result"].unique_id == serial_number
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "message"),
[
(asyncio.TimeoutError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_exceptions(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_panel: AsyncMock,
config_flow_data: dict[str, Any],
exception: Exception,
message: str,
) -> None:
"""Test we handle exceptions correctly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
mock_panel.connect.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 7700},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": message}
mock_panel.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 7700},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
config_flow_data,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("exception", "message"),
[
(PermissionError, "invalid_auth"),
(asyncio.TimeoutError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_exceptions_user(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_panel: AsyncMock,
config_flow_data: dict[str, Any],
exception: Exception,
message: str,
) -> None:
"""Test we handle exceptions correctly."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "1.1.1.1", CONF_PORT: 7700},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {}
mock_panel.connect.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"], config_flow_data
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {"base": message}
mock_panel.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], config_flow_data
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"])
async def test_entry_already_configured_host(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_panel: AsyncMock,
config_flow_data: dict[str, Any],
) -> None:
"""Test if configuring an entity twice results in an error."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "0.0.0.0"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], config_flow_data
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize("model", ["b5512"])
async def test_entry_already_configured_serial(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_panel: AsyncMock,
config_flow_data: dict[str, Any],
) -> None:
"""Test if configuring an entity twice results in an error."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "0.0.0.0"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], config_flow_data
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -0,0 +1,33 @@
"""Tests for bosch alarm integration init."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def disable_platform_only():
"""Disable platforms to speed up tests."""
with patch("homeassistant.components.bosch_alarm.PLATFORMS", []):
yield
@pytest.mark.parametrize("model", ["solution_3000"])
@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()])
async def test_incorrect_auth(
hass: HomeAssistant,
mock_panel: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test errors with incorrect auth."""
mock_panel.connect.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY