Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions homeassistant/components/risco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Comment thread
FredericMa marked this conversation as resolved.

PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Expand All @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions homeassistant/components/risco/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_CONCURRENCY: DEFAULT_CONCURRENCY,
}

SERVICE_SET_TIME = "set_time"
7 changes: 7 additions & 0 deletions homeassistant/components/risco/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"services": {
"set_time": {
"service": "mdi:clock-edit"
}
}
}
15 changes: 15 additions & 0 deletions homeassistant/components/risco/models.py
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions homeassistant/components/risco/services.py
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
FredericMa marked this conversation as resolved.
Comment thread
FredericMa marked this conversation as resolved.

local_data: LocalData = hass.data[DOMAIN][config_entry_id]
Comment thread
FredericMa marked this conversation as resolved.

await local_data.system.set_time(time_to_send)
Comment thread
FredericMa marked this conversation as resolved.

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,
)
11 changes: 11 additions & 0 deletions homeassistant/components/risco/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
set_time:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: risco
time:
required: false
selector:
datetime:
27 changes: 27 additions & 0 deletions homeassistant/components/risco/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
}
}
110 changes: 110 additions & 0 deletions tests/components/risco/test_services.py
Original file line number Diff line number Diff line change
@@ -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())
Comment thread
FredericMa marked this conversation as resolved.


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
)
Loading