diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 56c7a509cca079..d65bd5d5abf982 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -2,10 +2,7 @@ from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass, field import logging -from typing import Any from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError from pyrisco.common import Partition, System, Zone @@ -22,8 +19,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CONCURRENCY, @@ -35,6 +34,10 @@ TYPE_LOCAL, ) from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator +from .models import LocalData +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -45,14 +48,6 @@ _LOGGER = logging.getLogger(__name__) -@dataclass -class LocalData: - """A data class for local data passed to the platforms.""" - - system: RiscoLocal - partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) - - def is_local(entry: ConfigEntry) -> bool: """Return whether the entry represents an instance with local communication.""" return entry.data.get(CONF_TYPE) == TYPE_LOCAL @@ -176,3 +171,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Risco integration services.""" + + await async_setup_services(hass) + + return True diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index ef3280fe232356..88fae4de7c26e9 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -55,3 +55,5 @@ CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_CONCURRENCY: DEFAULT_CONCURRENCY, } + +SERVICE_SET_TIME = "set_time" diff --git a/homeassistant/components/risco/icons.json b/homeassistant/components/risco/icons.json new file mode 100644 index 00000000000000..97abbcca6f767a --- /dev/null +++ b/homeassistant/components/risco/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_time": { + "service": "mdi:clock-edit" + } + } +} diff --git a/homeassistant/components/risco/models.py b/homeassistant/components/risco/models.py new file mode 100644 index 00000000000000..07777839e884ba --- /dev/null +++ b/homeassistant/components/risco/models.py @@ -0,0 +1,15 @@ +"""Models for Risco integration.""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from pyrisco import RiscoLocal + + +@dataclass +class LocalData: + """A data class for local data passed to the platforms.""" + + system: RiscoLocal + partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict) diff --git a/homeassistant/components/risco/services.py b/homeassistant/components/risco/services.py new file mode 100644 index 00000000000000..4c2e632b2ecf7a --- /dev/null +++ b/homeassistant/components/risco/services.py @@ -0,0 +1,63 @@ +"""Services for Risco integration.""" + +from datetime import datetime + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME, CONF_TYPE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, SERVICE_SET_TIME, TYPE_LOCAL +from .models import LocalData + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Create the Risco Services/Actions.""" + + async def _set_time(service_call: ServiceCall) -> None: + config_entry_id = service_call.data[ATTR_CONFIG_ENTRY_ID] + time = service_call.data.get(ATTR_TIME) + + # Validate config entry exists + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + ) + + # Validate config entry is loaded + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_loaded", + ) + + # Validate config entry is local (not cloud) + if entry.data.get(CONF_TYPE) != TYPE_LOCAL: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_local_entry", + ) + + time_to_send = time + if time is None: + time_to_send = datetime.now() + + local_data: LocalData = hass.data[DOMAIN][config_entry_id] + + await local_data.system.set_time(time_to_send) + + hass.services.async_register( + domain=DOMAIN, + service=SERVICE_SET_TIME, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TIME): cv.datetime, + } + ), + service_func=_set_time, + ) diff --git a/homeassistant/components/risco/services.yaml b/homeassistant/components/risco/services.yaml new file mode 100644 index 00000000000000..88a7b4da27a53d --- /dev/null +++ b/homeassistant/components/risco/services.yaml @@ -0,0 +1,11 @@ +set_time: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: risco + time: + required: false + selector: + datetime: diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index 60367b9d0e6937..79c1e7b7b4b6d1 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -71,6 +71,17 @@ } } }, + "exceptions": { + "config_entry_not_found": { + "message": "Config entry not found. Please check that the config entry ID is correct." + }, + "config_entry_not_loaded": { + "message": "Config entry is not loaded. Please ensure the Risco integration is set up correctly." + }, + "not_local_entry": { + "message": "This service only works with local Risco connections." + } + }, "options": { "step": { "ha_to_risco": { @@ -105,5 +116,21 @@ "title": "Map Risco states to Home Assistant states" } } + }, + "services": { + "set_time": { + "description": "Sets the time of an alarm panel.", + "fields": { + "config_entry_id": { + "description": "The Risco alarm panel to set the time for.", + "name": "Config entry" + }, + "time": { + "description": "The time to send to the alarm panel. Leave it empty to use the Home Assistant system time.", + "name": "Time" + } + }, + "name": "Set the alarm panel time" + } } } diff --git a/tests/components/risco/test_services.py b/tests/components/risco/test_services.py new file mode 100644 index 00000000000000..6bbd78e577487c --- /dev/null +++ b/tests/components/risco/test_services.py @@ -0,0 +1,110 @@ +"""Tests for the Risco services.""" + +from datetime import datetime +from unittest.mock import patch + +import pytest + +from homeassistant.components.risco import DOMAIN +from homeassistant.components.risco.const import SERVICE_SET_TIME +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .conftest import TEST_CLOUD_CONFIG + +from tests.common import MockConfigEntry + + +async def test_set_time_service( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service.""" + with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock: + time_str = "2025-02-21T12:00:00" + time = datetime.fromisoformat(time_str) + data = { + ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id, + ATTR_TIME: time_str, + } + + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + mock.assert_called_once_with(time) + + +@pytest.mark.freeze_time("2025-02-21T12:00:00Z") +async def test_set_time_service_with_no_time( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service when no time is provided.""" + with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock_set_time: + data = { + "config_entry_id": local_config_entry.entry_id, + } + + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + mock_set_time.assert_called_once_with(datetime.now()) + + +async def test_set_time_service_with_invalid_entry( + hass: HomeAssistant, setup_risco_local +) -> None: + """Test the set_time service with an invalid config entry.""" + data = { + ATTR_CONFIG_ENTRY_ID: "invalid_entry_id", + } + + with pytest.raises(ServiceValidationError, match="Config entry not found"): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + +async def test_set_time_service_with_not_loaded_entry( + hass: HomeAssistant, setup_risco_local, local_config_entry +) -> None: + """Test the set_time service with a config entry that is not loaded.""" + await hass.config_entries.async_unload(local_config_entry.entry_id) + await hass.async_block_till_done() + + assert local_config_entry.state is ConfigEntryState.NOT_LOADED + + data = { + ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id, + } + + with pytest.raises(ServiceValidationError, match="is not loaded"): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + ) + + +async def test_set_time_service_with_cloud_entry( + hass: HomeAssistant, setup_risco_local +) -> None: + """Test the set_time service with a cloud config entry.""" + cloud_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-cloud", + data=TEST_CLOUD_CONFIG, + ) + cloud_entry.add_to_hass(hass) + cloud_entry.mock_state(hass, ConfigEntryState.LOADED) + + data = { + ATTR_CONFIG_ENTRY_ID: cloud_entry.entry_id, + } + + with pytest.raises( + ServiceValidationError, match="This service only works with local" + ): + await hass.services.async_call( + DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True + )