diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index 410adbd8d51..7f37476f1bb 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -6,14 +6,18 @@ from ssl import SSLError from bosch_alarm_mode2 import Panel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.typing import ConfigType from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN +from .services import setup_services +from .types import BoschAlarmConfigEntry + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [ Platform.ALARM_CONTROL_PANEL, @@ -22,7 +26,11 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type BoschAlarmConfigEntry = ConfigEntry[Panel] + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up bosch alarm services.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index 7115bae415a..60365070587 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -12,8 +12,8 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import BoschAlarmConfigEntry from .entity import BoschAlarmAreaEntity +from .types import BoschAlarmConfigEntry async def async_setup_entry( diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 7205831391c..33ec0ae526a 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -1,6 +1,9 @@ """Constants for the Bosch Alarm integration.""" DOMAIN = "bosch_alarm" -HISTORY_ATTR = "history" +ATTR_HISTORY = "history" CONF_INSTALLER_CODE = "installer_code" CONF_USER_CODE = "user_code" +ATTR_DATETIME = "datetime" +SERVICE_SET_DATE_TIME = "set_date_time" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py index 2e93052ea95..ea9988960b5 100644 --- a/homeassistant/components/bosch_alarm/diagnostics.py +++ b/homeassistant/components/bosch_alarm/diagnostics.py @@ -6,8 +6,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import BoschAlarmConfigEntry from .const import CONF_INSTALLER_CODE, CONF_USER_CODE +from .types import BoschAlarmConfigEntry TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD] diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json index b13822fa711..c396350e37e 100644 --- a/homeassistant/components/bosch_alarm/icons.json +++ b/homeassistant/components/bosch_alarm/icons.json @@ -1,4 +1,9 @@ { + "services": { + "set_date_time": { + "service": "mdi:clock-edit" + } + }, "entity": { "sensor": { "alarms_gas": { diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 5bbd1df0ebb..474dc348fd8 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -13,10 +13,7 @@ rules: config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - No custom actions are defined. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done @@ -29,10 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - No custom actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py new file mode 100644 index 00000000000..d9d6a1339a2 --- /dev/null +++ b/homeassistant/components/bosch_alarm/services.py @@ -0,0 +1,76 @@ +"""Services for the bosch_alarm integration.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv +from homeassistant.util import dt as dt_util + +from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .types import BoschAlarmConfigEntry + + +def validate_datetime(value: Any) -> dt.datetime: + """Validate that a provided datetime is supported on a bosch alarm panel.""" + date_val = cv.datetime(value) + if date_val.year < 2010: + raise vol.RangeInvalid("datetime must be after 2009") + + if date_val.year > 2037: + raise vol.RangeInvalid("datetime must be before 2038") + + return date_val + + +SET_DATE_TIME_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_DATETIME): validate_datetime, + } +) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the bosch alarm integration.""" + + async def async_set_panel_date(call: ServiceCall) -> None: + """Set the date and time on a bosch alarm panel.""" + config_entry: BoschAlarmConfigEntry | None + value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now()) + entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + if not (config_entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": entry_id}, + ) + if config_entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + panel = config_entry.runtime_data + try: + await panel.set_panel_date(value) + except asyncio.InvalidStateError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"target": config_entry.title}, + ) from err + + hass.services.async_register( + DOMAIN, + SERVICE_SET_DATE_TIME, + async_set_panel_date, + schema=SET_DATE_TIME_SCHEMA, + ) diff --git a/homeassistant/components/bosch_alarm/services.yaml b/homeassistant/components/bosch_alarm/services.yaml new file mode 100644 index 00000000000..a3e8d800005 --- /dev/null +++ b/homeassistant/components/bosch_alarm/services.yaml @@ -0,0 +1,12 @@ +set_date_time: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: bosch_alarm + datetime: + required: false + example: "2025-05-10 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 7a9d291a67f..40e0c315437 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -51,6 +51,18 @@ } }, "exceptions": { + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "connection_error": { + "message": "Could not connect to \"{target}\"." + }, + "unknown_error": { + "message": "An unknown error occurred while setting the date and time on \"{target}\"." + }, "cannot_connect": { "message": "Could not connect to panel." }, @@ -61,6 +73,22 @@ "message": "Door cannot be manipulated while it is momentarily unlocked." } }, + "services": { + "set_date_time": { + "name": "Set date & time", + "description": "Sets the date and time on the alarm panel.", + "fields": { + "datetime": { + "name": "Date & time", + "description": "The date and time to set. The time zone of the Home Assistant instance is assumed. If omitted, the current date and time is used." + }, + "config_entry_id": { + "name": "Config entry", + "description": "The Bosch Alarm integration ID." + } + } + } + }, "entity": { "binary_sensor": { "panel_fault_battery_mising": { diff --git a/homeassistant/components/bosch_alarm/types.py b/homeassistant/components/bosch_alarm/types.py new file mode 100644 index 00000000000..7d45094b208 --- /dev/null +++ b/homeassistant/components/bosch_alarm/types.py @@ -0,0 +1,7 @@ +"""Types for the Bosch Alarm integration.""" + +from bosch_alarm_mode2 import Panel + +from homeassistant.config_entries import ConfigEntry + +type BoschAlarmConfigEntry = ConfigEntry[Panel] diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py new file mode 100644 index 00000000000..7b5088f32c3 --- /dev/null +++ b/tests/components/bosch_alarm/test_services.py @@ -0,0 +1,192 @@ +"""Tests for Bosch Alarm component.""" + +import asyncio +from collections.abc import AsyncGenerator +import datetime as dt +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol + +from homeassistant.components.bosch_alarm.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DATETIME, + DOMAIN, + SERVICE_SET_DATE_TIME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@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", []): + yield + + +async def test_set_date_time_service( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls succeed if the service call is valid.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + mock_panel.set_panel_date.assert_called_once() + + +async def test_set_date_time_service_fails_bad_entity( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the service call is done for an incorrect entity.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + ServiceValidationError, + match='Integration "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: "bad-config_id", + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_params( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the service call is done with incorrect params.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + vol.MultipleInvalid, + match=r"Invalid datetime specified: for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: "", + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_year_before( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + vol.MultipleInvalid, + match=r"datetime must be before 2038 for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt.datetime(2038, 1, 1), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_bad_year_after( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + mock_panel.set_panel_date.side_effect = ValueError() + with pytest.raises( + vol.MultipleInvalid, + match=r"datetime must be after 2009 for dictionary value @ data\['datetime'\]", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt.datetime(2009, 1, 1), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_connection_error( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the panel fails the service call.""" + await setup_integration(hass, mock_config_entry) + mock_panel.set_panel_date.side_effect = asyncio.InvalidStateError() + with pytest.raises( + HomeAssistantError, + match=f'Could not connect to "{mock_config_entry.title}"', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + ) + + +async def test_set_date_time_service_fails_unloaded( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the service calls fail if the config entry is unloaded.""" + await async_setup_component(hass, DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + with pytest.raises( + HomeAssistantError, + match=f"{mock_config_entry.title} is not loaded", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DATE_TIME, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATETIME: dt_util.now(), + }, + blocking=True, + )