add date and time service to bosch_alarm (#142243)

* add date and time service

* update quality scale

* add changes from review

* fix issues after merge

* fix icons

* apply changes from review

* remove list from service schema

* update quality scale

* update strings

* Update homeassistant/components/bosch_alarm/services.py

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

* apply changes from review

* apply changes from review

* Update tests/components/bosch_alarm/test_services.py

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

* validate exception messages

* use schema to validate service call

* update docstring

* update error message

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Sanjay Govind 2025-05-20 21:09:46 +12:00 committed by GitHub
parent 7f9b454922
commit c3fe5f012e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 339 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,9 @@
{
"services": {
"set_date_time": {
"service": "mdi:clock-edit"
}
},
"entity": {
"sensor": {
"alarms_gas": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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