Skip to content

Commit d780188

Browse files
authored
Add Risco set_time service (home-assistant#139015)
1 parent a4bbdaf commit d780188

File tree

8 files changed

+249
-11
lines changed

8 files changed

+249
-11
lines changed

homeassistant/components/risco/__init__.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Callable
6-
from dataclasses import dataclass, field
75
import logging
8-
from typing import Any
96

107
from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError
118
from pyrisco.common import Partition, System, Zone
@@ -22,8 +19,10 @@
2219
)
2320
from homeassistant.core import HomeAssistant
2421
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
22+
from homeassistant.helpers import config_validation as cv
2523
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2624
from homeassistant.helpers.dispatcher import async_dispatcher_send
25+
from homeassistant.helpers.typing import ConfigType
2726

2827
from .const import (
2928
CONF_CONCURRENCY,
@@ -35,6 +34,10 @@
3534
TYPE_LOCAL,
3635
)
3736
from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator
37+
from .models import LocalData
38+
from .services import async_setup_services
39+
40+
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
3841

3942
PLATFORMS = [
4043
Platform.ALARM_CONTROL_PANEL,
@@ -45,14 +48,6 @@
4548
_LOGGER = logging.getLogger(__name__)
4649

4750

48-
@dataclass
49-
class LocalData:
50-
"""A data class for local data passed to the platforms."""
51-
52-
system: RiscoLocal
53-
partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict)
54-
55-
5651
def is_local(entry: ConfigEntry) -> bool:
5752
"""Return whether the entry represents an instance with local communication."""
5853
return entry.data.get(CONF_TYPE) == TYPE_LOCAL
@@ -176,3 +171,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
176171
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
177172
"""Handle options update."""
178173
await hass.config_entries.async_reload(entry.entry_id)
174+
175+
176+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
177+
"""Set up the Risco integration services."""
178+
179+
await async_setup_services(hass)
180+
181+
return True

homeassistant/components/risco/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,5 @@
5555
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
5656
CONF_CONCURRENCY: DEFAULT_CONCURRENCY,
5757
}
58+
59+
SERVICE_SET_TIME = "set_time"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"services": {
3+
"set_time": {
4+
"service": "mdi:clock-edit"
5+
}
6+
}
7+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Models for Risco integration."""
2+
3+
from collections.abc import Callable
4+
from dataclasses import dataclass, field
5+
from typing import Any
6+
7+
from pyrisco import RiscoLocal
8+
9+
10+
@dataclass
11+
class LocalData:
12+
"""A data class for local data passed to the platforms."""
13+
14+
system: RiscoLocal
15+
partition_updates: dict[int, Callable[[], Any]] = field(default_factory=dict)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Services for Risco integration."""
2+
3+
from datetime import datetime
4+
5+
import voluptuous as vol
6+
7+
from homeassistant.config_entries import ConfigEntryState
8+
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME, CONF_TYPE
9+
from homeassistant.core import HomeAssistant, ServiceCall
10+
from homeassistant.exceptions import ServiceValidationError
11+
from homeassistant.helpers import config_validation as cv
12+
13+
from .const import DOMAIN, SERVICE_SET_TIME, TYPE_LOCAL
14+
from .models import LocalData
15+
16+
17+
async def async_setup_services(hass: HomeAssistant) -> None:
18+
"""Create the Risco Services/Actions."""
19+
20+
async def _set_time(service_call: ServiceCall) -> None:
21+
config_entry_id = service_call.data[ATTR_CONFIG_ENTRY_ID]
22+
time = service_call.data.get(ATTR_TIME)
23+
24+
# Validate config entry exists
25+
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
26+
raise ServiceValidationError(
27+
translation_domain=DOMAIN,
28+
translation_key="config_entry_not_found",
29+
)
30+
31+
# Validate config entry is loaded
32+
if entry.state is not ConfigEntryState.LOADED:
33+
raise ServiceValidationError(
34+
translation_domain=DOMAIN,
35+
translation_key="config_entry_not_loaded",
36+
)
37+
38+
# Validate config entry is local (not cloud)
39+
if entry.data.get(CONF_TYPE) != TYPE_LOCAL:
40+
raise ServiceValidationError(
41+
translation_domain=DOMAIN,
42+
translation_key="not_local_entry",
43+
)
44+
45+
time_to_send = time
46+
if time is None:
47+
time_to_send = datetime.now()
48+
49+
local_data: LocalData = hass.data[DOMAIN][config_entry_id]
50+
51+
await local_data.system.set_time(time_to_send)
52+
53+
hass.services.async_register(
54+
domain=DOMAIN,
55+
service=SERVICE_SET_TIME,
56+
schema=vol.Schema(
57+
{
58+
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
59+
vol.Optional(ATTR_TIME): cv.datetime,
60+
}
61+
),
62+
service_func=_set_time,
63+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
set_time:
2+
fields:
3+
config_entry_id:
4+
required: true
5+
selector:
6+
config_entry:
7+
integration: risco
8+
time:
9+
required: false
10+
selector:
11+
datetime:

homeassistant/components/risco/strings.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@
7171
}
7272
}
7373
},
74+
"exceptions": {
75+
"config_entry_not_found": {
76+
"message": "Config entry not found. Please check that the config entry ID is correct."
77+
},
78+
"config_entry_not_loaded": {
79+
"message": "Config entry is not loaded. Please ensure the Risco integration is set up correctly."
80+
},
81+
"not_local_entry": {
82+
"message": "This service only works with local Risco connections."
83+
}
84+
},
7485
"options": {
7586
"step": {
7687
"ha_to_risco": {
@@ -105,5 +116,21 @@
105116
"title": "Map Risco states to Home Assistant states"
106117
}
107118
}
119+
},
120+
"services": {
121+
"set_time": {
122+
"description": "Sets the time of an alarm panel.",
123+
"fields": {
124+
"config_entry_id": {
125+
"description": "The Risco alarm panel to set the time for.",
126+
"name": "Config entry"
127+
},
128+
"time": {
129+
"description": "The time to send to the alarm panel. Leave it empty to use the Home Assistant system time.",
130+
"name": "Time"
131+
}
132+
},
133+
"name": "Set the alarm panel time"
134+
}
108135
}
109136
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for the Risco services."""
2+
3+
from datetime import datetime
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from homeassistant.components.risco import DOMAIN
9+
from homeassistant.components.risco.const import SERVICE_SET_TIME
10+
from homeassistant.config_entries import ConfigEntryState
11+
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_TIME
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.exceptions import ServiceValidationError
14+
15+
from .conftest import TEST_CLOUD_CONFIG
16+
17+
from tests.common import MockConfigEntry
18+
19+
20+
async def test_set_time_service(
21+
hass: HomeAssistant, setup_risco_local, local_config_entry
22+
) -> None:
23+
"""Test the set_time service."""
24+
with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock:
25+
time_str = "2025-02-21T12:00:00"
26+
time = datetime.fromisoformat(time_str)
27+
data = {
28+
ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id,
29+
ATTR_TIME: time_str,
30+
}
31+
32+
await hass.services.async_call(
33+
DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True
34+
)
35+
36+
mock.assert_called_once_with(time)
37+
38+
39+
@pytest.mark.freeze_time("2025-02-21T12:00:00Z")
40+
async def test_set_time_service_with_no_time(
41+
hass: HomeAssistant, setup_risco_local, local_config_entry
42+
) -> None:
43+
"""Test the set_time service when no time is provided."""
44+
with patch("homeassistant.components.risco.RiscoLocal.set_time") as mock_set_time:
45+
data = {
46+
"config_entry_id": local_config_entry.entry_id,
47+
}
48+
49+
await hass.services.async_call(
50+
DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True
51+
)
52+
53+
mock_set_time.assert_called_once_with(datetime.now())
54+
55+
56+
async def test_set_time_service_with_invalid_entry(
57+
hass: HomeAssistant, setup_risco_local
58+
) -> None:
59+
"""Test the set_time service with an invalid config entry."""
60+
data = {
61+
ATTR_CONFIG_ENTRY_ID: "invalid_entry_id",
62+
}
63+
64+
with pytest.raises(ServiceValidationError, match="Config entry not found"):
65+
await hass.services.async_call(
66+
DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True
67+
)
68+
69+
70+
async def test_set_time_service_with_not_loaded_entry(
71+
hass: HomeAssistant, setup_risco_local, local_config_entry
72+
) -> None:
73+
"""Test the set_time service with a config entry that is not loaded."""
74+
await hass.config_entries.async_unload(local_config_entry.entry_id)
75+
await hass.async_block_till_done()
76+
77+
assert local_config_entry.state is ConfigEntryState.NOT_LOADED
78+
79+
data = {
80+
ATTR_CONFIG_ENTRY_ID: local_config_entry.entry_id,
81+
}
82+
83+
with pytest.raises(ServiceValidationError, match="is not loaded"):
84+
await hass.services.async_call(
85+
DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True
86+
)
87+
88+
89+
async def test_set_time_service_with_cloud_entry(
90+
hass: HomeAssistant, setup_risco_local
91+
) -> None:
92+
"""Test the set_time service with a cloud config entry."""
93+
cloud_entry = MockConfigEntry(
94+
domain=DOMAIN,
95+
unique_id="test-cloud",
96+
data=TEST_CLOUD_CONFIG,
97+
)
98+
cloud_entry.add_to_hass(hass)
99+
cloud_entry.mock_state(hass, ConfigEntryState.LOADED)
100+
101+
data = {
102+
ATTR_CONFIG_ENTRY_ID: cloud_entry.entry_id,
103+
}
104+
105+
with pytest.raises(
106+
ServiceValidationError, match="This service only works with local"
107+
):
108+
await hass.services.async_call(
109+
DOMAIN, SERVICE_SET_TIME, service_data=data, blocking=True
110+
)

0 commit comments

Comments
 (0)