Add Stookwijzer forecast service (#138392)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
fwestenberg 2025-07-14 21:08:16 +02:00 committed by GitHub
parent 1ef07544d5
commit 9068a09620
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 226 additions and 10 deletions

View File

@ -8,13 +8,27 @@ from stookwijzer import Stookwijzer
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER
from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
from .services import setup_services
PLATFORMS = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Stookwijzer component."""
setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool:
"""Set up Stookwijzer from a config entry."""

View File

@ -5,3 +5,6 @@ from typing import Final
DOMAIN: Final = "stookwijzer"
LOGGER = logging.getLogger(__package__)
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
SERVICE_GET_FORECAST = "get_forecast"

View File

@ -0,0 +1,7 @@
{
"services": {
"get_forecast": {
"service": "mdi:clock-plus-outline"
}
}
}

View File

@ -0,0 +1,76 @@
"""Define services for the Stookwijzer integration."""
from typing import Required, TypedDict, cast
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST
from .coordinator import StookwijzerConfigEntry
SERVICE_GET_FORECAST_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
}
)
class Forecast(TypedDict):
"""Typed Stookwijzer forecast dict."""
datetime: Required[str]
advice: str | None
final: bool | None
def async_get_entry(
hass: HomeAssistant, config_entry_id: str
) -> StookwijzerConfigEntry:
"""Get the Overseerr config entry."""
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return cast(StookwijzerConfigEntry, entry)
def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Stookwijzer integration."""
async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None:
"""Get the forecast from API endpoint."""
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
client = entry.runtime_data.client
return cast(
ServiceResponse,
{
"forecast": cast(
list[Forecast], await client.async_get_forecast() or []
),
},
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_FORECAST,
async_get_forecast,
schema=SERVICE_GET_FORECAST_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@ -0,0 +1,7 @@
get_forecast:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: stookwijzer

View File

@ -27,6 +27,18 @@
}
}
},
"services": {
"get_forecast": {
"name": "Get forecast",
"description": "Retrieves the advice forecast from Stookwijzer.",
"fields": {
"config_entry_id": {
"name": "Stookwijzer instance",
"description": "The Stookwijzer instance to get the forecast from."
}
}
}
},
"issues": {
"location_migration_failed": {
"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 @@
"exceptions": {
"no_data_received": {
"message": "No data received from Stookwijzer."
},
"not_loaded": {
"message": "{target} is not loaded."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
}
}
}

View File

@ -1,26 +1,18 @@
"""Fixtures for Stookwijzer integration tests."""
from collections.abc import Generator
from typing import Required, TypedDict
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.stookwijzer.const import DOMAIN
from homeassistant.components.stookwijzer.services import Forecast
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
class Forecast(TypedDict):
"""Typed Stookwijzer forecast dict."""
datetime: Required[str]
advice: str | None
final: bool | None
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""

View File

@ -0,0 +1,27 @@
# serializer version: 1
# name: test_service_get_forecast
dict({
'forecast': tuple(
dict({
'advice': 'code_yellow',
'datetime': '2025-02-12T17:00:00+01:00',
'final': True,
}),
dict({
'advice': 'code_yellow',
'datetime': '2025-02-12T23:00:00+01:00',
'final': True,
}),
dict({
'advice': 'code_orange',
'datetime': '2025-02-13T05:00:00+01:00',
'final': False,
}),
dict({
'advice': 'code_orange',
'datetime': '2025-02-13T11:00:00+01:00',
'final': False,
}),
),
})
# ---

View File

@ -0,0 +1,72 @@
"""Tests for the Stookwijzer services."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.stookwijzer.const import (
ATTR_CONFIG_ENTRY_ID,
DOMAIN,
SERVICE_GET_FORECAST,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("init_integration")
async def test_service_get_forecast(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Stookwijzer forecast service."""
assert snapshot == await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_service_entry_not_loaded(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error handling when entry is not loaded."""
mock_config_entry2 = MockConfigEntry(domain=DOMAIN)
mock_config_entry2.add_to_hass(hass)
with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_service_integration_not_found(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error handling when integration not in registry."""
with pytest.raises(
ServiceValidationError, match='Integration "stookwijzer" not found in registry'
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECAST,
{ATTR_CONFIG_ENTRY_ID: "bad-config_id"},
blocking=True,
return_response=True,
)