diff --git a/custom_components/irm_kmi/coordinator.py b/custom_components/irm_kmi/coordinator.py index 32e36e9..d5721d3 100644 --- a/custom_components/irm_kmi/coordinator.py +++ b/custom_components/irm_kmi/coordinator.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import (TimestampDataUpdateCoordinator, - UpdateFailed) +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, UpdateFailed) from homeassistant.util.dt import utcnow from .api import IrmKmiApiClient, IrmKmiApiError @@ -168,6 +168,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator): current_weather=IrmKmiCoordinator.current_weather_from_data(api_data), daily_forecast=self.daily_list_to_forecast(api_data.get('for', {}).get('daily')), hourly_forecast=IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')), + radar_forecast=IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation', {})), animation=await self._async_animation_data(api_data=api_data), warnings=self.warnings_from_data(api_data.get('for', {}).get('warning')), pollen=await self._async_pollen_data(api_data=api_data) @@ -317,6 +318,21 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator): return forecasts + @staticmethod + def radar_list_to_forecast(data: dict | None) -> List[Forecast] | None: + if data is None: + return None + + forecast = list() + for f in data.get("sequence", []): + forecast.append( + Forecast( + datetime=f.get("time"), + native_precipitation=f.get('value') + ) + ) + return forecast + def daily_list_to_forecast(self, data: List[dict] | None) -> List[Forecast] | None: """Parse data from the API to create a list of daily forecasts""" if data is None or not isinstance(data, list) or len(data) == 0: diff --git a/custom_components/irm_kmi/data.py b/custom_components/irm_kmi/data.py index 2f12224..7108bd5 100644 --- a/custom_components/irm_kmi/data.py +++ b/custom_components/irm_kmi/data.py @@ -60,6 +60,7 @@ class ProcessedCoordinatorData(TypedDict, total=False): current_weather: CurrentWeatherData hourly_forecast: List[Forecast] | None daily_forecast: List[IrmKmiForecast] | None + radar_forecast: List[Forecast] | None animation: RadarAnimationData warnings: List[WarningData] pollen: dict diff --git a/custom_components/irm_kmi/icons.json b/custom_components/irm_kmi/icons.json new file mode 100644 index 0000000..3235128 --- /dev/null +++ b/custom_components/irm_kmi/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "get_forecasts_radar": "mdi:weather-cloudy-clock" + } +} \ No newline at end of file diff --git a/custom_components/irm_kmi/services.yaml b/custom_components/irm_kmi/services.yaml new file mode 100644 index 0000000..fecdd6d --- /dev/null +++ b/custom_components/irm_kmi/services.yaml @@ -0,0 +1,11 @@ +get_forecasts_radar: + target: + entity: + integration: irm_kmi + domain: weather + fields: + include_past_forecasts: + required: true + default: false + selector: + boolean: diff --git a/custom_components/irm_kmi/translations/en.json b/custom_components/irm_kmi/translations/en.json index 847dcca..7aee83c 100644 --- a/custom_components/irm_kmi/translations/en.json +++ b/custom_components/irm_kmi/translations/en.json @@ -166,5 +166,17 @@ } } } + }, + "services": { + "get_forecasts_radar": { + "name": "Get forecast from the radar", + "description": "Get weather forecast from the radar. Only precipitation is available.", + "fields": { + "include_past_forecasts": { + "name": "Include past forecasts", + "description": "Also return forecasts for that are in the past." + } + } + } } } diff --git a/custom_components/irm_kmi/translations/fr.json b/custom_components/irm_kmi/translations/fr.json index 6328e60..0447d2c 100644 --- a/custom_components/irm_kmi/translations/fr.json +++ b/custom_components/irm_kmi/translations/fr.json @@ -166,5 +166,17 @@ } } } + }, + "services": { + "get_forecasts_radar": { + "name": "Obtenir les prévisions du radar", + "description": "Obtenez les prévisions météorologiques depuis le radar. Seules les précipitations sont disponibles.", + "fields": { + "include_past_forecasts": { + "name": "Inclure les prévisions passées", + "description": "Retourne également les prévisions qui sont dans le passé." + } + } + } } } diff --git a/custom_components/irm_kmi/translations/nl.json b/custom_components/irm_kmi/translations/nl.json index 0b206d0..63b1014 100644 --- a/custom_components/irm_kmi/translations/nl.json +++ b/custom_components/irm_kmi/translations/nl.json @@ -166,5 +166,17 @@ } } } + }, + "services": { + "get_forecasts_radar": { + "name": "Get forecast from the radar", + "description": "Weersverwachting van radar ophalen. Alleen neerslag is beschikbaar.", + "fields": { + "include_past_forecasts": { + "name": "Verleden weersvoorspellingen opnemen", + "description": "Geeft ook weersvoorspellingen uit het verleden." + } + } + } } } diff --git a/custom_components/irm_kmi/weather.py b/custom_components/irm_kmi/weather.py index 8b57ef1..2506362 100644 --- a/custom_components/irm_kmi/weather.py +++ b/custom_components/irm_kmi/weather.py @@ -1,16 +1,20 @@ """Support for IRM KMI weather.""" -import copy import logging +from datetime import datetime from typing import List +import voluptuous as vol from homeassistant.components.weather import (Forecast, WeatherEntity, WeatherEntityFeature) from homeassistant.config_entries import ConfigEntry from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt from . import CONF_USE_DEPRECATED_FORECAST, DOMAIN from .const import (OPTION_DEPRECATED_FORECAST_DAILY, @@ -25,11 +29,25 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): """Set up the weather entry.""" + add_services() coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([IrmKmiWeather(coordinator, entry)]) +def add_services() -> None: + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "get_forecasts_radar", + cv.make_entity_service_schema({ + vol.Optional("include_past_forecasts"): vol.Boolean() + }), + IrmKmiWeather.get_forecasts_radar_service.__name__, + supports_response=SupportsResponse.ONLY + ) + + class IrmKmiWeather(CoordinatorEntity, WeatherEntity): def __init__(self, @@ -41,7 +59,6 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity): self._name = entry.title self._attr_unique_id = entry.entry_id self._attr_device_info = coordinator.shared_device_info - self._deprecated_forecast_as = get_config_value(entry, CONF_USE_DEPRECATED_FORECAST) if self._deprecated_forecast_as != OPTION_DEPRECATED_FORECAST_NOT_USED: @@ -134,6 +151,20 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity): return [f for f in data if f.get('is_daytime')] + def get_forecasts_radar_service(self, include_past_forecasts: bool) -> List[Forecast] | None: + """ + Forecast service based on data from the radar. Only contains datetime and precipitation amount. + The result always include the current 10 minutes interval, even if include_past_forecast is false. + :param include_past_forecasts: whether to include data points that are in the past + :return: ordered list of forecasts + """ + # now = datetime.now(tz=pytz.timezone(self.hass.config.time_zone)) + now = dt.now() + now = now.replace(minute=(now.minute // 10) * 10, second=0, microsecond=0) + + return [f for f in self.coordinator.data.get('radar_forecast') + if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now] + @property def extra_state_attributes(self) -> dict: """Here to keep the DEPRECATED forecast attribute. diff --git a/tests/conftest.py b/tests/conftest.py index 40db775..80a33f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,8 @@ from custom_components.irm_kmi.api import (IrmKmiApiError, IrmKmiApiParametersError) from custom_components.irm_kmi.const import ( CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST, DOMAIN, - OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD, OPTION_DEPRECATED_FORECAST_TWICE_DAILY) + OPTION_DEPRECATED_FORECAST_NOT_USED, + OPTION_DEPRECATED_FORECAST_TWICE_DAILY, OPTION_STYLE_STD) def get_api_data(fixture: str) -> dict: diff --git a/tests/fixtures/forecast.json b/tests/fixtures/forecast.json index de81f9e..ba3b66a 100644 --- a/tests/fixtures/forecast.json +++ b/tests/fixtures/forecast.json @@ -1408,7 +1408,7 @@ { "time": "2023-12-26T17:40:00+01:00", "uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261650&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", - "value": 0, + "value": 0.1, "position": 0, "positionLower": 0, "positionHigher": 0 @@ -1416,7 +1416,7 @@ { "time": "2023-12-26T17:50:00+01:00", "uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261700&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", - "value": 0, + "value": 0.01, "position": 0, "positionLower": 0, "positionHigher": 0 @@ -1424,7 +1424,7 @@ { "time": "2023-12-26T18:00:00+01:00", "uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261710&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", - "value": 0, + "value": 0.12, "position": 0, "positionLower": 0, "positionHigher": 0 @@ -1432,7 +1432,7 @@ { "time": "2023-12-26T18:10:00+01:00", "uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261720&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", - "value": 0, + "value": 1.2, "position": 0, "positionLower": 0, "positionHigher": 0 @@ -1440,7 +1440,7 @@ { "time": "2023-12-26T18:20:00+01:00", "uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261730&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720", - "value": 0, + "value": 2, "position": 0, "positionLower": 0, "positionHigher": 0 diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 1ebb152..432a205 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -8,8 +8,9 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.irm_kmi.coordinator import IrmKmiCoordinator -from custom_components.irm_kmi.data import CurrentWeatherData, IrmKmiForecast, ProcessedCoordinatorData, \ - RadarAnimationData +from custom_components.irm_kmi.data import (CurrentWeatherData, IrmKmiForecast, + ProcessedCoordinatorData, + RadarAnimationData) from custom_components.irm_kmi.pollen import PollenParser from tests.conftest import get_api_data @@ -177,3 +178,24 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail( assert result.get('animation').get('hint') == "This will remain unchanged" assert result.get('pollen') == {'foo': 'bar'} + + +def test_radar_forecast() -> None: + api_data = get_api_data("forecast.json") + result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation')) + + expected = [ + Forecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1), + Forecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01), + Forecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12), + Forecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2), + Forecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2), + Forecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0) + ] + + assert expected == result diff --git a/tests/test_weather.py b/tests/test_weather.py index 0ac3569..174313f 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather +from custom_components.irm_kmi.data import ProcessedCoordinatorData +from tests.conftest import get_api_data @freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00")) @@ -91,3 +93,43 @@ async def test_forecast_attribute_same_as_service_call( result_forecast: List[Forecast] = weather.extra_state_attributes['forecast'] assert result_service == result_forecast + + +@freeze_time(datetime.fromisoformat("2023-12-26T17:58:03+01:00")) +async def test_radar_forecast_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry +): + hass.config.time_zone = 'Europe/Brussels' + coordinator = IrmKmiCoordinator(hass, mock_config_entry) + + api_data = get_api_data("forecast.json") + data = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation')) + + coordinator.data = ProcessedCoordinatorData( + radar_forecast=data + ) + + weather = IrmKmiWeather(coordinator, mock_config_entry) + + result_service: List[Forecast] = weather.get_forecasts_radar_service(False) + + expected = [ + Forecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1), + Forecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01), + Forecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12), + Forecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2), + Forecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2), + Forecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0), + Forecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0) + ] + + assert result_service == expected[5:] + + result_service: List[Forecast] = weather.get_forecasts_radar_service(True) + + assert result_service == expected