Skip to content

Commit 9068a09

Browse files
Add Stookwijzer forecast service (home-assistant#138392)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 1ef0754 commit 9068a09

File tree

9 files changed

+226
-10
lines changed

9 files changed

+226
-10
lines changed

homeassistant/components/stookwijzer/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,27 @@
88

99
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
1010
from homeassistant.core import HomeAssistant, callback
11-
from homeassistant.helpers import entity_registry as er, issue_registry as ir
11+
from homeassistant.helpers import (
12+
config_validation as cv,
13+
entity_registry as er,
14+
issue_registry as ir,
15+
)
16+
from homeassistant.helpers.typing import ConfigType
1217

1318
from .const import DOMAIN, LOGGER
1419
from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
20+
from .services import setup_services
1521

1622
PLATFORMS = [Platform.SENSOR]
1723

24+
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
25+
26+
27+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
28+
"""Set up the Stookwijzer component."""
29+
setup_services(hass)
30+
return True
31+
1832

1933
async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool:
2034
"""Set up Stookwijzer from a config entry."""

homeassistant/components/stookwijzer/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55

66
DOMAIN: Final = "stookwijzer"
77
LOGGER = logging.getLogger(__package__)
8+
9+
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
10+
SERVICE_GET_FORECAST = "get_forecast"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"services": {
3+
"get_forecast": {
4+
"service": "mdi:clock-plus-outline"
5+
}
6+
}
7+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Define services for the Stookwijzer integration."""
2+
3+
from typing import Required, TypedDict, cast
4+
5+
import voluptuous as vol
6+
7+
from homeassistant.config_entries import ConfigEntryState
8+
from homeassistant.core import (
9+
HomeAssistant,
10+
ServiceCall,
11+
ServiceResponse,
12+
SupportsResponse,
13+
)
14+
from homeassistant.exceptions import ServiceValidationError
15+
16+
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST
17+
from .coordinator import StookwijzerConfigEntry
18+
19+
SERVICE_GET_FORECAST_SCHEMA = vol.Schema(
20+
{
21+
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
22+
}
23+
)
24+
25+
26+
class Forecast(TypedDict):
27+
"""Typed Stookwijzer forecast dict."""
28+
29+
datetime: Required[str]
30+
advice: str | None
31+
final: bool | None
32+
33+
34+
def async_get_entry(
35+
hass: HomeAssistant, config_entry_id: str
36+
) -> StookwijzerConfigEntry:
37+
"""Get the Overseerr config entry."""
38+
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
39+
raise ServiceValidationError(
40+
translation_domain=DOMAIN,
41+
translation_key="integration_not_found",
42+
translation_placeholders={"target": DOMAIN},
43+
)
44+
if entry.state is not ConfigEntryState.LOADED:
45+
raise ServiceValidationError(
46+
translation_domain=DOMAIN,
47+
translation_key="not_loaded",
48+
translation_placeholders={"target": entry.title},
49+
)
50+
return cast(StookwijzerConfigEntry, entry)
51+
52+
53+
def setup_services(hass: HomeAssistant) -> None:
54+
"""Set up the services for the Stookwijzer integration."""
55+
56+
async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None:
57+
"""Get the forecast from API endpoint."""
58+
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
59+
client = entry.runtime_data.client
60+
61+
return cast(
62+
ServiceResponse,
63+
{
64+
"forecast": cast(
65+
list[Forecast], await client.async_get_forecast() or []
66+
),
67+
},
68+
)
69+
70+
hass.services.async_register(
71+
DOMAIN,
72+
SERVICE_GET_FORECAST,
73+
async_get_forecast,
74+
schema=SERVICE_GET_FORECAST_SCHEMA,
75+
supports_response=SupportsResponse.ONLY,
76+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
get_forecast:
2+
fields:
3+
config_entry_id:
4+
required: true
5+
selector:
6+
config_entry:
7+
integration: stookwijzer

homeassistant/components/stookwijzer/strings.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@
2727
}
2828
}
2929
},
30+
"services": {
31+
"get_forecast": {
32+
"name": "Get forecast",
33+
"description": "Retrieves the advice forecast from Stookwijzer.",
34+
"fields": {
35+
"config_entry_id": {
36+
"name": "Stookwijzer instance",
37+
"description": "The Stookwijzer instance to get the forecast from."
38+
}
39+
}
40+
}
41+
},
3042
"issues": {
3143
"location_migration_failed": {
3244
"description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.",
@@ -36,6 +48,12 @@
3648
"exceptions": {
3749
"no_data_received": {
3850
"message": "No data received from Stookwijzer."
51+
},
52+
"not_loaded": {
53+
"message": "{target} is not loaded."
54+
},
55+
"integration_not_found": {
56+
"message": "Integration \"{target}\" not found in registry."
3957
}
4058
}
4159
}

tests/components/stookwijzer/conftest.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
"""Fixtures for Stookwijzer integration tests."""
22

33
from collections.abc import Generator
4-
from typing import Required, TypedDict
54
from unittest.mock import AsyncMock, MagicMock, patch
65

76
import pytest
87

98
from homeassistant.components.stookwijzer.const import DOMAIN
9+
from homeassistant.components.stookwijzer.services import Forecast
1010
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
1111
from homeassistant.core import HomeAssistant
1212

1313
from tests.common import MockConfigEntry
1414

1515

16-
class Forecast(TypedDict):
17-
"""Typed Stookwijzer forecast dict."""
18-
19-
datetime: Required[str]
20-
advice: str | None
21-
final: bool | None
22-
23-
2416
@pytest.fixture
2517
def mock_config_entry() -> MockConfigEntry:
2618
"""Return the default mocked config entry."""
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# serializer version: 1
2+
# name: test_service_get_forecast
3+
dict({
4+
'forecast': tuple(
5+
dict({
6+
'advice': 'code_yellow',
7+
'datetime': '2025-02-12T17:00:00+01:00',
8+
'final': True,
9+
}),
10+
dict({
11+
'advice': 'code_yellow',
12+
'datetime': '2025-02-12T23:00:00+01:00',
13+
'final': True,
14+
}),
15+
dict({
16+
'advice': 'code_orange',
17+
'datetime': '2025-02-13T05:00:00+01:00',
18+
'final': False,
19+
}),
20+
dict({
21+
'advice': 'code_orange',
22+
'datetime': '2025-02-13T11:00:00+01:00',
23+
'final': False,
24+
}),
25+
),
26+
})
27+
# ---
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for the Stookwijzer services."""
2+
3+
import pytest
4+
from syrupy.assertion import SnapshotAssertion
5+
6+
from homeassistant.components.stookwijzer.const import (
7+
ATTR_CONFIG_ENTRY_ID,
8+
DOMAIN,
9+
SERVICE_GET_FORECAST,
10+
)
11+
from homeassistant.core import HomeAssistant
12+
from homeassistant.exceptions import ServiceValidationError
13+
from homeassistant.helpers import entity_registry as er
14+
15+
from tests.common import MockConfigEntry
16+
17+
18+
@pytest.mark.usefixtures("init_integration")
19+
async def test_service_get_forecast(
20+
hass: HomeAssistant,
21+
snapshot: SnapshotAssertion,
22+
entity_registry: er.EntityRegistry,
23+
mock_config_entry: MockConfigEntry,
24+
) -> None:
25+
"""Test the Stookwijzer forecast service."""
26+
27+
assert snapshot == await hass.services.async_call(
28+
DOMAIN,
29+
SERVICE_GET_FORECAST,
30+
{ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id},
31+
blocking=True,
32+
return_response=True,
33+
)
34+
35+
36+
@pytest.mark.usefixtures("init_integration")
37+
async def test_service_entry_not_loaded(
38+
hass: HomeAssistant,
39+
entity_registry: er.EntityRegistry,
40+
mock_config_entry: MockConfigEntry,
41+
) -> None:
42+
"""Test error handling when entry is not loaded."""
43+
mock_config_entry2 = MockConfigEntry(domain=DOMAIN)
44+
mock_config_entry2.add_to_hass(hass)
45+
46+
with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"):
47+
await hass.services.async_call(
48+
DOMAIN,
49+
SERVICE_GET_FORECAST,
50+
{ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id},
51+
blocking=True,
52+
return_response=True,
53+
)
54+
55+
56+
@pytest.mark.usefixtures("init_integration")
57+
async def test_service_integration_not_found(
58+
hass: HomeAssistant,
59+
entity_registry: er.EntityRegistry,
60+
mock_config_entry: MockConfigEntry,
61+
) -> None:
62+
"""Test error handling when integration not in registry."""
63+
with pytest.raises(
64+
ServiceValidationError, match='Integration "stookwijzer" not found in registry'
65+
):
66+
await hass.services.async_call(
67+
DOMAIN,
68+
SERVICE_GET_FORECAST,
69+
{ATTR_CONFIG_ENTRY_ID: "bad-config_id"},
70+
blocking=True,
71+
return_response=True,
72+
)

0 commit comments

Comments
 (0)