diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index 9adfc09de0e..e51f3d76c7c 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -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.""" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1b0be86d375..7b4c28540fc 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -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" diff --git a/homeassistant/components/stookwijzer/icons.json b/homeassistant/components/stookwijzer/icons.json new file mode 100644 index 00000000000..19fda370796 --- /dev/null +++ b/homeassistant/components/stookwijzer/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_forecast": { + "service": "mdi:clock-plus-outline" + } + } +} diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py new file mode 100644 index 00000000000..e8c12717a21 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.py @@ -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, + ) diff --git a/homeassistant/components/stookwijzer/services.yaml b/homeassistant/components/stookwijzer/services.yaml new file mode 100644 index 00000000000..49e1f7b2927 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.yaml @@ -0,0 +1,7 @@ +get_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: stookwijzer diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index a028f1f19c5..160387ed8aa 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -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." } } } diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index dd7f2a7bbc3..0f127ba767a 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -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.""" diff --git a/tests/components/stookwijzer/snapshots/test_services.ambr b/tests/components/stookwijzer/snapshots/test_services.ambr new file mode 100644 index 00000000000..d5124219d32 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_services.ambr @@ -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, + }), + ), + }) +# --- diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py new file mode 100644 index 00000000000..f60730a290d --- /dev/null +++ b/tests/components/stookwijzer/test_services.py @@ -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, + )