Move API related code to a sub-module + update tests
|
@ -9,9 +9,9 @@ from homeassistant.exceptions import ConfigEntryError
|
||||||
|
|
||||||
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD,
|
OPTION_DEPRECATED_FORECAST_NOT_USED, PLATFORMS)
|
||||||
PLATFORMS)
|
|
||||||
from .coordinator import IrmKmiCoordinator
|
from .coordinator import IrmKmiCoordinator
|
||||||
|
from .irm_kmi_api.const import OPTION_STYLE_STD
|
||||||
from .weather import IrmKmiWeather
|
from .weather import IrmKmiWeather
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
"""API Client for IRM KMI weather"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import async_timeout
|
|
||||||
from .const import USER_AGENT
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiError(Exception):
|
|
||||||
"""Exception to indicate a general API error."""
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiCommunicationError(IrmKmiApiError):
|
|
||||||
"""Exception to indicate a communication error."""
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiParametersError(IrmKmiApiError):
|
|
||||||
"""Exception to indicate a parameter error."""
|
|
||||||
|
|
||||||
|
|
||||||
def _api_key(method_name: str) -> str:
|
|
||||||
"""Get API key."""
|
|
||||||
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiClient:
|
|
||||||
"""API client for IRM KMI weather data"""
|
|
||||||
COORD_DECIMALS = 6
|
|
||||||
cache_max_age = 60 * 60 * 2 # Remove items from the cache if they have not been hit since 2 hours
|
|
||||||
cache = {}
|
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
||||||
self._session = session
|
|
||||||
self._base_url = "https://app.meteo.be/services/appv4/"
|
|
||||||
|
|
||||||
async def get_forecasts_coord(self, coord: dict) -> dict:
|
|
||||||
"""Get forecasts for given city."""
|
|
||||||
assert 'lat' in coord
|
|
||||||
assert 'long' in coord
|
|
||||||
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
|
||||||
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
|
||||||
|
|
||||||
response: bytes = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
|
||||||
return json.loads(response)
|
|
||||||
|
|
||||||
async def get_image(self, url, params: dict | None = None) -> bytes:
|
|
||||||
"""Get the image at the specified url with the parameters"""
|
|
||||||
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
|
||||||
return r
|
|
||||||
|
|
||||||
async def get_svg(self, url, params: dict | None = None) -> str:
|
|
||||||
"""Get SVG as str at the specified url with the parameters"""
|
|
||||||
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
|
||||||
return r.decode()
|
|
||||||
|
|
||||||
async def _api_wrapper(
|
|
||||||
self,
|
|
||||||
params: dict,
|
|
||||||
base_url: str | None = None,
|
|
||||||
path: str = "",
|
|
||||||
method: str = "get",
|
|
||||||
data: dict | None = None,
|
|
||||||
headers: dict | None = None,
|
|
||||||
) -> bytes:
|
|
||||||
"""Get information from the API."""
|
|
||||||
url = f"{self._base_url if base_url is None else base_url}{path}"
|
|
||||||
|
|
||||||
if headers is None:
|
|
||||||
headers = {'User-Agent': USER_AGENT}
|
|
||||||
else:
|
|
||||||
headers['User-Agent'] = USER_AGENT
|
|
||||||
|
|
||||||
if url in self.cache:
|
|
||||||
headers['If-None-Match'] = self.cache[url]['etag']
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_timeout.timeout(60):
|
|
||||||
response = await self._session.request(
|
|
||||||
method=method,
|
|
||||||
url=url,
|
|
||||||
headers=headers,
|
|
||||||
json=data,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if response.status == 304:
|
|
||||||
_LOGGER.debug(f"Cache hit for {url}")
|
|
||||||
self.cache[url]['timestamp'] = time.time()
|
|
||||||
return self.cache[url]['response']
|
|
||||||
|
|
||||||
if 'ETag' in response.headers:
|
|
||||||
_LOGGER.debug(f"Saving in cache {url}")
|
|
||||||
r = await response.read()
|
|
||||||
self.cache[url] = {'etag': response.headers['ETag'], 'response': r, 'timestamp': time.time()}
|
|
||||||
return r
|
|
||||||
|
|
||||||
return await response.read()
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as exception:
|
|
||||||
raise IrmKmiApiCommunicationError("Timeout error fetching information") from exception
|
|
||||||
except (aiohttp.ClientError, socket.gaierror) as exception:
|
|
||||||
raise IrmKmiApiCommunicationError("Error fetching information") from exception
|
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
|
||||||
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception
|
|
||||||
|
|
||||||
def expire_cache(self):
|
|
||||||
now = time.time()
|
|
||||||
keys_to_delete = set()
|
|
||||||
for key, value in self.cache.items():
|
|
||||||
if now - value['timestamp'] > self.cache_max_age:
|
|
||||||
keys_to_delete.add(key)
|
|
||||||
for key in keys_to_delete:
|
|
||||||
del self.cache[key]
|
|
||||||
_LOGGER.info(f"Expired {len(keys_to_delete)} elements from API cache")
|
|
|
@ -15,13 +15,14 @@ from homeassistant.helpers.selector import (EntitySelector,
|
||||||
SelectSelectorConfig,
|
SelectSelectorConfig,
|
||||||
SelectSelectorMode)
|
SelectSelectorMode)
|
||||||
|
|
||||||
from .api import IrmKmiApiClient
|
from . import OPTION_STYLE_STD
|
||||||
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE,
|
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE,
|
||||||
CONF_LANGUAGE_OVERRIDE_OPTIONS, CONF_STYLE,
|
CONF_LANGUAGE_OVERRIDE_OPTIONS, CONF_STYLE,
|
||||||
CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST,
|
CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST,
|
||||||
CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION,
|
CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION,
|
||||||
DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
OPTION_STYLE_STD, OUT_OF_BENELUX)
|
OUT_OF_BENELUX, USER_AGENT)
|
||||||
|
from .irm_kmi_api.api import IrmKmiApiClient
|
||||||
from .utils import get_config_value
|
from .utils import get_config_value
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -50,9 +51,11 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
if not errors:
|
if not errors:
|
||||||
api_data = {}
|
api_data = {}
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(60):
|
async with (async_timeout.timeout(60)):
|
||||||
api_data = await IrmKmiApiClient(
|
api_data = await IrmKmiApiClient(
|
||||||
session=async_get_clientsession(self.hass)).get_forecasts_coord(
|
session=async_get_clientsession(self.hass),
|
||||||
|
user_agent=USER_AGENT
|
||||||
|
).get_forecasts_coord(
|
||||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,6 +14,9 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
ATTR_CONDITION_SUNNY)
|
ATTR_CONDITION_SUNNY)
|
||||||
from homeassistant.const import Platform, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, DEGREE
|
from homeassistant.const import Platform, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, DEGREE
|
||||||
|
|
||||||
|
from custom_components.irm_kmi.irm_kmi_api.const import OPTION_STYLE_CONTRAST, OPTION_STYLE_YELLOW_RED, \
|
||||||
|
OPTION_STYLE_SATELLITE, OPTION_STYLE_STD
|
||||||
|
|
||||||
DOMAIN: Final = 'irm_kmi'
|
DOMAIN: Final = 'irm_kmi'
|
||||||
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
|
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||||
CONFIG_FLOW_VERSION = 5
|
CONFIG_FLOW_VERSION = 5
|
||||||
|
@ -24,10 +27,6 @@ OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
|
||||||
"Buiten de Benelux (Brussel)"]
|
"Buiten de Benelux (Brussel)"]
|
||||||
LANGS: Final = ['en', 'fr', 'nl', 'de']
|
LANGS: Final = ['en', 'fr', 'nl', 'de']
|
||||||
|
|
||||||
OPTION_STYLE_STD: Final = 'standard_style'
|
|
||||||
OPTION_STYLE_CONTRAST: Final = 'contrast_style'
|
|
||||||
OPTION_STYLE_YELLOW_RED: Final = 'yellow_red_style'
|
|
||||||
OPTION_STYLE_SATELLITE: Final = 'satellite_style'
|
|
||||||
CONF_STYLE: Final = "style"
|
CONF_STYLE: Final = "style"
|
||||||
|
|
||||||
CONF_STYLE_OPTIONS: Final = [
|
CONF_STYLE_OPTIONS: Final = [
|
||||||
|
@ -39,13 +38,6 @@ CONF_STYLE_OPTIONS: Final = [
|
||||||
|
|
||||||
CONF_DARK_MODE: Final = "dark_mode"
|
CONF_DARK_MODE: Final = "dark_mode"
|
||||||
|
|
||||||
STYLE_TO_PARAM_MAP: Final = {
|
|
||||||
OPTION_STYLE_STD: 1,
|
|
||||||
OPTION_STYLE_CONTRAST: 2,
|
|
||||||
OPTION_STYLE_YELLOW_RED: 3,
|
|
||||||
OPTION_STYLE_SATELLITE: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
CONF_USE_DEPRECATED_FORECAST: Final = 'use_deprecated_forecast_attribute'
|
CONF_USE_DEPRECATED_FORECAST: Final = 'use_deprecated_forecast_attribute'
|
||||||
OPTION_DEPRECATED_FORECAST_NOT_USED: Final = 'do_not_use_deprecated_forecast'
|
OPTION_DEPRECATED_FORECAST_NOT_USED: Final = 'do_not_use_deprecated_forecast'
|
||||||
OPTION_DEPRECATED_FORECAST_DAILY: Final = 'daily_in_deprecated_forecast'
|
OPTION_DEPRECATED_FORECAST_DAILY: Final = 'daily_in_deprecated_forecast'
|
||||||
|
@ -130,23 +122,6 @@ IRM_KMI_TO_HA_CONDITION_MAP: Final = {
|
||||||
(27, 'n'): ATTR_CONDITION_FOG
|
(27, 'n'): ATTR_CONDITION_FOG
|
||||||
}
|
}
|
||||||
|
|
||||||
MAP_WARNING_ID_TO_SLUG: Final = {
|
|
||||||
0: 'wind',
|
|
||||||
1: 'rain',
|
|
||||||
2: 'ice_or_snow',
|
|
||||||
3: 'thunder',
|
|
||||||
7: 'fog',
|
|
||||||
9: 'cold',
|
|
||||||
12: 'thunder_wind_rain',
|
|
||||||
13: 'thunderstorm_strong_gusts',
|
|
||||||
14: 'thunderstorm_large_rainfall',
|
|
||||||
15: 'storm_surge',
|
|
||||||
17: 'coldspell'}
|
|
||||||
|
|
||||||
POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', 'Oak'}
|
|
||||||
POLLEN_LEVEL_TO_COLOR = {'null': 'green', 'low': 'yellow', 'moderate': 'orange', 'high': 'red', 'very high': 'purple',
|
|
||||||
'active': 'active'}
|
|
||||||
|
|
||||||
POLLEN_TO_ICON_MAP: Final = {
|
POLLEN_TO_ICON_MAP: Final = {
|
||||||
'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree',
|
'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree',
|
||||||
'mugwort': 'mdi:sprout', 'oak': 'mdi:tree'
|
'mugwort': 'mdi:sprout', 'oak': 'mdi:tree'
|
||||||
|
@ -159,8 +134,6 @@ IRM_KMI_NAME: Final = {
|
||||||
'en': 'Royal Meteorological Institute of Belgium'
|
'en': 'Royal Meteorological Institute of Belgium'
|
||||||
}
|
}
|
||||||
|
|
||||||
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
||||||
|
|
||||||
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.2.32'
|
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.2.32'
|
||||||
|
|
||||||
CURRENT_WEATHER_SENSORS: Final = {'temperature', 'wind_speed', 'wind_gust_speed', 'wind_bearing', 'uv_index',
|
CURRENT_WEATHER_SENSORS: Final = {'temperature', 'wind_speed', 'wind_gust_speed', 'wind_bearing', 'uv_index',
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from statistics import mean
|
|
||||||
from typing import List
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
import async_timeout
|
import async_timeout
|
||||||
from homeassistant.components.weather import Forecast
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
|
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -18,20 +14,14 @@ from homeassistant.helpers.update_coordinator import (
|
||||||
from homeassistant.util import dt
|
from homeassistant.util import dt
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
|
||||||
from .api import IrmKmiApiClient, IrmKmiApiError
|
from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN, IRM_KMI_NAME, USER_AGENT
|
||||||
from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN, IRM_KMI_NAME
|
|
||||||
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
|
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
|
||||||
from .const import MAP_WARNING_ID_TO_SLUG as SLUG_MAP
|
from .const import (OUT_OF_BENELUX)
|
||||||
from .const import (OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP,
|
from .data import ProcessedCoordinatorData
|
||||||
WEEKDAYS)
|
from .irm_kmi_api.api import IrmKmiApiClientHa, IrmKmiApiError
|
||||||
from .data import (CurrentWeatherData, IrmKmiForecast,
|
from .irm_kmi_api.pollen import PollenParser
|
||||||
ProcessedCoordinatorData,
|
from .irm_kmi_api.rain_graph import RainGraph
|
||||||
WarningData)
|
from .utils import (disable_from_config, get_config_value, preferred_language)
|
||||||
from .radar_data import IrmKmiRadarForecast, AnimationFrameData, RadarAnimationData
|
|
||||||
from .pollen import PollenParser
|
|
||||||
from .rain_graph import RainGraph
|
|
||||||
from .utils import (disable_from_config, get_config_value, next_weekday,
|
|
||||||
preferred_language)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -50,7 +40,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
||||||
# Polling interval. Will only be polled if there are subscribers.
|
# Polling interval. Will only be polled if there are subscribers.
|
||||||
update_interval=timedelta(minutes=7),
|
update_interval=timedelta(minutes=7),
|
||||||
)
|
)
|
||||||
self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass))
|
self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP)
|
||||||
self._zone = get_config_value(entry, CONF_ZONE)
|
self._zone = get_config_value(entry, CONF_ZONE)
|
||||||
self._dark_mode = get_config_value(entry, CONF_DARK_MODE)
|
self._dark_mode = get_config_value(entry, CONF_DARK_MODE)
|
||||||
self._style = get_config_value(entry, CONF_STYLE)
|
self._style = get_config_value(entry, CONF_STYLE)
|
||||||
|
@ -67,19 +57,17 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
||||||
This is the place to pre-process the data to lookup tables
|
This is the place to pre-process the data to lookup tables
|
||||||
so entities can quickly look up their data.
|
so entities can quickly look up their data.
|
||||||
"""
|
"""
|
||||||
self._api_client.expire_cache()
|
self._api.expire_cache()
|
||||||
if (zone := self.hass.states.get(self._zone)) is None:
|
if (zone := self.hass.states.get(self._zone)) is None:
|
||||||
raise UpdateFailed(f"Zone '{self._zone}' not found")
|
raise UpdateFailed(f"Zone '{self._zone}' not found")
|
||||||
try:
|
try:
|
||||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||||
# handled by the data update coordinator.
|
# handled by the data update coordinator.
|
||||||
async with async_timeout.timeout(60):
|
async with async_timeout.timeout(60):
|
||||||
api_data = await self._api_client.get_forecasts_coord(
|
await self._api.refresh_forecasts_coord(
|
||||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
)
|
)
|
||||||
_LOGGER.debug(f"Observation for {api_data.get('cityName', '')}: {api_data.get('obs', '{}')}")
|
|
||||||
_LOGGER.debug(f"Full data: {api_data}")
|
|
||||||
|
|
||||||
except IrmKmiApiError as err:
|
except IrmKmiApiError as err:
|
||||||
if self.last_update_success_time is not None \
|
if self.last_update_success_time is not None \
|
||||||
|
@ -90,7 +78,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
||||||
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
|
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
|
||||||
f"Last success time is: {self.last_update_success_time}")
|
f"Last success time is: {self.last_update_success_time}")
|
||||||
|
|
||||||
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
if self._api.get_city() in OUT_OF_BENELUX:
|
||||||
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
|
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
|
||||||
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
|
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
|
||||||
f"this")
|
f"this")
|
||||||
|
@ -108,414 +96,38 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
||||||
)
|
)
|
||||||
return ProcessedCoordinatorData()
|
return ProcessedCoordinatorData()
|
||||||
|
|
||||||
return await self.process_api_data(api_data)
|
return await self.process_api_data()
|
||||||
|
|
||||||
async def async_refresh(self) -> None:
|
async def async_refresh(self) -> None:
|
||||||
"""Refresh data and log errors."""
|
"""Refresh data and log errors."""
|
||||||
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
|
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
|
||||||
|
|
||||||
async def _async_animation_data(self, api_data: dict) -> RainGraph | None:
|
async def process_api_data(self) -> ProcessedCoordinatorData:
|
||||||
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
|
"""From the API data, create the object that will be used in the entities"""
|
||||||
Frames from the API are merged with the background map and the location marker to create each frame."""
|
tz = await dt.async_get_time_zone('Europe/Brussels')
|
||||||
animation_data = api_data.get('animation', {}).get('sequence')
|
|
||||||
localisation_layer_url = api_data.get('animation', {}).get('localisationLayer')
|
|
||||||
country = api_data.get('country', '')
|
|
||||||
|
|
||||||
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
|
||||||
return None
|
|
||||||
|
|
||||||
localisation = self.merge_url_and_params(localisation_layer_url,
|
|
||||||
{'th': 'd' if country == 'NL' or not self._dark_mode else 'n'})
|
|
||||||
images_from_api = [self.merge_url_and_params(frame.get('uri'), {'rs': STYLE_TO_PARAM_MAP[self._style]})
|
|
||||||
for frame in animation_data if frame is not None and frame.get('uri') is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
lang = preferred_language(self.hass, self.config_entry)
|
lang = preferred_language(self.hass, self.config_entry)
|
||||||
radar_animation = RadarAnimationData(
|
|
||||||
hint=api_data.get('animation', {}).get('sequenceHint', {}).get(lang),
|
|
||||||
unit=api_data.get('animation', {}).get('unit', {}).get(lang),
|
|
||||||
location=localisation
|
|
||||||
)
|
|
||||||
rain_graph: RainGraph = await self.create_rain_graph(radar_animation, animation_data, country, images_from_api)
|
|
||||||
return rain_graph
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def merge_url_and_params(url, params):
|
|
||||||
parsed_url = urllib.parse.urlparse(url)
|
|
||||||
query_params = urllib.parse.parse_qs(parsed_url.query)
|
|
||||||
query_params.update(params)
|
|
||||||
new_query = urllib.parse.urlencode(query_params, doseq=True)
|
|
||||||
new_url = parsed_url._replace(query=new_query)
|
|
||||||
return str(urllib.parse.urlunparse(new_url))
|
|
||||||
|
|
||||||
async def _async_pollen_data(self, api_data: dict) -> dict:
|
|
||||||
"""Get SVG pollen info from the API, return the pollen data dict"""
|
|
||||||
_LOGGER.debug("Getting pollen data from API")
|
|
||||||
svg_url = None
|
|
||||||
for module in api_data.get('module', []):
|
|
||||||
_LOGGER.debug(f"module: {module}")
|
|
||||||
if module.get('type', None) == 'svg':
|
|
||||||
url = module.get('data', {}).get('url', {}).get('en', '')
|
|
||||||
if 'pollen' in url:
|
|
||||||
svg_url = url
|
|
||||||
break
|
|
||||||
if svg_url is None:
|
|
||||||
return PollenParser.get_default_data()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug(f"Requesting pollen SVG at url {svg_url}")
|
pollen = await self._api.get_pollen()
|
||||||
pollen_svg: str = await self._api_client.get_svg(svg_url)
|
|
||||||
except IrmKmiApiError as err:
|
except IrmKmiApiError as err:
|
||||||
_LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.")
|
_LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.")
|
||||||
return self.data.get('pollen', PollenParser.get_unavailable_data()) \
|
pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \
|
||||||
if self.data is not None else PollenParser.get_unavailable_data()
|
if self.data is not None else PollenParser.get_unavailable_data()
|
||||||
|
|
||||||
return PollenParser(pollen_svg).get_pollen_data()
|
|
||||||
|
|
||||||
async def process_api_data(self, api_data: dict) -> ProcessedCoordinatorData:
|
|
||||||
"""From the API data, create the object that will be used in the entities"""
|
|
||||||
return ProcessedCoordinatorData(
|
|
||||||
current_weather=await IrmKmiCoordinator.current_weather_from_data(api_data),
|
|
||||||
daily_forecast=await self.daily_list_to_forecast(api_data.get('for', {}).get('daily')),
|
|
||||||
hourly_forecast=await 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),
|
|
||||||
country=api_data.get('country')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def current_weather_from_data(api_data: dict) -> CurrentWeatherData:
|
|
||||||
"""Parse the API data to build a CurrentWeatherData."""
|
|
||||||
# Process data to get current hour forecast
|
|
||||||
now_hourly = None
|
|
||||||
hourly_forecast_data = api_data.get('for', {}).get('hourly')
|
|
||||||
tz = await dt.async_get_time_zone('Europe/Brussels')
|
|
||||||
now = dt.now(time_zone=tz)
|
|
||||||
if not (hourly_forecast_data is None
|
|
||||||
or not isinstance(hourly_forecast_data, list)
|
|
||||||
or len(hourly_forecast_data) == 0):
|
|
||||||
|
|
||||||
for current in hourly_forecast_data[:4]:
|
|
||||||
if now.strftime('%H') == current['hour']:
|
|
||||||
now_hourly = current
|
|
||||||
break
|
|
||||||
# Get UV index
|
|
||||||
module_data = api_data.get('module', None)
|
|
||||||
uv_index = None
|
|
||||||
if not (module_data is None or not isinstance(module_data, list)):
|
|
||||||
for module in module_data:
|
|
||||||
if module.get('type', None) == 'uv':
|
|
||||||
uv_index = module.get('data', {}).get('levelValue')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pressure = float(now_hourly.get('pressure', None)) if now_hourly is not None else None
|
radar_animation, image_path, bg_size = await self._api.get_animation_data(tz, lang, self._style,
|
||||||
except (TypeError, ValueError):
|
self._dark_mode)
|
||||||
pressure = None
|
animation = await RainGraph(radar_animation, image_path, bg_size, tz=tz, dark_mode=self._dark_mode,
|
||||||
|
api_client=self._api).build()
|
||||||
try:
|
|
||||||
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
wind_speed = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
wind_gust_speed = float(now_hourly.get('windPeakSpeedKm', None)) if now_hourly is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
wind_gust_speed = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
temperature = float(api_data.get('obs', {}).get('temp'))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
temperature = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
dir_cardinal = now_hourly.get('windDirectionText', {}).get('en') if now_hourly is not None else None
|
|
||||||
if dir_cardinal == 'VAR' or now_hourly is None:
|
|
||||||
wind_bearing = None
|
|
||||||
else:
|
|
||||||
wind_bearing = (float(now_hourly.get('windDirection')) + 180) % 360
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
wind_bearing = None
|
|
||||||
|
|
||||||
current_weather = CurrentWeatherData(
|
|
||||||
condition=CDT_MAP.get((api_data.get('obs', {}).get('ww'), api_data.get('obs', {}).get('dayNight')), None),
|
|
||||||
temperature=temperature,
|
|
||||||
wind_speed=wind_speed,
|
|
||||||
wind_gust_speed=wind_gust_speed,
|
|
||||||
wind_bearing=wind_bearing,
|
|
||||||
pressure=pressure,
|
|
||||||
uv_index=uv_index
|
|
||||||
)
|
|
||||||
|
|
||||||
if api_data.get('country', '') == 'NL':
|
|
||||||
current_weather['wind_speed'] = api_data.get('obs', {}).get('windSpeedKm')
|
|
||||||
if api_data.get('obs', {}).get('windDirectionText', {}).get('en') == 'VAR':
|
|
||||||
current_weather['wind_bearing'] = None
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
current_weather['wind_bearing'] = (float(api_data.get('obs', {}).get('windDirection')) + 180) % 360
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
current_weather['wind_bearing'] = None
|
animation = None
|
||||||
|
|
||||||
# Since June 2024, the NL weather does not include the condition in the 'ww' key, so we get it from the current
|
return ProcessedCoordinatorData(
|
||||||
# hourly forecast instead if it is missing
|
current_weather=await self._api.get_current_weather(tz),
|
||||||
if current_weather['condition'] is None:
|
daily_forecast=await self._api.get_daily_forecast(tz, lang),
|
||||||
try:
|
hourly_forecast=await self._api.get_hourly_forecast(tz),
|
||||||
current_weather['condition'] = CDT_MAP.get((int(now_hourly.get('ww')), now_hourly.get('dayNight')))
|
radar_forecast=self._api.get_radar_forecast(),
|
||||||
except (TypeError, ValueError, AttributeError):
|
animation=animation,
|
||||||
current_weather['condition'] = None
|
warnings=self._api.get_warnings(lang),
|
||||||
|
pollen=pollen,
|
||||||
return current_weather
|
country=self._api.get_country()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
|
|
||||||
"""Parse data from the API to create a list of hourly forecasts"""
|
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
forecasts = list()
|
|
||||||
tz = await dt.async_get_time_zone('Europe/Brussels')
|
|
||||||
day = dt.now(time_zone=tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
for idx, f in enumerate(data):
|
|
||||||
if 'dateShow' in f and idx > 0:
|
|
||||||
day = day + timedelta(days=1)
|
|
||||||
|
|
||||||
hour = f.get('hour', None)
|
|
||||||
if hour is None:
|
|
||||||
continue
|
|
||||||
day = day.replace(hour=int(hour))
|
|
||||||
|
|
||||||
precipitation_probability = None
|
|
||||||
if f.get('precipChance', None) is not None:
|
|
||||||
precipitation_probability = int(f.get('precipChance'))
|
|
||||||
|
|
||||||
ww = None
|
|
||||||
if f.get('ww', None) is not None:
|
|
||||||
ww = int(f.get('ww'))
|
|
||||||
|
|
||||||
wind_bearing = None
|
|
||||||
if f.get('windDirectionText', {}).get('en') != 'VAR':
|
|
||||||
try:
|
|
||||||
wind_bearing = (float(f.get('windDirection')) + 180) % 360
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
forecast = Forecast(
|
|
||||||
datetime=day.isoformat(),
|
|
||||||
condition=CDT_MAP.get((ww, f.get('dayNight', None)), None),
|
|
||||||
native_precipitation=f.get('precipQuantity', None),
|
|
||||||
native_temperature=f.get('temp', None),
|
|
||||||
native_templow=None,
|
|
||||||
native_wind_gust_speed=f.get('windPeakSpeedKm', None),
|
|
||||||
native_wind_speed=f.get('windSpeedKm', None),
|
|
||||||
precipitation_probability=precipitation_probability,
|
|
||||||
wind_bearing=wind_bearing,
|
|
||||||
native_pressure=f.get('pressure', None),
|
|
||||||
is_daytime=f.get('dayNight', None) == 'd'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
forecasts.append(forecast)
|
|
||||||
|
|
||||||
return forecasts
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def radar_list_to_forecast(data: dict | None) -> List[IrmKmiRadarForecast] | None:
|
|
||||||
"""Create a list of short term forecasts for rain based on the data provided by the rain radar"""
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
sequence = data.get("sequence", [])
|
|
||||||
unit = data.get("unit", {}).get("en", None)
|
|
||||||
ratios = [f['value'] / f['position'] for f in sequence if f['position'] > 0]
|
|
||||||
|
|
||||||
if len(ratios) > 0:
|
|
||||||
ratio = mean(ratios)
|
|
||||||
else:
|
|
||||||
ratio = 0
|
|
||||||
|
|
||||||
forecast = list()
|
|
||||||
for f in sequence:
|
|
||||||
forecast.append(
|
|
||||||
IrmKmiRadarForecast(
|
|
||||||
datetime=f.get("time"),
|
|
||||||
native_precipitation=f.get('value'),
|
|
||||||
rain_forecast_max=round(f.get('positionHigher') * ratio, 2),
|
|
||||||
rain_forecast_min=round(f.get('positionLower') * ratio, 2),
|
|
||||||
might_rain=f.get('positionHigher') > 0,
|
|
||||||
unit=unit
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return forecast
|
|
||||||
|
|
||||||
async def daily_list_to_forecast(self, data: List[dict] | None) -> List[IrmKmiForecast] | 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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
forecasts = list()
|
|
||||||
lang = preferred_language(self.hass, self.config_entry)
|
|
||||||
tz = await dt.async_get_time_zone('Europe/Brussels')
|
|
||||||
forecast_day = dt.now(tz)
|
|
||||||
|
|
||||||
for (idx, f) in enumerate(data):
|
|
||||||
precipitation = None
|
|
||||||
if f.get('precipQuantity', None) is not None:
|
|
||||||
try:
|
|
||||||
precipitation = float(f.get('precipQuantity'))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
native_wind_gust_speed = None
|
|
||||||
if f.get('wind', {}).get('peakSpeed') is not None:
|
|
||||||
try:
|
|
||||||
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
wind_bearing = None
|
|
||||||
if f.get('wind', {}).get('dirText', {}).get('en') != 'VAR':
|
|
||||||
try:
|
|
||||||
wind_bearing = (float(f.get('wind', {}).get('dir')) + 180) % 360
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
is_daytime = f.get('dayNight', None) == 'd'
|
|
||||||
|
|
||||||
day_name = f.get('dayName', {}).get('en', None)
|
|
||||||
timestamp = f.get('timestamp', None)
|
|
||||||
if timestamp is not None:
|
|
||||||
forecast_day = datetime.fromisoformat(timestamp)
|
|
||||||
elif day_name in WEEKDAYS:
|
|
||||||
forecast_day = next_weekday(forecast_day, WEEKDAYS.index(day_name))
|
|
||||||
elif day_name in ['Today', 'Tonight']:
|
|
||||||
forecast_day = dt.now(tz)
|
|
||||||
elif day_name == 'Tomorrow':
|
|
||||||
forecast_day = dt.now(tz) + timedelta(days=1)
|
|
||||||
|
|
||||||
sunrise_sec = f.get('dawnRiseSeconds', None)
|
|
||||||
if sunrise_sec is None:
|
|
||||||
sunrise_sec = f.get('sunRise', None)
|
|
||||||
sunrise = None
|
|
||||||
if sunrise_sec is not None:
|
|
||||||
try:
|
|
||||||
sunrise = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
|
|
||||||
+ timedelta(seconds=float(sunrise_sec)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
sunset_sec = f.get('dawnSetSeconds', None)
|
|
||||||
if sunset_sec is None:
|
|
||||||
sunset_sec = f.get('sunSet', None)
|
|
||||||
sunset = None
|
|
||||||
if sunset_sec is not None:
|
|
||||||
try:
|
|
||||||
sunset = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
|
|
||||||
+ timedelta(seconds=float(sunset_sec)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
forecast = IrmKmiForecast(
|
|
||||||
datetime=(forecast_day.strftime('%Y-%m-%d')),
|
|
||||||
condition=CDT_MAP.get((f.get('ww1', None), f.get('dayNight', None)), None),
|
|
||||||
native_precipitation=precipitation,
|
|
||||||
native_temperature=f.get('tempMax', None),
|
|
||||||
native_templow=f.get('tempMin', None),
|
|
||||||
native_wind_gust_speed=native_wind_gust_speed,
|
|
||||||
native_wind_speed=f.get('wind', {}).get('speed'),
|
|
||||||
precipitation_probability=f.get('precipChance', None),
|
|
||||||
wind_bearing=wind_bearing,
|
|
||||||
is_daytime=is_daytime,
|
|
||||||
text=f.get('text', {}).get(lang, ""),
|
|
||||||
sunrise=sunrise.isoformat() if sunrise is not None else None,
|
|
||||||
sunset=sunset.isoformat() if sunset is not None else None
|
|
||||||
)
|
|
||||||
# Swap temperature and templow if needed
|
|
||||||
if (forecast['native_templow'] is not None
|
|
||||||
and forecast['native_temperature'] is not None
|
|
||||||
and forecast['native_templow'] > forecast['native_temperature']):
|
|
||||||
(forecast['native_templow'], forecast['native_temperature']) = \
|
|
||||||
(forecast['native_temperature'], forecast['native_templow'])
|
|
||||||
|
|
||||||
forecasts.append(forecast)
|
|
||||||
|
|
||||||
return forecasts
|
|
||||||
|
|
||||||
async def create_rain_graph(self,
|
|
||||||
radar_animation: RadarAnimationData,
|
|
||||||
api_animation_data: List[dict],
|
|
||||||
country: str,
|
|
||||||
images_from_api: list[str],
|
|
||||||
) -> RainGraph:
|
|
||||||
"""Create a RainGraph object that is ready to output animated and still SVG images"""
|
|
||||||
sequence: List[AnimationFrameData] = list()
|
|
||||||
|
|
||||||
tz = await dt.async_get_time_zone(self.hass.config.time_zone)
|
|
||||||
current_time = dt.now(time_zone=tz)
|
|
||||||
most_recent_frame = None
|
|
||||||
|
|
||||||
for idx, item in enumerate(api_animation_data):
|
|
||||||
frame = AnimationFrameData(
|
|
||||||
image=images_from_api[idx],
|
|
||||||
time=datetime.fromisoformat(item.get('time')) if item.get('time', None) is not None else None,
|
|
||||||
value=item.get('value', 0),
|
|
||||||
position=item.get('position', 0),
|
|
||||||
position_lower=item.get('positionLower', 0),
|
|
||||||
position_higher=item.get('positionHigher', 0)
|
|
||||||
)
|
|
||||||
sequence.append(frame)
|
|
||||||
|
|
||||||
if most_recent_frame is None and current_time < frame['time']:
|
|
||||||
most_recent_frame = idx - 1 if idx > 0 else idx
|
|
||||||
|
|
||||||
radar_animation['sequence'] = sequence
|
|
||||||
radar_animation['most_recent_image_idx'] = most_recent_frame
|
|
||||||
|
|
||||||
satellite_mode = self._style == OPTION_STYLE_SATELLITE
|
|
||||||
|
|
||||||
if country == 'NL':
|
|
||||||
image_path = "custom_components/irm_kmi/resources/nl.png"
|
|
||||||
bg_size = (640, 600)
|
|
||||||
else:
|
|
||||||
image_path = (f"custom_components/irm_kmi/resources/be_"
|
|
||||||
f"{'satellite' if satellite_mode else 'black' if self._dark_mode else 'white'}.png")
|
|
||||||
bg_size = (640, 490)
|
|
||||||
|
|
||||||
return await RainGraph(radar_animation, image_path, bg_size, tz=tz, config_dir=self.hass.config.config_dir,
|
|
||||||
dark_mode=self._dark_mode, api_client=self._api_client).build()
|
|
||||||
|
|
||||||
def warnings_from_data(self, warning_data: list | None) -> List[WarningData]:
|
|
||||||
"""Create a list of warning data instances based on the api data"""
|
|
||||||
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
lang = preferred_language(self.hass, self.config_entry)
|
|
||||||
result = list()
|
|
||||||
for data in warning_data:
|
|
||||||
try:
|
|
||||||
warning_id = int(data.get('warningType', {}).get('id'))
|
|
||||||
start = datetime.fromisoformat(data.get('fromTimestamp', None))
|
|
||||||
end = datetime.fromisoformat(data.get('toTimestamp', None))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
# Without this data, the warning is useless
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
level = int(data.get('warningLevel'))
|
|
||||||
except TypeError:
|
|
||||||
level = None
|
|
||||||
|
|
||||||
result.append(
|
|
||||||
WarningData(
|
|
||||||
slug=SLUG_MAP.get(warning_id, 'unknown'),
|
|
||||||
id=warning_id,
|
|
||||||
level=level,
|
|
||||||
friendly_name=data.get('warningType', {}).get('name', {}).get(lang, ''),
|
|
||||||
text=data.get('text', {}).get(lang, ''),
|
|
||||||
starts_at=start,
|
|
||||||
ends_at=end
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return result if len(result) > 0 else []
|
|
||||||
|
|
|
@ -1,41 +1,9 @@
|
||||||
"""Data classes for IRM KMI integration"""
|
from typing import TypedDict, List
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, TypedDict
|
|
||||||
|
|
||||||
from homeassistant.components.weather import Forecast
|
from homeassistant.components.weather import Forecast
|
||||||
|
|
||||||
from .rain_graph import RainGraph
|
from .irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
|
||||||
|
from .irm_kmi_api.rain_graph import RainGraph
|
||||||
|
|
||||||
class IrmKmiForecast(Forecast):
|
|
||||||
"""Forecast class with additional attributes for IRM KMI"""
|
|
||||||
|
|
||||||
# TODO: add condition_2 as well and evolution to match data from the API?
|
|
||||||
text: str | None
|
|
||||||
sunrise: str | None
|
|
||||||
sunset: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentWeatherData(TypedDict, total=False):
|
|
||||||
"""Class to hold the currently observable weather at a given location"""
|
|
||||||
condition: str | None
|
|
||||||
temperature: float | None
|
|
||||||
wind_speed: float | None
|
|
||||||
wind_gust_speed: float | None
|
|
||||||
wind_bearing: float | str | None
|
|
||||||
uv_index: float | None
|
|
||||||
pressure: float | None
|
|
||||||
|
|
||||||
|
|
||||||
class WarningData(TypedDict, total=False):
|
|
||||||
"""Holds data about a specific warning"""
|
|
||||||
slug: str
|
|
||||||
id: int
|
|
||||||
level: int
|
|
||||||
friendly_name: str
|
|
||||||
text: str
|
|
||||||
starts_at: datetime
|
|
||||||
ends_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedCoordinatorData(TypedDict, total=False):
|
class ProcessedCoordinatorData(TypedDict, total=False):
|
||||||
|
|
0
custom_components/irm_kmi/irm_kmi_api/__init__.py
Normal file
562
custom_components/irm_kmi/irm_kmi_api/api.py
Normal file
|
@ -0,0 +1,562 @@
|
||||||
|
"""API Client for IRM KMI weather"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from statistics import mean
|
||||||
|
from typing import List, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
|
from custom_components.irm_kmi.irm_kmi_api.const import WEEKDAYS, STYLE_TO_PARAM_MAP, OPTION_STYLE_SATELLITE, \
|
||||||
|
MAP_WARNING_ID_TO_SLUG as SLUG_MAP
|
||||||
|
from custom_components.irm_kmi.irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, Forecast, \
|
||||||
|
IrmKmiRadarForecast, RadarAnimationData, AnimationFrameData, WarningData
|
||||||
|
from custom_components.irm_kmi.irm_kmi_api.pollen import PollenParser
|
||||||
|
from custom_components.irm_kmi.utils import next_weekday
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiApiError(Exception):
|
||||||
|
"""Exception to indicate a general API error."""
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiApiCommunicationError(IrmKmiApiError):
|
||||||
|
"""Exception to indicate a communication error."""
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiApiParametersError(IrmKmiApiError):
|
||||||
|
"""Exception to indicate a parameter error."""
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key(method_name: str) -> str:
|
||||||
|
"""Get API key."""
|
||||||
|
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiApiClient:
|
||||||
|
"""API client for IRM KMI weather data"""
|
||||||
|
COORD_DECIMALS = 6
|
||||||
|
cache_max_age = 60 * 60 * 2 # Remove items from the cache if they have not been hit since 2 hours
|
||||||
|
cache = {}
|
||||||
|
|
||||||
|
def __init__(self, session: aiohttp.ClientSession, user_agent: str) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._base_url = "https://app.meteo.be/services/appv4/"
|
||||||
|
self._user_agent = user_agent
|
||||||
|
|
||||||
|
async def get_forecasts_coord(self, coord: dict) -> dict:
|
||||||
|
"""Get forecasts for given city."""
|
||||||
|
assert 'lat' in coord
|
||||||
|
assert 'long' in coord
|
||||||
|
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
||||||
|
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
||||||
|
|
||||||
|
response: bytes = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
||||||
|
response: dict = json.loads(response)
|
||||||
|
|
||||||
|
_LOGGER.debug(f"Observation for {response.get('cityName', '')}: {response.get('obs', '{}')}")
|
||||||
|
_LOGGER.debug(f"Full data: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get_image(self, url, params: dict | None = None) -> bytes:
|
||||||
|
"""Get the image at the specified url with the parameters"""
|
||||||
|
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||||
|
return r
|
||||||
|
|
||||||
|
async def get_svg(self, url, params: dict | None = None) -> str:
|
||||||
|
"""Get SVG as str at the specified url with the parameters"""
|
||||||
|
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||||
|
return r.decode()
|
||||||
|
|
||||||
|
async def _api_wrapper(
|
||||||
|
self,
|
||||||
|
params: dict,
|
||||||
|
base_url: str | None = None,
|
||||||
|
path: str = "",
|
||||||
|
method: str = "get",
|
||||||
|
data: dict | None = None,
|
||||||
|
headers: dict | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Get information from the API."""
|
||||||
|
url = f"{self._base_url if base_url is None else base_url}{path}"
|
||||||
|
|
||||||
|
if headers is None:
|
||||||
|
headers = {'User-Agent': self._user_agent}
|
||||||
|
else:
|
||||||
|
headers['User-Agent'] = self._user_agent
|
||||||
|
|
||||||
|
if url in self.cache:
|
||||||
|
headers['If-None-Match'] = self.cache[url]['etag']
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(60):
|
||||||
|
response = await self._session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=data,
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
if response.status == 304:
|
||||||
|
_LOGGER.debug(f"Cache hit for {url}")
|
||||||
|
self.cache[url]['timestamp'] = time.time()
|
||||||
|
return self.cache[url]['response']
|
||||||
|
|
||||||
|
if 'ETag' in response.headers:
|
||||||
|
_LOGGER.debug(f"Saving in cache {url}")
|
||||||
|
r = await response.read()
|
||||||
|
self.cache[url] = {'etag': response.headers['ETag'], 'response': r, 'timestamp': time.time()}
|
||||||
|
return r
|
||||||
|
|
||||||
|
return await response.read()
|
||||||
|
|
||||||
|
except asyncio.TimeoutError as exception:
|
||||||
|
raise IrmKmiApiCommunicationError("Timeout error fetching information") from exception
|
||||||
|
except (aiohttp.ClientError, socket.gaierror) as exception:
|
||||||
|
raise IrmKmiApiCommunicationError("Error fetching information") from exception
|
||||||
|
except Exception as exception: # pylint: disable=broad-except
|
||||||
|
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception
|
||||||
|
|
||||||
|
def expire_cache(self):
|
||||||
|
now = time.time()
|
||||||
|
keys_to_delete = set()
|
||||||
|
for key, value in self.cache.items():
|
||||||
|
if now - value['timestamp'] > self.cache_max_age:
|
||||||
|
keys_to_delete.add(key)
|
||||||
|
for key in keys_to_delete:
|
||||||
|
del self.cache[key]
|
||||||
|
_LOGGER.info(f"Expired {len(keys_to_delete)} elements from API cache")
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiApiClientHa(IrmKmiApiClient):
|
||||||
|
def __init__(self, session: aiohttp.ClientSession, user_agent: str, cdt_map: dict) -> None:
|
||||||
|
super().__init__(session, user_agent)
|
||||||
|
self._api_data = dict()
|
||||||
|
self._cdt_map = cdt_map
|
||||||
|
|
||||||
|
async def refresh_forecasts_coord(self, coord: dict) -> None:
|
||||||
|
self._api_data = await self.get_forecasts_coord(coord)
|
||||||
|
|
||||||
|
def get_city(self) -> str | None:
|
||||||
|
return self._api_data.get('cityName', None)
|
||||||
|
|
||||||
|
def get_country(self) -> str | None:
|
||||||
|
return self._api_data.get('country', None)
|
||||||
|
|
||||||
|
async def get_current_weather(self, tz: ZoneInfo) -> CurrentWeatherData:
|
||||||
|
"""Parse the API data to build a CurrentWeatherData."""
|
||||||
|
|
||||||
|
now_hourly = await self._get_now_hourly(tz)
|
||||||
|
uv_index = await self._get_uv_index()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pressure = float(now_hourly.get('pressure', None)) if now_hourly is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pressure = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wind_speed = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
wind_gust_speed = float(now_hourly.get('windPeakSpeedKm', None)) if now_hourly is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wind_gust_speed = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
temperature = float(self._api_data.get('obs', {}).get('temp'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
temperature = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
dir_cardinal = now_hourly.get('windDirectionText', {}).get('en') if now_hourly is not None else None
|
||||||
|
if dir_cardinal == 'VAR' or now_hourly is None:
|
||||||
|
wind_bearing = None
|
||||||
|
else:
|
||||||
|
wind_bearing = (float(now_hourly.get('windDirection')) + 180) % 360
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wind_bearing = None
|
||||||
|
|
||||||
|
current_weather = CurrentWeatherData(
|
||||||
|
condition=self._cdt_map.get(
|
||||||
|
(self._api_data.get('obs', {}).get('ww'), self._api_data.get('obs', {}).get('dayNight')), None),
|
||||||
|
temperature=temperature,
|
||||||
|
wind_speed=wind_speed,
|
||||||
|
wind_gust_speed=wind_gust_speed,
|
||||||
|
wind_bearing=wind_bearing,
|
||||||
|
pressure=pressure,
|
||||||
|
uv_index=uv_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._api_data.get('country', '') == 'NL':
|
||||||
|
current_weather['wind_speed'] = self._api_data.get('obs', {}).get('windSpeedKm')
|
||||||
|
if self._api_data.get('obs', {}).get('windDirectionText', {}).get('en') == 'VAR':
|
||||||
|
current_weather['wind_bearing'] = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
current_weather['wind_bearing'] = (float(
|
||||||
|
self._api_data.get('obs', {}).get('windDirection')) + 180) % 360
|
||||||
|
except ValueError:
|
||||||
|
current_weather['wind_bearing'] = None
|
||||||
|
|
||||||
|
# Since June 2024, the NL weather does not include the condition in the 'ww' key, so we get it from the current
|
||||||
|
# hourly forecast instead if it is missing
|
||||||
|
if current_weather['condition'] is None:
|
||||||
|
try:
|
||||||
|
current_weather['condition'] = self._cdt_map.get(
|
||||||
|
(int(now_hourly.get('ww')), now_hourly.get('dayNight')), None)
|
||||||
|
except (TypeError, ValueError, AttributeError):
|
||||||
|
current_weather['condition'] = None
|
||||||
|
|
||||||
|
return current_weather
|
||||||
|
|
||||||
|
async def _get_uv_index(self) -> float | None:
|
||||||
|
uv_index = None
|
||||||
|
module_data = self._api_data.get('module', None)
|
||||||
|
if not (module_data is None or not isinstance(module_data, list)):
|
||||||
|
for module in module_data:
|
||||||
|
if module.get('type', None) == 'uv':
|
||||||
|
uv_index = module.get('data', {}).get('levelValue')
|
||||||
|
return uv_index
|
||||||
|
|
||||||
|
async def _get_now_hourly(self, tz: ZoneInfo) -> dict | None:
|
||||||
|
now_hourly = None
|
||||||
|
hourly_forecast_data = self._api_data.get('for', {}).get('hourly')
|
||||||
|
now = datetime.now(tz)
|
||||||
|
if not (hourly_forecast_data is None
|
||||||
|
or not isinstance(hourly_forecast_data, list)
|
||||||
|
or len(hourly_forecast_data) == 0):
|
||||||
|
|
||||||
|
for current in hourly_forecast_data[:4]:
|
||||||
|
if now.strftime('%H') == current['hour']:
|
||||||
|
now_hourly = current
|
||||||
|
break
|
||||||
|
return now_hourly
|
||||||
|
|
||||||
|
async def get_daily_forecast(self, tz: ZoneInfo, lang: str) -> List[IrmKmiForecast] | None:
|
||||||
|
"""Parse data from the API to create a list of daily forecasts"""
|
||||||
|
data = self._api_data.get('for', {}).get('daily')
|
||||||
|
if data is None or not isinstance(data, list) or len(data) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
forecasts = list()
|
||||||
|
forecast_day = datetime.now(tz)
|
||||||
|
|
||||||
|
for (idx, f) in enumerate(data):
|
||||||
|
precipitation = None
|
||||||
|
if f.get('precipQuantity', None) is not None:
|
||||||
|
try:
|
||||||
|
precipitation = float(f.get('precipQuantity'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
native_wind_gust_speed = None
|
||||||
|
if f.get('wind', {}).get('peakSpeed') is not None:
|
||||||
|
try:
|
||||||
|
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
wind_bearing = None
|
||||||
|
if f.get('wind', {}).get('dirText', {}).get('en') != 'VAR':
|
||||||
|
try:
|
||||||
|
wind_bearing = (float(f.get('wind', {}).get('dir')) + 180) % 360
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_daytime = f.get('dayNight', None) == 'd'
|
||||||
|
|
||||||
|
day_name = f.get('dayName', {}).get('en', None)
|
||||||
|
timestamp = f.get('timestamp', None)
|
||||||
|
if timestamp is not None:
|
||||||
|
forecast_day = datetime.fromisoformat(timestamp)
|
||||||
|
elif day_name in WEEKDAYS:
|
||||||
|
forecast_day = next_weekday(forecast_day, WEEKDAYS.index(day_name))
|
||||||
|
elif day_name in ['Today', 'Tonight']:
|
||||||
|
forecast_day = datetime.now(tz)
|
||||||
|
elif day_name == 'Tomorrow':
|
||||||
|
forecast_day = datetime.now(tz) + timedelta(days=1)
|
||||||
|
|
||||||
|
sunrise_sec = f.get('dawnRiseSeconds', None)
|
||||||
|
if sunrise_sec is None:
|
||||||
|
sunrise_sec = f.get('sunRise', None)
|
||||||
|
sunrise = None
|
||||||
|
if sunrise_sec is not None:
|
||||||
|
try:
|
||||||
|
sunrise = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
|
||||||
|
+ timedelta(seconds=float(sunrise_sec)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sunset_sec = f.get('dawnSetSeconds', None)
|
||||||
|
if sunset_sec is None:
|
||||||
|
sunset_sec = f.get('sunSet', None)
|
||||||
|
sunset = None
|
||||||
|
if sunset_sec is not None:
|
||||||
|
try:
|
||||||
|
sunset = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
|
||||||
|
+ timedelta(seconds=float(sunset_sec)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
forecast = IrmKmiForecast(
|
||||||
|
datetime=(forecast_day.strftime('%Y-%m-%d')),
|
||||||
|
condition=self._cdt_map.get((f.get('ww1', None), f.get('dayNight', None)), None),
|
||||||
|
native_precipitation=precipitation,
|
||||||
|
native_temperature=f.get('tempMax', None),
|
||||||
|
native_templow=f.get('tempMin', None),
|
||||||
|
native_wind_gust_speed=native_wind_gust_speed,
|
||||||
|
native_wind_speed=f.get('wind', {}).get('speed'),
|
||||||
|
precipitation_probability=f.get('precipChance', None),
|
||||||
|
wind_bearing=wind_bearing,
|
||||||
|
is_daytime=is_daytime,
|
||||||
|
text=f.get('text', {}).get(lang, ""),
|
||||||
|
sunrise=sunrise.isoformat() if sunrise is not None else None,
|
||||||
|
sunset=sunset.isoformat() if sunset is not None else None
|
||||||
|
)
|
||||||
|
# Swap temperature and templow if needed
|
||||||
|
if (forecast['native_templow'] is not None
|
||||||
|
and forecast['native_temperature'] is not None
|
||||||
|
and forecast['native_templow'] > forecast['native_temperature']):
|
||||||
|
(forecast['native_templow'], forecast['native_temperature']) = \
|
||||||
|
(forecast['native_temperature'], forecast['native_templow'])
|
||||||
|
|
||||||
|
forecasts.append(forecast)
|
||||||
|
|
||||||
|
return forecasts
|
||||||
|
|
||||||
|
async def get_hourly_forecast(self, tz: ZoneInfo) -> List[Forecast] | None:
|
||||||
|
"""Parse data from the API to create a list of hourly forecasts"""
|
||||||
|
data = self._api_data.get('for', {}).get('hourly')
|
||||||
|
|
||||||
|
if data is None or not isinstance(data, list) or len(data) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
forecasts = list()
|
||||||
|
day = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
for idx, f in enumerate(data):
|
||||||
|
if 'dateShow' in f and idx > 0:
|
||||||
|
day = day + timedelta(days=1)
|
||||||
|
|
||||||
|
hour = f.get('hour', None)
|
||||||
|
if hour is None:
|
||||||
|
continue
|
||||||
|
day = day.replace(hour=int(hour))
|
||||||
|
|
||||||
|
precipitation_probability = None
|
||||||
|
if f.get('precipChance', None) is not None:
|
||||||
|
precipitation_probability = int(f.get('precipChance'))
|
||||||
|
|
||||||
|
ww = None
|
||||||
|
if f.get('ww', None) is not None:
|
||||||
|
ww = int(f.get('ww'))
|
||||||
|
|
||||||
|
wind_bearing = None
|
||||||
|
if f.get('windDirectionText', {}).get('en') != 'VAR':
|
||||||
|
try:
|
||||||
|
wind_bearing = (float(f.get('windDirection')) + 180) % 360
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
forecast = Forecast(
|
||||||
|
datetime=day.isoformat(),
|
||||||
|
condition=self._cdt_map.get((ww, f.get('dayNight', None)), None),
|
||||||
|
native_precipitation=f.get('precipQuantity', None),
|
||||||
|
native_temperature=f.get('temp', None),
|
||||||
|
native_templow=None,
|
||||||
|
native_wind_gust_speed=f.get('windPeakSpeedKm', None),
|
||||||
|
native_wind_speed=f.get('windSpeedKm', None),
|
||||||
|
precipitation_probability=precipitation_probability,
|
||||||
|
wind_bearing=wind_bearing,
|
||||||
|
native_pressure=f.get('pressure', None),
|
||||||
|
is_daytime=f.get('dayNight', None) == 'd'
|
||||||
|
)
|
||||||
|
|
||||||
|
forecasts.append(forecast)
|
||||||
|
|
||||||
|
return forecasts
|
||||||
|
|
||||||
|
def get_radar_forecast(self) -> List[IrmKmiRadarForecast] | None:
|
||||||
|
"""Create a list of short term forecasts for rain based on the data provided by the rain radar"""
|
||||||
|
data = self._api_data.get('animation', {})
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
sequence = data.get("sequence", [])
|
||||||
|
unit = data.get("unit", {}).get("en", None)
|
||||||
|
ratios = [f['value'] / f['position'] for f in sequence if f['position'] > 0]
|
||||||
|
|
||||||
|
if len(ratios) > 0:
|
||||||
|
ratio = mean(ratios)
|
||||||
|
else:
|
||||||
|
ratio = 0
|
||||||
|
|
||||||
|
forecast = list()
|
||||||
|
for f in sequence:
|
||||||
|
forecast.append(
|
||||||
|
IrmKmiRadarForecast(
|
||||||
|
datetime=f.get("time"),
|
||||||
|
native_precipitation=f.get('value'),
|
||||||
|
rain_forecast_max=round(f.get('positionHigher') * ratio, 2),
|
||||||
|
rain_forecast_min=round(f.get('positionLower') * ratio, 2),
|
||||||
|
might_rain=f.get('positionHigher') > 0,
|
||||||
|
unit=unit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return forecast
|
||||||
|
|
||||||
|
async def get_animation_data(self, tz: ZoneInfo, lang: str, style: str, dark_mode: bool) -> (RadarAnimationData,
|
||||||
|
str, Tuple[int, int]):
|
||||||
|
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
|
||||||
|
Frames from the API are merged with the background map and the location marker to create each frame."""
|
||||||
|
animation_data = self._api_data.get('animation', {}).get('sequence')
|
||||||
|
localisation_layer_url = self._api_data.get('animation', {}).get('localisationLayer')
|
||||||
|
country = self.get_country()
|
||||||
|
|
||||||
|
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
||||||
|
raise ValueError("Cannot create animation data")
|
||||||
|
|
||||||
|
localisation = self.merge_url_and_params(localisation_layer_url,
|
||||||
|
{'th': 'd' if country == 'NL' or not dark_mode else 'n'})
|
||||||
|
images_from_api = [self.merge_url_and_params(frame.get('uri'), {'rs': STYLE_TO_PARAM_MAP[style]})
|
||||||
|
for frame in animation_data if frame is not None and frame.get('uri') is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
radar_animation = RadarAnimationData(
|
||||||
|
hint=self._api_data.get('animation', {}).get('sequenceHint', {}).get(lang),
|
||||||
|
unit=self._api_data.get('animation', {}).get('unit', {}).get(lang),
|
||||||
|
location=localisation
|
||||||
|
)
|
||||||
|
|
||||||
|
r = self._get_rain_graph_data(
|
||||||
|
radar_animation,
|
||||||
|
animation_data,
|
||||||
|
country,
|
||||||
|
images_from_api,
|
||||||
|
tz,
|
||||||
|
style,
|
||||||
|
dark_mode)
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_warnings(self, lang: str) -> List[WarningData]:
|
||||||
|
"""Create a list of warning data instances based on the api data"""
|
||||||
|
warning_data = self._api_data.get('for', {}).get('warning')
|
||||||
|
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = list()
|
||||||
|
for data in warning_data:
|
||||||
|
try:
|
||||||
|
warning_id = int(data.get('warningType', {}).get('id'))
|
||||||
|
start = datetime.fromisoformat(data.get('fromTimestamp', None))
|
||||||
|
end = datetime.fromisoformat(data.get('toTimestamp', None))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Without this data, the warning is useless
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
level = int(data.get('warningLevel'))
|
||||||
|
except TypeError:
|
||||||
|
level = None
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
WarningData(
|
||||||
|
slug=SLUG_MAP.get(warning_id, 'unknown'),
|
||||||
|
id=warning_id,
|
||||||
|
level=level,
|
||||||
|
friendly_name=data.get('warningType', {}).get('name', {}).get(lang, ''),
|
||||||
|
text=data.get('text', {}).get(lang, ''),
|
||||||
|
starts_at=start,
|
||||||
|
ends_at=end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result if len(result) > 0 else []
|
||||||
|
|
||||||
|
async def get_pollen(self) -> dict:
|
||||||
|
"""Get SVG pollen info from the API, return the pollen data dict"""
|
||||||
|
_LOGGER.debug("Getting pollen data from API")
|
||||||
|
svg_url = None
|
||||||
|
for module in self._api_data.get('module', []):
|
||||||
|
_LOGGER.debug(f"module: {module}")
|
||||||
|
if module.get('type', None) == 'svg':
|
||||||
|
url = module.get('data', {}).get('url', {}).get('en', '')
|
||||||
|
if 'pollen' in url:
|
||||||
|
svg_url = url
|
||||||
|
break
|
||||||
|
if svg_url is None:
|
||||||
|
return PollenParser.get_default_data()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug(f"Requesting pollen SVG at url {svg_url}")
|
||||||
|
pollen_svg: str = await self.get_svg(svg_url)
|
||||||
|
except IrmKmiApiError as err:
|
||||||
|
raise err
|
||||||
|
|
||||||
|
return PollenParser(pollen_svg).get_pollen_data()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def merge_url_and_params(url, params):
|
||||||
|
parsed_url = urllib.parse.urlparse(url)
|
||||||
|
query_params = urllib.parse.parse_qs(parsed_url.query)
|
||||||
|
query_params.update(params)
|
||||||
|
new_query = urllib.parse.urlencode(query_params, doseq=True)
|
||||||
|
new_url = parsed_url._replace(query=new_query)
|
||||||
|
return str(urllib.parse.urlunparse(new_url))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_rain_graph_data(radar_animation: RadarAnimationData,
|
||||||
|
api_animation_data: List[dict],
|
||||||
|
country: str | None,
|
||||||
|
images_from_api: list[str],
|
||||||
|
tz: ZoneInfo,
|
||||||
|
style: str,
|
||||||
|
dark_mode: bool
|
||||||
|
) -> (RadarAnimationData, str, Tuple[int, int]):
|
||||||
|
"""Create a RainGraph object that is ready to output animated and still SVG images"""
|
||||||
|
sequence: List[AnimationFrameData] = list()
|
||||||
|
|
||||||
|
current_time = datetime.now(tz)
|
||||||
|
most_recent_frame = None
|
||||||
|
|
||||||
|
for idx, item in enumerate(api_animation_data):
|
||||||
|
frame = AnimationFrameData(
|
||||||
|
image=images_from_api[idx],
|
||||||
|
time=datetime.fromisoformat(item.get('time')) if item.get('time', None) is not None else None,
|
||||||
|
value=item.get('value', 0),
|
||||||
|
position=item.get('position', 0),
|
||||||
|
position_lower=item.get('positionLower', 0),
|
||||||
|
position_higher=item.get('positionHigher', 0)
|
||||||
|
)
|
||||||
|
sequence.append(frame)
|
||||||
|
|
||||||
|
if most_recent_frame is None and current_time < frame['time']:
|
||||||
|
most_recent_frame = idx - 1 if idx > 0 else idx
|
||||||
|
|
||||||
|
radar_animation['sequence'] = sequence
|
||||||
|
radar_animation['most_recent_image_idx'] = most_recent_frame
|
||||||
|
|
||||||
|
satellite_mode = style == OPTION_STYLE_SATELLITE
|
||||||
|
|
||||||
|
if country == 'NL':
|
||||||
|
image_path = "custom_components/irm_kmi/resources/nl.png"
|
||||||
|
bg_size = (640, 600)
|
||||||
|
else:
|
||||||
|
image_path = (f"custom_components/irm_kmi/resources/be_"
|
||||||
|
f"{'satellite' if satellite_mode else 'black' if dark_mode else 'white'}.png")
|
||||||
|
bg_size = (640, 490)
|
||||||
|
|
||||||
|
return radar_animation, image_path, bg_size
|
28
custom_components/irm_kmi/irm_kmi_api/const.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', 'Oak'}
|
||||||
|
POLLEN_LEVEL_TO_COLOR = {'null': 'green', 'low': 'yellow', 'moderate': 'orange', 'high': 'red', 'very high': 'purple',
|
||||||
|
'active': 'active'}
|
||||||
|
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||||
|
OPTION_STYLE_STD: Final = 'standard_style'
|
||||||
|
OPTION_STYLE_CONTRAST: Final = 'contrast_style'
|
||||||
|
OPTION_STYLE_YELLOW_RED: Final = 'yellow_red_style'
|
||||||
|
OPTION_STYLE_SATELLITE: Final = 'satellite_style'
|
||||||
|
STYLE_TO_PARAM_MAP: Final = {
|
||||||
|
OPTION_STYLE_STD: 1,
|
||||||
|
OPTION_STYLE_CONTRAST: 2,
|
||||||
|
OPTION_STYLE_YELLOW_RED: 3,
|
||||||
|
OPTION_STYLE_SATELLITE: 4
|
||||||
|
}
|
||||||
|
MAP_WARNING_ID_TO_SLUG: Final = {
|
||||||
|
0: 'wind',
|
||||||
|
1: 'rain',
|
||||||
|
2: 'ice_or_snow',
|
||||||
|
3: 'thunder',
|
||||||
|
7: 'fog',
|
||||||
|
9: 'cold',
|
||||||
|
12: 'thunder_wind_rain',
|
||||||
|
13: 'thunderstorm_strong_gusts',
|
||||||
|
14: 'thunderstorm_large_rainfall',
|
||||||
|
15: 'storm_surge',
|
||||||
|
17: 'coldspell'}
|
95
custom_components/irm_kmi/irm_kmi_api/data.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
"""Data classes for IRM KMI integration"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TypedDict, Required, List
|
||||||
|
|
||||||
|
|
||||||
|
class Forecast(TypedDict, total=False):
|
||||||
|
"""Typed weather forecast dict.
|
||||||
|
|
||||||
|
All attributes are in native units and old attributes kept
|
||||||
|
for backwards compatibility.
|
||||||
|
|
||||||
|
Data from Home Assistant to avoid to depend on Home Assistant for this
|
||||||
|
"""
|
||||||
|
|
||||||
|
condition: str | None
|
||||||
|
datetime: Required[str]
|
||||||
|
humidity: float | None
|
||||||
|
precipitation_probability: int | None
|
||||||
|
cloud_coverage: int | None
|
||||||
|
native_precipitation: float | None
|
||||||
|
precipitation: None
|
||||||
|
native_pressure: float | None
|
||||||
|
pressure: None
|
||||||
|
native_temperature: float | None
|
||||||
|
temperature: None
|
||||||
|
native_templow: float | None
|
||||||
|
templow: None
|
||||||
|
native_apparent_temperature: float | None
|
||||||
|
wind_bearing: float | str | None
|
||||||
|
native_wind_gust_speed: float | None
|
||||||
|
native_wind_speed: float | None
|
||||||
|
wind_speed: None
|
||||||
|
native_dew_point: float | None
|
||||||
|
uv_index: float | None
|
||||||
|
is_daytime: bool | None # Mandatory to use with forecast_twice_daily
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiForecast(Forecast):
|
||||||
|
"""Forecast class with additional attributes for IRM KMI"""
|
||||||
|
|
||||||
|
# TODO: add condition_2 as well and evolution to match data from the API?
|
||||||
|
text: str | None
|
||||||
|
sunrise: str | None
|
||||||
|
sunset: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentWeatherData(TypedDict, total=False):
|
||||||
|
"""Class to hold the currently observable weather at a given location"""
|
||||||
|
condition: str | None
|
||||||
|
temperature: float | None
|
||||||
|
wind_speed: float | None
|
||||||
|
wind_gust_speed: float | None
|
||||||
|
wind_bearing: float | str | None
|
||||||
|
uv_index: float | None
|
||||||
|
pressure: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class WarningData(TypedDict, total=False):
|
||||||
|
"""Holds data about a specific warning"""
|
||||||
|
slug: str
|
||||||
|
id: int
|
||||||
|
level: int
|
||||||
|
friendly_name: str
|
||||||
|
text: str
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiRadarForecast(Forecast):
|
||||||
|
"""Forecast class to handle rain forecast from the IRM KMI rain radar"""
|
||||||
|
rain_forecast_max: float
|
||||||
|
rain_forecast_min: float
|
||||||
|
might_rain: bool
|
||||||
|
unit: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimationFrameData(TypedDict, total=False):
|
||||||
|
"""Holds one single frame of the radar camera, along with the timestamp of the frame"""
|
||||||
|
time: datetime | None
|
||||||
|
image: bytes | str | None
|
||||||
|
value: float | None
|
||||||
|
position: float | None
|
||||||
|
position_higher: float | None
|
||||||
|
position_lower: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class RadarAnimationData(TypedDict, total=False):
|
||||||
|
"""Holds frames and additional data for the animation to be rendered"""
|
||||||
|
sequence: List[AnimationFrameData] | None
|
||||||
|
most_recent_image_idx: int | None
|
||||||
|
hint: str | None
|
||||||
|
unit: str | None
|
||||||
|
location: bytes | str | None
|
||||||
|
svg_still: bytes | None
|
||||||
|
svg_animated: bytes | None
|
2
custom_components/irm_kmi/irm_kmi_api/ha.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from .const import POLLEN_LEVEL_TO_COLOR, POLLEN_NAMES
|
from .const import POLLEN_NAMES, POLLEN_LEVEL_TO_COLOR
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
|
@ -13,8 +13,8 @@ from svgwrite.animate import Animate
|
||||||
from svgwrite.container import FONT_TEMPLATE
|
from svgwrite.container import FONT_TEMPLATE
|
||||||
|
|
||||||
from .api import IrmKmiApiClient
|
from .api import IrmKmiApiClient
|
||||||
from .radar_data import AnimationFrameData, RadarAnimationData
|
from .data import AnimationFrameData, RadarAnimationData
|
||||||
from custom_components.irm_kmi.resources import roboto, be_black, be_satellite, be_white, nl
|
from .resources import be_black, be_satellite, be_white, nl, roboto
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ class RainGraph:
|
||||||
animation_data: RadarAnimationData,
|
animation_data: RadarAnimationData,
|
||||||
background_image_path: str,
|
background_image_path: str,
|
||||||
background_size: (int, int),
|
background_size: (int, int),
|
||||||
config_dir: str = '.',
|
config_dir: str = '.', # TODO remove ununsed
|
||||||
dark_mode: bool = False,
|
dark_mode: bool = False,
|
||||||
tz: datetime.tzinfo = dt.get_default_time_zone(),
|
tz: datetime.tzinfo = dt.get_default_time_zone(),
|
||||||
svg_width: float = 640,
|
svg_width: float = 640,
|
||||||
|
@ -40,7 +40,6 @@ class RainGraph:
|
||||||
self._animation_data: RadarAnimationData = animation_data
|
self._animation_data: RadarAnimationData = animation_data
|
||||||
self._background_image_path: str = background_image_path
|
self._background_image_path: str = background_image_path
|
||||||
self._background_size: (int, int) = background_size
|
self._background_size: (int, int) = background_size
|
||||||
self._config_dir: str = config_dir
|
|
||||||
self._dark_mode: bool = dark_mode
|
self._dark_mode: bool = dark_mode
|
||||||
self._tz = tz
|
self._tz = tz
|
||||||
self._svg_width: float = svg_width
|
self._svg_width: float = svg_width
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 666 KiB After Width: | Height: | Size: 666 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
@ -1,35 +0,0 @@
|
||||||
"""Data classes related to radar forecast for IRM KMI integration"""
|
|
||||||
# This file was needed to avoid circular import with rain_graph.py and data.py
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TypedDict, List
|
|
||||||
|
|
||||||
from homeassistant.components.weather import Forecast
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiRadarForecast(Forecast):
|
|
||||||
"""Forecast class to handle rain forecast from the IRM KMI rain radar"""
|
|
||||||
rain_forecast_max: float
|
|
||||||
rain_forecast_min: float
|
|
||||||
might_rain: bool
|
|
||||||
unit: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class AnimationFrameData(TypedDict, total=False):
|
|
||||||
"""Holds one single frame of the radar camera, along with the timestamp of the frame"""
|
|
||||||
time: datetime | None
|
|
||||||
image: bytes | str | None
|
|
||||||
value: float | None
|
|
||||||
position: float | None
|
|
||||||
position_higher: float | None
|
|
||||||
position_lower: float | None
|
|
||||||
|
|
||||||
|
|
||||||
class RadarAnimationData(TypedDict, total=False):
|
|
||||||
"""Holds frames and additional data for the animation to be rendered"""
|
|
||||||
sequence: List[AnimationFrameData] | None
|
|
||||||
most_recent_image_idx: int | None
|
|
||||||
hint: str | None
|
|
||||||
unit: str | None
|
|
||||||
location: bytes | str | None
|
|
||||||
svg_still: bytes | None
|
|
||||||
svg_animated: bytes | None
|
|
|
@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||||
|
|
||||||
from custom_components.irm_kmi import async_reload_entry
|
from . import async_reload_entry
|
||||||
from custom_components.irm_kmi.api import IrmKmiApiClient
|
from .const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE,
|
||||||
from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE,
|
|
||||||
REPAIR_OPT_MOVE, REPAIR_OPTIONS,
|
REPAIR_OPT_MOVE, REPAIR_OPTIONS,
|
||||||
REPAIR_SOLUTION)
|
REPAIR_SOLUTION, USER_AGENT)
|
||||||
from custom_components.irm_kmi.utils import modify_from_config
|
from .irm_kmi_api.api import IrmKmiApiClient
|
||||||
|
from .utils import modify_from_config
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -50,7 +50,9 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
|
||||||
try:
|
try:
|
||||||
async with async_timeout.timeout(10):
|
async with async_timeout.timeout(10):
|
||||||
api_data = await IrmKmiApiClient(
|
api_data = await IrmKmiApiClient(
|
||||||
session=async_get_clientsession(self.hass)).get_forecasts_coord(
|
session=async_get_clientsession(self.hass),
|
||||||
|
user_agent=USER_AGENT
|
||||||
|
).get_forecasts_coord(
|
||||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
)
|
)
|
||||||
|
@ -84,8 +86,8 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
|
||||||
|
|
||||||
|
|
||||||
async def async_create_fix_flow(
|
async def async_create_fix_flow(
|
||||||
hass: HomeAssistant,
|
_hass: HomeAssistant,
|
||||||
issue_id: str,
|
_issue_id: str,
|
||||||
data: dict[str, str | int | float | None] | None,
|
data: dict[str, str | int | float | None] | None,
|
||||||
) -> OutOfBeneluxRepairFlow:
|
) -> OutOfBeneluxRepairFlow:
|
||||||
"""Create flow."""
|
"""Create flow."""
|
||||||
|
|
|
@ -10,12 +10,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
from homeassistant.util import dt
|
from homeassistant.util import dt
|
||||||
|
|
||||||
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
from . import DOMAIN, IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.const import POLLEN_NAMES, POLLEN_TO_ICON_MAP, CURRENT_WEATHER_SENSOR_UNITS, \
|
from .const import POLLEN_TO_ICON_MAP, CURRENT_WEATHER_SENSOR_UNITS, \
|
||||||
CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_ICON
|
CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_ICON
|
||||||
from custom_components.irm_kmi.data import IrmKmiForecast
|
from .irm_kmi_api.const import POLLEN_NAMES
|
||||||
from custom_components.irm_kmi.pollen import PollenParser
|
from .irm_kmi_api.data import IrmKmiForecast, IrmKmiRadarForecast
|
||||||
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
|
from .irm_kmi_api.pollen import PollenParser
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Generator
|
from typing import Generator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -10,32 +10,22 @@ from homeassistant.const import CONF_ZONE
|
||||||
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||||
load_fixture)
|
load_fixture)
|
||||||
|
|
||||||
from custom_components.irm_kmi.api import (IrmKmiApiError,
|
from custom_components.irm_kmi import OPTION_STYLE_STD
|
||||||
IrmKmiApiParametersError)
|
from custom_components.irm_kmi.const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
from custom_components.irm_kmi.const import (
|
|
||||||
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
|
||||||
CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
OPTION_DEPRECATED_FORECAST_TWICE_DAILY, OPTION_STYLE_STD)
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY, IRM_KMI_TO_HA_CONDITION_MAP)
|
||||||
|
from custom_components.irm_kmi.irm_kmi_api.api import (IrmKmiApiError,
|
||||||
|
IrmKmiApiParametersError, IrmKmiApiClientHa)
|
||||||
|
|
||||||
|
|
||||||
def get_api_data(fixture: str) -> dict:
|
def get_api_data(fixture: str) -> dict:
|
||||||
return json.loads(load_fixture(fixture))
|
return json.loads(load_fixture(fixture))
|
||||||
|
|
||||||
|
|
||||||
async def patched(url: str, params: dict | None = None) -> bytes:
|
def get_api_with_data(fixture: str) -> IrmKmiApiClientHa:
|
||||||
if "cdn.knmi.nl" in url:
|
api = IrmKmiApiClientHa(session=MagicMock(), user_agent='', cdt_map=IRM_KMI_TO_HA_CONDITION_MAP)
|
||||||
file_name = "tests/fixtures/clouds_nl.png"
|
api._api_data = get_api_data(fixture)
|
||||||
elif "app.meteo.be/services/appv4/?s=getIncaImage" in url:
|
return api
|
||||||
file_name = "tests/fixtures/clouds_be.png"
|
|
||||||
elif "getLocalizationLayerBE" in url:
|
|
||||||
file_name = "tests/fixtures/loc_layer_be_n.png"
|
|
||||||
elif "getLocalizationLayerNL" in url:
|
|
||||||
file_name = "tests/fixtures/loc_layer_nl.png"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Not a valid parameter for the mock: {url}")
|
|
||||||
|
|
||||||
with open(file_name, "rb") as file:
|
|
||||||
return file.read()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
@ -121,21 +111,7 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
forecast = json.loads(load_fixture(fixture))
|
||||||
with patch(
|
with patch(
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_irm_kmi_api_coordinator_out_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "forecast_out_of_benelux.json"
|
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
) as irm_kmi_api_mock:
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
irm_kmi.get_forecasts_coord.return_value = forecast
|
||||||
|
@ -174,111 +150,9 @@ def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Ge
|
||||||
def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||||
"""Return a mocked IrmKmi api client."""
|
"""Return a mocked IrmKmi api client."""
|
||||||
with patch(
|
with patch(
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||||
) as irm_kmi_api_mock:
|
) as irm_kmi_api_mock:
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
irm_kmi.get_forecasts_coord.side_effect = IrmKmiApiParametersError
|
irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiParametersError
|
||||||
yield irm_kmi
|
yield irm_kmi
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_image_and_nl_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "forecast_nl.json"
|
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_image.side_effect = patched
|
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_image_and_high_temp_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "high_low_temp.json"
|
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_image.side_effect = patched
|
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_image_and_simple_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "forecast.json"
|
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_image.side_effect = patched
|
|
||||||
irm_kmi.get_svg.return_value = ""
|
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "pollen.svg"
|
|
||||||
|
|
||||||
svg_str = load_fixture(fixture)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_svg.return_value = svg_str
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_exception_irm_kmi_api_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_svg.side_effect = IrmKmiApiParametersError
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_coordinator(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked coordinator."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.IrmKmiCoordinator", autospec=True
|
|
||||||
) as coordinator_mock:
|
|
||||||
coord = coordinator_mock.return_value
|
|
||||||
coord._async_animation_data.return_value = {'animation': None}
|
|
||||||
yield coord
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_irm_kmi_api_works_but_pollen_and_radar_fail(request: pytest.FixtureRequest) -> Generator[
|
|
||||||
None, MagicMock, None]:
|
|
||||||
"""Return a mocked IrmKmi api client."""
|
|
||||||
fixture: str = "forecast.json"
|
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
|
||||||
irm_kmi.get_svg.side_effect = IrmKmiApiError
|
|
||||||
irm_kmi.get_image.side_effect = IrmKmiApiError
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
|
@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi import async_migrate_entry
|
from custom_components.irm_kmi import async_migrate_entry, OPTION_STYLE_STD
|
||||||
from custom_components.irm_kmi.const import (
|
from custom_components.irm_kmi.const import (
|
||||||
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_SATELLITE,
|
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||||
OPTION_STYLE_STD)
|
from custom_components.irm_kmi.irm_kmi_api.const import OPTION_STYLE_SATELLITE
|
||||||
|
|
||||||
|
|
||||||
async def test_full_user_flow(
|
async def test_full_user_flow(
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
|
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
|
||||||
|
@ -9,11 +10,11 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
||||||
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
|
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.data import (CurrentWeatherData, IrmKmiForecast,
|
from custom_components.irm_kmi.irm_kmi_api.api import IrmKmiApiClientHa
|
||||||
ProcessedCoordinatorData)
|
from custom_components.irm_kmi.irm_kmi_api.data import (CurrentWeatherData, IrmKmiForecast, IrmKmiRadarForecast)
|
||||||
from custom_components.irm_kmi.pollen import PollenParser
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
|
from custom_components.irm_kmi.irm_kmi_api.pollen import PollenParser
|
||||||
from tests.conftest import get_api_data
|
from tests.conftest import get_api_data, get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||||
|
@ -27,19 +28,16 @@ async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-01-12T07:10:00+00:00'))
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:10:00+00:00'))
|
||||||
async def test_warning_data(
|
async def test_warning_data(
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
result = api.get_warnings(lang='en')
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
first = result[0]
|
first = result[0]
|
||||||
|
|
||||||
assert first.get('starts_at').replace(tzinfo=None) < datetime.now()
|
assert first.get('starts_at').replace(tzinfo=None) < datetime.now()
|
||||||
assert first.get('ends_at').replace(tzinfo=None) > datetime.now()
|
assert first.get('ends_at').replace(tzinfo=None) > datetime.now()
|
||||||
|
|
||||||
|
@ -51,8 +49,9 @@ async def test_warning_data(
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T17:30:00+00:00'))
|
@freeze_time(datetime.fromisoformat('2023-12-26T17:30:00+00:00'))
|
||||||
async def test_current_weather_be() -> None:
|
async def test_current_weather_be() -> None:
|
||||||
api_data = get_api_data("forecast.json")
|
api = get_api_with_data("forecast.json")
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
result = await api.get_current_weather(tz)
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
expected = CurrentWeatherData(
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
condition=ATTR_CONDITION_CLOUDY,
|
||||||
|
@ -69,8 +68,9 @@ async def test_current_weather_be() -> None:
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00"))
|
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00"))
|
||||||
async def test_current_weather_nl() -> None:
|
async def test_current_weather_nl() -> None:
|
||||||
api_data = get_api_data("forecast_nl.json")
|
api = get_api_with_data("forecast_nl.json")
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
result = await api.get_current_weather(tz)
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
expected = CurrentWeatherData(
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
condition=ATTR_CONDITION_CLOUDY,
|
||||||
|
@ -90,11 +90,10 @@ async def test_daily_forecast(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
|
api = get_api_with_data("forecast.json")
|
||||||
await hass.config_entries.async_add(mock_config_entry)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'fr'})
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
result = await api.get_daily_forecast(tz, 'fr')
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 8
|
assert len(result) == 8
|
||||||
|
@ -121,8 +120,9 @@ async def test_daily_forecast(
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00'))
|
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00'))
|
||||||
async def test_hourly_forecast() -> None:
|
async def test_hourly_forecast() -> None:
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('hourly')
|
api = get_api_with_data("forecast.json")
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
result = await api.get_hourly_forecast(tz)
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert len(result) == 49
|
assert len(result) == 49
|
||||||
|
@ -146,8 +146,10 @@ async def test_hourly_forecast() -> None:
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-05-31T01:50:00+02:00'))
|
@freeze_time(datetime.fromisoformat('2024-05-31T01:50:00+02:00'))
|
||||||
async def test_hourly_forecast_bis() -> None:
|
async def test_hourly_forecast_bis() -> None:
|
||||||
api_data = get_api_data("no-midnight-bug-31-05-2024T01-55.json").get('for', {}).get('hourly')
|
api = get_api_with_data("no-midnight-bug-31-05-2024T01-55.json")
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
|
result = await api.get_hourly_forecast(tz)
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
@ -163,8 +165,10 @@ async def test_hourly_forecast_bis() -> None:
|
||||||
@freeze_time(datetime.fromisoformat('2024-05-31T00:10:00+02:00'))
|
@freeze_time(datetime.fromisoformat('2024-05-31T00:10:00+02:00'))
|
||||||
async def test_hourly_forecast_midnight_bug() -> None:
|
async def test_hourly_forecast_midnight_bug() -> None:
|
||||||
# Related to https://github.com/jdejaegh/irm-kmi-ha/issues/38
|
# Related to https://github.com/jdejaegh/irm-kmi-ha/issues/38
|
||||||
api_data = get_api_data("midnight-bug-31-05-2024T00-13.json").get('for', {}).get('hourly')
|
api = get_api_with_data("midnight-bug-31-05-2024T00-13.json")
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
|
result = await api.get_hourly_forecast(tz)
|
||||||
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
@ -200,10 +204,10 @@ async def test_daily_forecast_midnight_bug(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
api = get_api_with_data("midnight-bug-31-05-2024T00-13.json")
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
api_data = get_api_data("midnight-bug-31-05-2024T00-13.json").get('for', {}).get('daily')
|
result = await api.get_daily_forecast(tz, 'en')
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['datetime'] == '2024-05-31'
|
assert result[0]['datetime'] == '2024-05-31'
|
||||||
assert not result[0]['is_daytime']
|
assert not result[0]['is_daytime']
|
||||||
|
@ -221,19 +225,11 @@ async def test_daily_forecast_midnight_bug(
|
||||||
async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_irm_kmi_api_works_but_pollen_and_radar_fail
|
|
||||||
):
|
):
|
||||||
hass.states.async_set(
|
|
||||||
"zone.home",
|
|
||||||
0,
|
|
||||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
|
||||||
)
|
|
||||||
hass.config.config_dir = "."
|
|
||||||
mock_config_entry.add_to_hass(hass)
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
coordinator._api._api_data = get_api_data("forecast.json")
|
||||||
|
|
||||||
result = await coordinator._async_update_data()
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||||
|
|
||||||
|
@ -250,7 +246,7 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
||||||
pollen={'foo': 'bar'}
|
pollen={'foo': 'bar'}
|
||||||
)
|
)
|
||||||
coordinator.data = existing_data
|
coordinator.data = existing_data
|
||||||
result = await coordinator._async_update_data()
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||||
|
|
||||||
|
@ -260,8 +256,8 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
||||||
|
|
||||||
|
|
||||||
def test_radar_forecast() -> None:
|
def test_radar_forecast() -> None:
|
||||||
api_data = get_api_data("forecast.json")
|
api = get_api_with_data("forecast.json")
|
||||||
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
result = api.get_radar_forecast()
|
||||||
|
|
||||||
expected = [
|
expected = [
|
||||||
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
@ -292,8 +288,8 @@ def test_radar_forecast() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_radar_forecast_rain_interval() -> None:
|
def test_radar_forecast_rain_interval() -> None:
|
||||||
api_data = get_api_data('forecast_with_rain_on_radar.json')
|
api = get_api_with_data('forecast_with_rain_on_radar.json')
|
||||||
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
result = api.get_radar_forecast()
|
||||||
|
|
||||||
_12 = IrmKmiRadarForecast(
|
_12 = IrmKmiRadarForecast(
|
||||||
datetime='2024-05-30T18:00:00+02:00',
|
datetime='2024-05-30T18:00:00+02:00',
|
||||||
|
@ -322,10 +318,10 @@ async def test_datetime_daily_forecast_nl(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json").get('for', {}).get('daily')
|
api = get_api_with_data("forecast_ams_no_ww.json")
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
result = await api.get_daily_forecast(tz, 'en')
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['datetime'] == '2024-06-09'
|
assert result[0]['datetime'] == '2024-06-09'
|
||||||
assert result[0]['is_daytime']
|
assert result[0]['is_daytime']
|
||||||
|
@ -339,8 +335,10 @@ async def test_datetime_daily_forecast_nl(
|
||||||
|
|
||||||
@freeze_time("2024-06-09T13:40:00+00:00")
|
@freeze_time("2024-06-09T13:40:00+00:00")
|
||||||
async def test_current_condition_forecast_nl() -> None:
|
async def test_current_condition_forecast_nl() -> None:
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json")
|
api = get_api_with_data("forecast_ams_no_ww.json")
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
|
result = await api.get_current_weather(tz)
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
expected = CurrentWeatherData(
|
||||||
condition=ATTR_CONDITION_PARTLYCLOUDY,
|
condition=ATTR_CONDITION_PARTLYCLOUDY,
|
||||||
|
@ -359,10 +357,10 @@ async def test_sunrise_sunset_nl(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json").get('for', {}).get('daily')
|
api = get_api_with_data("forecast_ams_no_ww.json")
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
result = await api.get_daily_forecast(tz, 'en')
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['sunrise'] == '2024-06-09T05:19:28+02:00'
|
assert result[0]['sunrise'] == '2024-06-09T05:19:28+02:00'
|
||||||
assert result[0]['sunset'] == '2024-06-09T22:01:09+02:00'
|
assert result[0]['sunset'] == '2024-06-09T22:01:09+02:00'
|
||||||
|
@ -379,10 +377,10 @@ async def test_sunrise_sunset_be(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
|
api = get_api_with_data("forecast.json")
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
result = await api.get_daily_forecast(tz, 'en')
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[1]['sunrise'] == '2023-12-27T08:44:00+01:00'
|
assert result[1]['sunrise'] == '2023-12-27T08:44:00+01:00'
|
||||||
assert result[1]['sunset'] == '2023-12-27T16:43:00+01:00'
|
assert result[1]['sunset'] == '2023-12-27T16:43:00+01:00'
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
@ -9,9 +10,10 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
from custom_components.irm_kmi import IrmKmiCoordinator
|
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.const import CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_UNITS, \
|
from custom_components.irm_kmi.const import CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_UNITS, \
|
||||||
CURRENT_WEATHER_SENSOR_CLASS
|
CURRENT_WEATHER_SENSOR_CLASS
|
||||||
from custom_components.irm_kmi.data import CurrentWeatherData, ProcessedCoordinatorData
|
from custom_components.irm_kmi.irm_kmi_api.data import CurrentWeatherData
|
||||||
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
from custom_components.irm_kmi.sensor import IrmKmiCurrentWeather, IrmKmiCurrentRainfall
|
from custom_components.irm_kmi.sensor import IrmKmiCurrentWeather, IrmKmiCurrentRainfall
|
||||||
from tests.conftest import get_api_data
|
from tests.conftest import get_api_data, get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
def test_sensors_in_current_weather_data():
|
def test_sensors_in_current_weather_data():
|
||||||
|
@ -110,14 +112,16 @@ async def test_current_weather_sensors(
|
||||||
|
|
||||||
api_data = get_api_data(filename)
|
api_data = get_api_data(filename)
|
||||||
time = api_data.get('obs').get('timestamp')
|
time = api_data.get('obs').get('timestamp')
|
||||||
|
api = get_api_with_data(filename)
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat(time) + timedelta(seconds=45, minutes=1))
|
@freeze_time(datetime.fromisoformat(time) + timedelta(seconds=45, minutes=1))
|
||||||
async def run(mock_config_entry_, sensor_, expected_):
|
async def run(mock_config_entry_, sensor_, expected_):
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry_)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry_)
|
||||||
coordinator.data = ProcessedCoordinatorData(
|
coordinator.data = ProcessedCoordinatorData(
|
||||||
current_weather=await IrmKmiCoordinator.current_weather_from_data(api_data),
|
current_weather=await api.get_current_weather(tz),
|
||||||
hourly_forecast=await IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
|
hourly_forecast=await api.get_hourly_forecast(tz),
|
||||||
radar_forecast=IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation', {})),
|
radar_forecast=api.get_radar_forecast(),
|
||||||
country=api_data.get('country')
|
country=api_data.get('country')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -145,13 +149,14 @@ async def test_current_rainfall_unit(
|
||||||
) -> None:
|
) -> None:
|
||||||
hass.config.time_zone = 'Europe/Brussels'
|
hass.config.time_zone = 'Europe/Brussels'
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
api_data = get_api_data(filename)
|
api = get_api_with_data(filename)
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
coordinator.data = ProcessedCoordinatorData(
|
coordinator.data = ProcessedCoordinatorData(
|
||||||
current_weather=await IrmKmiCoordinator.current_weather_from_data(api_data),
|
current_weather=await api.get_current_weather(tz),
|
||||||
hourly_forecast=await IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
|
hourly_forecast=await api.get_hourly_forecast(tz),
|
||||||
radar_forecast=IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation', {})),
|
radar_forecast=api.get_radar_forecast(),
|
||||||
country=api_data.get('country')
|
country=api.get_country()
|
||||||
)
|
)
|
||||||
|
|
||||||
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry)
|
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry)
|
||||||
|
|
|
@ -8,18 +8,17 @@ from homeassistant.const import CONF_ZONE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi import async_migrate_entry
|
from custom_components.irm_kmi import async_migrate_entry, OPTION_STYLE_STD
|
||||||
from custom_components.irm_kmi.const import (
|
from custom_components.irm_kmi.const import (
|
||||||
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD)
|
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||||
|
|
||||||
|
|
||||||
async def test_load_unload_config_entry(
|
async def test_load_unload_config_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_irm_kmi_api: AsyncMock,
|
mock_irm_kmi_api: AsyncMock,
|
||||||
mock_coordinator: AsyncMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the IRM KMI configuration entry loading/unloading."""
|
"""Test the IRM KMI configuration entry loading/unloading."""
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
|
@ -57,7 +56,7 @@ async def test_config_entry_not_ready(
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert mock_exception_irm_kmi_api.get_forecasts_coord.call_count == 1
|
assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1
|
||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
from custom_components.irm_kmi import IrmKmiCoordinator
|
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.pollen import PollenParser
|
from custom_components.irm_kmi.irm_kmi_api.api import IrmKmiApiError
|
||||||
from tests.conftest import get_api_data
|
from custom_components.irm_kmi.irm_kmi_api.pollen import PollenParser
|
||||||
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
def test_svg_pollen_parsing():
|
def test_svg_pollen_parsing():
|
||||||
|
@ -38,15 +39,13 @@ def test_pollen_default_values():
|
||||||
'alder': 'none', 'grasses': 'none', 'ash': 'none'}
|
'alder': 'none', 'grasses': 'none', 'ash': 'none'}
|
||||||
|
|
||||||
|
|
||||||
async def test_pollen_data_from_api(
|
async def test_pollen_data_from_api() -> None:
|
||||||
hass: HomeAssistant,
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
mock_svg_pollen: AsyncMock,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
|
||||||
|
|
||||||
result = await coordinator._async_pollen_data(api_data)
|
# Mock get_svg function
|
||||||
|
api.get_svg = AsyncMock(return_value=load_fixture("pollen.svg"))
|
||||||
|
|
||||||
|
result = await api.get_pollen()
|
||||||
expected = {'mugwort': 'none', 'birch': 'none', 'alder': 'none', 'ash': 'none', 'oak': 'none',
|
expected = {'mugwort': 'none', 'birch': 'none', 'alder': 'none', 'ash': 'none', 'oak': 'none',
|
||||||
'grasses': 'purple', 'hazel': 'none'}
|
'grasses': 'purple', 'hazel': 'none'}
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
@ -55,11 +54,15 @@ async def test_pollen_data_from_api(
|
||||||
async def test_pollen_error_leads_to_unavailable_on_first_call(
|
async def test_pollen_error_leads_to_unavailable_on_first_call(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_exception_irm_kmi_api_svg_pollen: AsyncMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
|
||||||
result = await coordinator._async_pollen_data(api_data)
|
api.get_svg = AsyncMock()
|
||||||
|
api.get_svg.side_effect = IrmKmiApiError
|
||||||
|
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
expected = PollenParser.get_unavailable_data()
|
expected = PollenParser.get_unavailable_data()
|
||||||
assert result == expected
|
assert result['pollen'] == expected
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from custom_components.irm_kmi.radar_data import AnimationFrameData, RadarAnimationData
|
from custom_components.irm_kmi.irm_kmi_api.data import AnimationFrameData, RadarAnimationData
|
||||||
from custom_components.irm_kmi.rain_graph import RainGraph
|
from custom_components.irm_kmi.irm_kmi_api.rain_graph import RainGraph
|
||||||
|
|
||||||
|
|
||||||
def get_radar_animation_data() -> RadarAnimationData:
|
def get_radar_animation_data() -> RadarAnimationData:
|
||||||
|
@ -36,7 +36,7 @@ async def test_svg_frame_setup():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ async def test_svg_frame_setup():
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
svg_str = rain_graph.get_dwg().tostring()
|
||||||
|
|
||||||
with open("custom_components/irm_kmi/resources/roboto_medium.ttf", "rb") as file:
|
with open("custom_components/irm_kmi/irm_kmi_api/resources/roboto_medium.ttf", "rb") as file:
|
||||||
font_b64 = base64.b64encode(file.read()).decode('utf-8')
|
font_b64 = base64.b64encode(file.read()).decode('utf-8')
|
||||||
|
|
||||||
assert '#385E95' in svg_str
|
assert '#385E95' in svg_str
|
||||||
|
@ -56,7 +56,7 @@ def test_svg_hint():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -71,7 +71,7 @@ def test_svg_time_bars():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ def test_draw_chances_path():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -108,7 +108,7 @@ def test_draw_data_line():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -126,13 +126,13 @@ async def test_insert_background():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
await rain_graph.insert_background()
|
await rain_graph.insert_background()
|
||||||
|
|
||||||
with open("custom_components/irm_kmi/resources/be_white.png", "rb") as file:
|
with open("custom_components/irm_kmi/irm_kmi_api/resources/be_white.png", "rb") as file:
|
||||||
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
svg_str = rain_graph.get_dwg().tostring()
|
||||||
|
@ -149,7 +149,7 @@ def test_draw_current_frame_line_moving():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -177,7 +177,7 @@ def test_draw_current_frame_line_index():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -205,7 +205,7 @@ def test_draw_description_text():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -232,7 +232,7 @@ def test_draw_cloud_layer():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -252,7 +252,7 @@ async def test_draw_location_layer():
|
||||||
data = get_radar_animation_data()
|
data = get_radar_animation_data()
|
||||||
rain_graph = RainGraph(
|
rain_graph = RainGraph(
|
||||||
animation_data=data,
|
animation_data=data,
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
background_image_path="custom_components/irm_kmi/irm_kmi_api/resources/be_white.png",
|
||||||
background_size=(640, 490),
|
background_size=(640, 490),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, AsyncMock
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.helpers import issue_registry
|
from homeassistant.helpers import issue_registry
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE,
|
from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE,
|
||||||
|
@ -28,6 +29,11 @@ async def get_repair_flow(
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
fixture: str = "forecast_out_of_benelux.json"
|
||||||
|
forecast = json.loads(load_fixture(fixture))
|
||||||
|
coordinator._api.get_forecasts_coord = AsyncMock(return_value=forecast)
|
||||||
|
|
||||||
await coordinator._async_update_data()
|
await coordinator._async_update_data()
|
||||||
ir = issue_registry.async_get(hass)
|
ir = issue_registry.async_get(hass)
|
||||||
issue = ir.async_get_issue(DOMAIN, "zone_moved")
|
issue = ir.async_get_issue(DOMAIN, "zone_moved")
|
||||||
|
@ -38,7 +44,6 @@ async def get_repair_flow(
|
||||||
|
|
||||||
async def test_repair_triggers_when_out_of_benelux(
|
async def test_repair_triggers_when_out_of_benelux(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
|
@ -50,6 +55,8 @@ async def test_repair_triggers_when_out_of_benelux(
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
coordinator._api.get_forecasts_coord = AsyncMock(return_value=json.loads(load_fixture("forecast_out_of_benelux.json")))
|
||||||
|
|
||||||
await coordinator._async_update_data()
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
ir = issue_registry.async_get(hass)
|
ir = issue_registry.async_get(hass)
|
||||||
|
@ -65,7 +72,6 @@ async def test_repair_triggers_when_out_of_benelux(
|
||||||
|
|
||||||
async def test_repair_flow(
|
async def test_repair_flow(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -87,7 +93,6 @@ async def test_repair_flow(
|
||||||
|
|
||||||
async def test_repair_flow_invalid_choice(
|
async def test_repair_flow_invalid_choice(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -106,7 +111,6 @@ async def test_repair_flow_invalid_choice(
|
||||||
|
|
||||||
async def test_repair_flow_api_error(
|
async def test_repair_flow_api_error(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_get_forecast_api_error_repair: MagicMock,
|
mock_get_forecast_api_error_repair: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -125,7 +129,6 @@ async def test_repair_flow_api_error(
|
||||||
|
|
||||||
async def test_repair_flow_out_of_benelux(
|
async def test_repair_flow_out_of_benelux(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_irm_kmi_api_repair_out_of_benelux: MagicMock,
|
mock_irm_kmi_api_repair_out_of_benelux: MagicMock,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -144,7 +147,6 @@ async def test_repair_flow_out_of_benelux(
|
||||||
|
|
||||||
async def test_repair_flow_delete_entry(
|
async def test_repair_flow_delete_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
|
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -8,7 +9,7 @@ from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.binary_sensor import IrmKmiWarning
|
from custom_components.irm_kmi.binary_sensor import IrmKmiWarning
|
||||||
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
||||||
from custom_components.irm_kmi.sensor import IrmKmiNextSunMove, IrmKmiNextWarning
|
from custom_components.irm_kmi.sensor import IrmKmiNextSunMove, IrmKmiNextWarning
|
||||||
from tests.conftest import get_api_data
|
from tests.conftest import get_api_data, get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
|
||||||
|
@ -16,10 +17,10 @@ async def test_warning_data(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
result = api.get_warnings('en')
|
||||||
|
|
||||||
coordinator.data = {'warnings': result}
|
coordinator.data = {'warnings': result}
|
||||||
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||||
|
@ -39,15 +40,18 @@ async def test_warning_data_unknown_lang(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
# When language is unknown, default to english setting
|
|
||||||
hass.config.language = "foo"
|
|
||||||
|
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = AsyncMock()
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
coordinator.data = {'warnings': result}
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||||
warning.hass = hass
|
warning.hass = hass
|
||||||
|
|
||||||
|
@ -65,15 +69,19 @@ async def test_next_warning_when_data_available(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
await hass.config_entries.async_add(mock_config_entry)
|
await hass.config_entries.async_add(mock_config_entry)
|
||||||
hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'de'})
|
hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'de'})
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = AsyncMock()
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
coordinator.data = {'warnings': result}
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
warning.hass = hass
|
warning.hass = hass
|
||||||
|
|
||||||
|
@ -93,12 +101,16 @@ async def test_next_warning_none_when_only_active_warnings(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = AsyncMock()
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
coordinator.data = {'warnings': result}
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
warning.hass = hass
|
warning.hass = hass
|
||||||
|
|
||||||
|
@ -154,13 +166,16 @@ async def test_next_sunrise_sunset(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast.json")
|
api = get_api_with_data("forecast.json")
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = AsyncMock()
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
coordinator.data = {'daily_forecast': result}
|
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||||
|
|
||||||
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||||
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||||
|
@ -180,13 +195,16 @@ async def test_next_sunrise_sunset_bis(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
api_data = get_api_data("forecast.json")
|
api = get_api_with_data("forecast.json")
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = AsyncMock()
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
coordinator.data = {'daily_forecast': result}
|
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||||
|
|
||||||
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||||
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||||
|
|
|
@ -1,35 +1,28 @@
|
||||||
import os
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from homeassistant.components.weather import Forecast
|
from homeassistant.components.weather import Forecast
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry, load_fixture
|
||||||
|
|
||||||
from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather
|
from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather
|
||||||
from custom_components.irm_kmi.data import (ProcessedCoordinatorData)
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
|
from custom_components.irm_kmi.irm_kmi_api.data import IrmKmiRadarForecast
|
||||||
from tests.conftest import get_api_data
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00"))
|
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00"))
|
||||||
async def test_weather_nl(
|
async def test_weather_nl(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_image_and_nl_forecast_irm_kmi_api: AsyncMock,
|
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
hass.states.async_set(
|
|
||||||
"zone.home",
|
|
||||||
0,
|
|
||||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
|
||||||
)
|
|
||||||
hass.config.config_dir = os.getcwd()
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
await coordinator.async_refresh()
|
forecast = json.loads(load_fixture("forecast_nl.json"))
|
||||||
print(coordinator.data)
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
result = await weather.async_forecast_daily()
|
result = await weather.async_forecast_daily()
|
||||||
|
|
||||||
|
@ -44,19 +37,14 @@ async def test_weather_nl(
|
||||||
@freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00"))
|
@freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00"))
|
||||||
async def test_weather_higher_temp_at_night(
|
async def test_weather_higher_temp_at_night(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_image_and_high_temp_irm_kmi_api: AsyncMock,
|
|
||||||
mock_config_entry: MockConfigEntry
|
mock_config_entry: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
|
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
|
||||||
hass.states.async_set(
|
|
||||||
"zone.home",
|
|
||||||
0,
|
|
||||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
|
||||||
)
|
|
||||||
hass.config.config_dir = os.getcwd()
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
await coordinator.async_refresh()
|
forecast = json.loads(load_fixture("high_low_temp.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
|
||||||
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
result: List[Forecast] = await weather.async_forecast_daily()
|
result: List[Forecast] = await weather.async_forecast_daily()
|
||||||
|
@ -75,18 +63,13 @@ async def test_weather_higher_temp_at_night(
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00"))
|
@freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00"))
|
||||||
async def test_forecast_attribute_same_as_service_call(
|
async def test_forecast_attribute_same_as_service_call(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_image_and_simple_forecast_irm_kmi_api: AsyncMock,
|
|
||||||
mock_config_entry_with_deprecated: MockConfigEntry
|
mock_config_entry_with_deprecated: MockConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
hass.states.async_set(
|
|
||||||
"zone.home",
|
|
||||||
0,
|
|
||||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
|
||||||
)
|
|
||||||
hass.config.config_dir = os.getcwd()
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
|
||||||
await coordinator.async_refresh()
|
forecast = json.loads(load_fixture("forecast.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
|
||||||
weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated)
|
weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated)
|
||||||
|
|
||||||
|
@ -104,11 +87,10 @@ async def test_radar_forecast_service(
|
||||||
hass.config.time_zone = 'Europe/Brussels'
|
hass.config.time_zone = 'Europe/Brussels'
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
api_data = get_api_data("forecast.json")
|
coordinator._api = get_api_with_data("forecast.json")
|
||||||
data = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
|
||||||
|
|
||||||
coordinator.data = ProcessedCoordinatorData(
|
coordinator.data = ProcessedCoordinatorData(
|
||||||
radar_forecast=data
|
radar_forecast=coordinator._api.get_radar_forecast()
|
||||||
)
|
)
|
||||||
|
|
||||||
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
|
|