mirror of
https://github.com/jdejaegh/irm-kmi-ha.git
synced 2025-06-27 11:39:26 +02:00
Compare commits
31 commits
Author | SHA1 | Date | |
---|---|---|---|
52be58a9ef | |||
f1b18fe29a | |||
77e94d802b | |||
76a670427b | |||
866b1f3fa0 | |||
914dd75d7b | |||
5c320b57fb | |||
68bcb8aeb4 | |||
702f687a8d | |||
d5a687fff5 | |||
9e178378fc | |||
ef5d3ad126 | |||
d0d542c3fe | |||
fd8aa3029f | |||
fb43a882f8 | |||
57cce48c5f | |||
7951bafefb | |||
f0a1853f67 | |||
2707950ad9 | |||
1a33b3b594 | |||
6476f0e57a | |||
5932884c7a | |||
1e35e24c15 | |||
f729d59d9f | |||
16a5399edb | |||
36bfe49ce2 | |||
16a1991063 | |||
be30c160f4 | |||
9064326860 | |||
ea23f0da2c | |||
7f9cca4960 |
37 changed files with 288 additions and 2135 deletions
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023-2024 Jules Dejaeghere
|
Copyright (c) 2023-2025 Jules Dejaeghere
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
@ -119,6 +119,7 @@ The following table summarizes the different known warning types. Other warning
|
||||||
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
|
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
|
||||||
| fog | 7 | Fog, Brouillard, Mist, Nebel |
|
| fog | 7 | Fog, Brouillard, Mist, Nebel |
|
||||||
| cold | 9 | Cold, Froid, Koude, Kalt |
|
| cold | 9 | Cold, Froid, Koude, Kalt |
|
||||||
|
| heat | 10 | Heat, Chaleur, Hitte, Hitze |
|
||||||
| thunder_wind_rain | 12 | Thunder Wind Rain, Orage, rafales et averses, Onweer Wind Regen, Gewitter Windböen Regen |
|
| thunder_wind_rain | 12 | Thunder Wind Rain, Orage, rafales et averses, Onweer Wind Regen, Gewitter Windböen Regen |
|
||||||
| thunderstorm_strong_gusts | 13 | Thunderstorm & strong gusts, Orage et rafales, Onweer en wind, Gewitter und Windböen |
|
| thunderstorm_strong_gusts | 13 | Thunderstorm & strong gusts, Orage et rafales, Onweer en wind, Gewitter und Windböen |
|
||||||
| thunderstorm_large_rainfall | 14 | Thunderstorm & large rainfall, Orage et averses, Onweer en regen, Gewitter und Regen |
|
| thunderstorm_large_rainfall | 14 | Thunderstorm & large rainfall, Orage et averses, Onweer en regen, Gewitter und Regen |
|
||||||
|
|
|
@ -6,11 +6,11 @@ import logging
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryError
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
|
from irm_kmi_api.const import OPTION_STYLE_STD
|
||||||
|
|
||||||
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 .weather import IrmKmiWeather
|
from .weather import IrmKmiWeather
|
||||||
|
|
||||||
|
@ -22,6 +22,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry)
|
hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry)
|
||||||
|
|
||||||
|
# When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging
|
||||||
|
logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel())
|
||||||
try:
|
try:
|
||||||
# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities
|
# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
|
@ -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")
|
|
|
@ -10,7 +10,7 @@ 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
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,7 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||||
"""Return still image to be used as thumbnail."""
|
"""Return still image to be used as thumbnail."""
|
||||||
if self.coordinator.data.get('animation', None) is not None:
|
if self.coordinator.data.get('animation', None) is not None:
|
||||||
return await self.coordinator.data.get('animation').get_still()
|
return await self.coordinator.data.get('animation').get_still()
|
||||||
|
return None
|
||||||
|
|
||||||
async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse:
|
async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse:
|
||||||
"""Generate an HTTP MJPEG stream from camera images."""
|
"""Generate an HTTP MJPEG stream from camera images."""
|
||||||
|
|
|
@ -14,14 +14,15 @@ from homeassistant.helpers.selector import (EntitySelector,
|
||||||
SelectSelector,
|
SelectSelector,
|
||||||
SelectSelectorConfig,
|
SelectSelectorConfig,
|
||||||
SelectSelectorMode)
|
SelectSelectorMode)
|
||||||
|
from irm_kmi_api.api import IrmKmiApiClient
|
||||||
|
|
||||||
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 .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]}
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,7 +12,10 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
ATTR_CONDITION_SNOWY,
|
ATTR_CONDITION_SNOWY,
|
||||||
ATTR_CONDITION_SNOWY_RAINY,
|
ATTR_CONDITION_SNOWY_RAINY,
|
||||||
ATTR_CONDITION_SUNNY)
|
ATTR_CONDITION_SUNNY)
|
||||||
from homeassistant.const import Platform, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, DEGREE
|
from homeassistant.const import (DEGREE, Platform, UnitOfPressure, UnitOfSpeed,
|
||||||
|
UnitOfTemperature)
|
||||||
|
from irm_kmi_api.const import (OPTION_STYLE_CONTRAST, OPTION_STYLE_SATELLITE,
|
||||||
|
OPTION_STYLE_STD, OPTION_STYLE_YELLOW_RED)
|
||||||
|
|
||||||
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]
|
||||||
|
@ -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,9 +134,7 @@ 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.3.2'
|
||||||
|
|
||||||
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.2.30'
|
|
||||||
|
|
||||||
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',
|
||||||
'pressure'}
|
'pressure'}
|
||||||
|
@ -170,7 +143,8 @@ CURRENT_WEATHER_SENSOR_UNITS: Final = {'temperature': UnitOfTemperature.CELSIUS,
|
||||||
'wind_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
'wind_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||||
'wind_gust_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
'wind_gust_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||||
'wind_bearing': DEGREE,
|
'wind_bearing': DEGREE,
|
||||||
'uv_index': None,
|
# Need to put '', else the history shows a bar graph instead of a chart
|
||||||
|
'uv_index': '',
|
||||||
'pressure': UnitOfPressure.HPA}
|
'pressure': UnitOfPressure.HPA}
|
||||||
|
|
||||||
CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE,
|
CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE,
|
||||||
|
|
|
@ -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
|
||||||
|
@ -17,21 +13,15 @@ from homeassistant.helpers.update_coordinator import (
|
||||||
TimestampDataUpdateCoordinator, UpdateFailed)
|
TimestampDataUpdateCoordinator, UpdateFailed)
|
||||||
from homeassistant.util import dt
|
from homeassistant.util import dt
|
||||||
from homeassistant.util.dt import utcnow
|
from homeassistant.util.dt import utcnow
|
||||||
|
from irm_kmi_api.api import IrmKmiApiClientHa, IrmKmiApiError
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
from irm_kmi_api.rain_graph import RainGraph
|
||||||
|
|
||||||
from .api import IrmKmiApiClient, IrmKmiApiError
|
|
||||||
from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN, IRM_KMI_NAME
|
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, USER_AGENT
|
||||||
from .const import (OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP,
|
from .data import ProcessedCoordinatorData
|
||||||
WEEKDAYS)
|
from .utils import disable_from_config, get_config_value, preferred_language
|
||||||
from .data import (CurrentWeatherData, IrmKmiForecast,
|
|
||||||
ProcessedCoordinatorData,
|
|
||||||
WarningData)
|
|
||||||
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,20 @@ 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()
|
# When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging
|
||||||
|
logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel())
|
||||||
|
|
||||||
|
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 +81,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 +99,51 @@ 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 = self._api.get_animation_data(tz, lang, self._style, self._dark_mode)
|
||||||
except (TypeError, ValueError):
|
animation = await RainGraph(radar_animation,
|
||||||
pressure = None
|
country=self._api.get_country(),
|
||||||
|
style=self._style,
|
||||||
try:
|
tz=tz,
|
||||||
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
|
dark_mode=self._dark_mode,
|
||||||
except (TypeError, ValueError):
|
api_client=self._api
|
||||||
wind_speed = None
|
).build()
|
||||||
|
|
||||||
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
|
|
||||||
# hourly forecast instead if it is missing
|
|
||||||
if current_weather['condition'] is None:
|
|
||||||
try:
|
|
||||||
current_weather['condition'] = CDT_MAP.get((int(now_hourly.get('ww')), now_hourly.get('dayNight')))
|
|
||||||
except (TypeError, ValueError, AttributeError):
|
|
||||||
current_weather['condition'] = None
|
|
||||||
|
|
||||||
return current_weather
|
# Make 'condition_evol' in a str instead of enum variant
|
||||||
|
daily_forecast = [
|
||||||
|
{**d, "condition_evol": d["condition_evol"].value}
|
||||||
|
if "condition_evol" in d and hasattr(d["condition_evol"], "value")
|
||||||
|
else d
|
||||||
|
for d in self._api.get_daily_forecast(tz, lang)
|
||||||
|
]
|
||||||
|
|
||||||
@staticmethod
|
return ProcessedCoordinatorData(
|
||||||
async def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
|
current_weather=self._api.get_current_weather(tz),
|
||||||
"""Parse data from the API to create a list of hourly forecasts"""
|
daily_forecast=daily_forecast,
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
hourly_forecast=self._api.get_hourly_forecast(tz),
|
||||||
return None
|
radar_forecast=self._api.get_radar_forecast(),
|
||||||
|
animation=animation,
|
||||||
forecasts = list()
|
warnings=self._api.get_warnings(lang),
|
||||||
tz = await dt.async_get_time_zone('Europe/Brussels')
|
pollen=pollen,
|
||||||
day = dt.now(time_zone=tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
country=self._api.get_country()
|
||||||
|
|
||||||
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,8 @@
|
||||||
"""Data classes for IRM KMI integration"""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, TypedDict
|
from typing import List, TypedDict
|
||||||
|
|
||||||
from homeassistant.components.weather import Forecast
|
from homeassistant.components.weather import Forecast
|
||||||
|
from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
|
||||||
from .rain_graph import RainGraph
|
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):
|
||||||
|
|
|
@ -9,8 +9,7 @@
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
|
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"svgwrite==1.4.3",
|
"irm-kmi-api==0.2.0"
|
||||||
"aiofile==3.9.0"
|
|
||||||
],
|
],
|
||||||
"version": "0.2.30"
|
"version": "0.3.2"
|
||||||
}
|
}
|
|
@ -1,108 +0,0 @@
|
||||||
"""Parse pollen info from SVG from IRM KMI api"""
|
|
||||||
import logging
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .const import POLLEN_LEVEL_TO_COLOR, POLLEN_NAMES
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PollenParser:
|
|
||||||
"""
|
|
||||||
Extract pollen level from an SVG provided by the IRM KMI API.
|
|
||||||
To get the data, match pollen names and pollen levels that are vertically aligned or the dot on the color scale.
|
|
||||||
Then, map the value to the corresponding color on the scale.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
xml_string: str
|
|
||||||
):
|
|
||||||
self._xml = xml_string
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_default_data() -> dict:
|
|
||||||
"""Return all the known pollen with 'none' value"""
|
|
||||||
return {k.lower(): 'none' for k in POLLEN_NAMES}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_unavailable_data() -> dict:
|
|
||||||
"""Return all the known pollen with 'none' value"""
|
|
||||||
return {k.lower(): None for k in POLLEN_NAMES}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_option_values() -> List[str]:
|
|
||||||
"""List all the values that the pollen can have"""
|
|
||||||
return list(POLLEN_LEVEL_TO_COLOR.values()) + ['none']
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_elements(root) -> List[ET.Element]:
|
|
||||||
"""Recursively collect all elements of the SVG in a list"""
|
|
||||||
elements = []
|
|
||||||
for child in root:
|
|
||||||
elements.append(child)
|
|
||||||
elements.extend(PollenParser._extract_elements(child))
|
|
||||||
return elements
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_elem_text(e) -> str | None:
|
|
||||||
if e.text is not None:
|
|
||||||
return e.text.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_pollen_data(self) -> dict:
|
|
||||||
"""From the XML string, parse the SVG and extract the pollen data from the image.
|
|
||||||
If an error occurs, return the default value"""
|
|
||||||
pollen_data = self.get_default_data()
|
|
||||||
try:
|
|
||||||
_LOGGER.debug(f"Full SVG: {self._xml}")
|
|
||||||
root = ET.fromstring(self._xml)
|
|
||||||
except ET.ParseError as e:
|
|
||||||
_LOGGER.warning(f"Could not parse SVG pollen XML: {e}")
|
|
||||||
return pollen_data
|
|
||||||
|
|
||||||
elements: List[ET.Element] = self._extract_elements(root)
|
|
||||||
|
|
||||||
pollens = {e.attrib.get('x', None): self._get_elem_text(e).lower()
|
|
||||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_NAMES}
|
|
||||||
|
|
||||||
pollen_levels = {e.attrib.get('x', None): POLLEN_LEVEL_TO_COLOR[self._get_elem_text(e)]
|
|
||||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_LEVEL_TO_COLOR}
|
|
||||||
|
|
||||||
level_dots = {e.attrib.get('cx', None) for e in elements if 'circle' in e.tag}
|
|
||||||
|
|
||||||
# For each pollen name found, check the text just below.
|
|
||||||
# As of January 2025, the text is always 'active' and the dot shows the real level
|
|
||||||
# If text says 'active', check the dot; else trust the text
|
|
||||||
for position, pollen in pollens.items():
|
|
||||||
# Determine pollen level based on text
|
|
||||||
if position is not None and position in pollen_levels:
|
|
||||||
pollen_data[pollen] = pollen_levels[position]
|
|
||||||
_LOGGER.debug(f"{pollen} is {pollen_data[pollen]} according to text")
|
|
||||||
|
|
||||||
# If text is 'active' or if there is no text, check the dot as a fallback
|
|
||||||
if pollen_data[pollen] not in {'none', 'active'}:
|
|
||||||
_LOGGER.debug(f"{pollen} trusting text")
|
|
||||||
else:
|
|
||||||
for dot in level_dots:
|
|
||||||
try:
|
|
||||||
relative_x_position = float(position) - float(dot)
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if 24 <= relative_x_position <= 34:
|
|
||||||
pollen_data[pollen] = 'green'
|
|
||||||
elif 13 <= relative_x_position <= 23:
|
|
||||||
pollen_data[pollen] = 'yellow'
|
|
||||||
elif -5 <= relative_x_position <= 5:
|
|
||||||
pollen_data[pollen] = 'orange'
|
|
||||||
elif -23 <= relative_x_position <= -13:
|
|
||||||
pollen_data[pollen] = 'red'
|
|
||||||
elif -34 <= relative_x_position <= -24:
|
|
||||||
pollen_data[pollen] = 'purple'
|
|
||||||
|
|
||||||
_LOGGER.debug(f"{pollen} is {pollen_data[pollen]} according to dot")
|
|
||||||
|
|
||||||
_LOGGER.debug(f"Pollen data: {pollen_data}")
|
|
||||||
return pollen_data
|
|
|
@ -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
|
|
|
@ -1,412 +0,0 @@
|
||||||
"""Create graphs for rain short term forecast."""
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import copy
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import List, Self, Any, Coroutine
|
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
from aiofile import async_open
|
|
||||||
from homeassistant.util import dt
|
|
||||||
from svgwrite import Drawing
|
|
||||||
from svgwrite.animate import Animate
|
|
||||||
from svgwrite.utils import font_mimetype
|
|
||||||
|
|
||||||
from .api import IrmKmiApiClient
|
|
||||||
from .radar_data import AnimationFrameData, RadarAnimationData
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RainGraph:
|
|
||||||
def __init__(self,
|
|
||||||
animation_data: RadarAnimationData,
|
|
||||||
background_image_path: str,
|
|
||||||
background_size: (int, int),
|
|
||||||
config_dir: str = '.',
|
|
||||||
dark_mode: bool = False,
|
|
||||||
tz: datetime.tzinfo = dt.get_default_time_zone(),
|
|
||||||
svg_width: float = 640,
|
|
||||||
inset: float = 20,
|
|
||||||
graph_height: float = 150,
|
|
||||||
top_text_space: float = 30,
|
|
||||||
top_text_y_pos: float = 20,
|
|
||||||
bottom_text_space: float = 50,
|
|
||||||
bottom_text_y_pos: float = 218,
|
|
||||||
api_client: IrmKmiApiClient | None = None
|
|
||||||
):
|
|
||||||
|
|
||||||
self._animation_data: RadarAnimationData = animation_data
|
|
||||||
self._background_image_path: str = background_image_path
|
|
||||||
self._background_size: (int, int) = background_size
|
|
||||||
self._config_dir: str = config_dir
|
|
||||||
self._dark_mode: bool = dark_mode
|
|
||||||
self._tz = tz
|
|
||||||
self._svg_width: float = svg_width
|
|
||||||
self._inset: float = inset
|
|
||||||
self._graph_height: float = graph_height
|
|
||||||
self._top_text_space: float = top_text_space + background_size[1]
|
|
||||||
self._top_text_y_pos: float = top_text_y_pos + background_size[1]
|
|
||||||
self._bottom_text_space: float = bottom_text_space
|
|
||||||
self._bottom_text_y_pos: float = bottom_text_y_pos + background_size[1]
|
|
||||||
self._api_client = api_client
|
|
||||||
|
|
||||||
self._frame_count: int = len(self._animation_data['sequence'])
|
|
||||||
self._graph_width: float = self._svg_width - 2 * self._inset
|
|
||||||
self._graph_bottom: float = self._top_text_space + self._graph_height
|
|
||||||
self._svg_height: float = self._graph_height + self._top_text_space + self._bottom_text_space
|
|
||||||
self._interval_width: float = self._graph_width / self._frame_count
|
|
||||||
self._offset: float = self._inset + self._interval_width / 2
|
|
||||||
|
|
||||||
if not (0 <= self._top_text_y_pos <= self._top_text_space):
|
|
||||||
raise ValueError("It must hold that 0 <= top_text_y_pos <= top_text_space")
|
|
||||||
|
|
||||||
if not (self._graph_bottom <= self._bottom_text_y_pos <= self._graph_bottom + self._bottom_text_space):
|
|
||||||
raise ValueError("bottom_text_y_pos must be below the graph")
|
|
||||||
|
|
||||||
self._dwg: Drawing = Drawing(size=(self._svg_width, self._svg_height), profile='full')
|
|
||||||
self._dwg_save: Drawing | None = None
|
|
||||||
self._dwg_animated: Drawing | None = None
|
|
||||||
self._dwg_still: Drawing | None = None
|
|
||||||
|
|
||||||
async def build(self) -> Self:
|
|
||||||
"""Build the rain graph by calling all the method in the right order. Returns self when done"""
|
|
||||||
await self.draw_svg_frame()
|
|
||||||
self.draw_hour_bars()
|
|
||||||
self.draw_chances_path()
|
|
||||||
self.draw_data_line()
|
|
||||||
self.write_hint()
|
|
||||||
await self.insert_background()
|
|
||||||
self._dwg_save = copy.deepcopy(self._dwg)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def get_animated(self) -> bytes:
|
|
||||||
"""Get the animated SVG. If called for the first time since refresh, downloads the images to build the file."""
|
|
||||||
|
|
||||||
_LOGGER.info(f"Get animated with _dwg_animated {self._dwg_animated}")
|
|
||||||
if self._dwg_animated is None:
|
|
||||||
clouds = self.download_clouds()
|
|
||||||
self._dwg = copy.deepcopy(self._dwg_save)
|
|
||||||
self.draw_current_fame_line()
|
|
||||||
self.draw_description_text()
|
|
||||||
await clouds
|
|
||||||
self.insert_cloud_layer()
|
|
||||||
await self.draw_location()
|
|
||||||
self._dwg_animated = self._dwg
|
|
||||||
return self.get_svg_string(still_image=False)
|
|
||||||
|
|
||||||
async def get_still(self) -> bytes:
|
|
||||||
"""Get the animated SVG. If called for the first time since refresh, downloads the images to build the file."""
|
|
||||||
_LOGGER.info(f"Get still with _dwg_still {self._dwg_still}")
|
|
||||||
|
|
||||||
if self._dwg_still is None:
|
|
||||||
idx = self._animation_data['most_recent_image_idx']
|
|
||||||
cloud = self.download_clouds(idx)
|
|
||||||
self._dwg = copy.deepcopy(self._dwg_save)
|
|
||||||
self.draw_current_fame_line(idx)
|
|
||||||
self.draw_description_text(idx)
|
|
||||||
await cloud
|
|
||||||
self.insert_cloud_layer(idx)
|
|
||||||
await self.draw_location()
|
|
||||||
self._dwg_still = self._dwg
|
|
||||||
return self.get_svg_string(still_image=True)
|
|
||||||
|
|
||||||
async def download_clouds(self, idx = None):
|
|
||||||
imgs = [e['image'] for e in self._animation_data['sequence']]
|
|
||||||
|
|
||||||
if idx is not None and type(imgs[idx]) is str:
|
|
||||||
_LOGGER.info("Download single cloud image")
|
|
||||||
result = await self.download_images_from_api([imgs[idx]])
|
|
||||||
self._animation_data['sequence'][idx]['image'] = result[0]
|
|
||||||
|
|
||||||
else:
|
|
||||||
_LOGGER.info("Download many cloud images")
|
|
||||||
|
|
||||||
result = await self.download_images_from_api([img for img in imgs if type(img) is str])
|
|
||||||
|
|
||||||
for i in range(len(self._animation_data['sequence'])):
|
|
||||||
if type(self._animation_data['sequence'][i]['image']) is str:
|
|
||||||
self._animation_data['sequence'][i]['image'] = result[0]
|
|
||||||
result = result[1:]
|
|
||||||
|
|
||||||
async def download_images_from_api(self, urls: list[str]) -> list[Any]:
|
|
||||||
"""Download a batch of images to create the radar frames."""
|
|
||||||
coroutines = list()
|
|
||||||
|
|
||||||
for url in urls:
|
|
||||||
coroutines.append(self._api_client.get_image(url))
|
|
||||||
async with async_timeout.timeout(60):
|
|
||||||
images_from_api = await asyncio.gather(*coroutines)
|
|
||||||
|
|
||||||
_LOGGER.info(f"Just downloaded {len(images_from_api)} images")
|
|
||||||
return images_from_api
|
|
||||||
|
|
||||||
def get_hint(self) -> str:
|
|
||||||
return self._animation_data.get('hint', None)
|
|
||||||
|
|
||||||
async def draw_svg_frame(self):
|
|
||||||
"""Create the global area to draw the other items"""
|
|
||||||
font_file = os.path.join(self._config_dir, 'custom_components/irm_kmi/resources/roboto_medium.ttf')
|
|
||||||
_LOGGER.debug(f"Opening font file at {font_file}")
|
|
||||||
|
|
||||||
async with async_open(font_file, 'rb') as font:
|
|
||||||
data = await font.read()
|
|
||||||
|
|
||||||
# Need to use the private class method as the public one does not offer an async call
|
|
||||||
# As this is run in the main loop, we cannot afford a blocking open() call
|
|
||||||
self._dwg._embed_font_data("Roboto Medium", data, font_mimetype(font_file))
|
|
||||||
self._dwg.embed_stylesheet("""
|
|
||||||
.roboto {
|
|
||||||
font-family: "Roboto Medium";
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
fill_color = '#393C40' if self._dark_mode else '#385E95'
|
|
||||||
self._dwg.add(self._dwg.rect(insert=(0, 0),
|
|
||||||
size=(self._svg_width, self._svg_height),
|
|
||||||
rx=None, ry=None,
|
|
||||||
fill=fill_color, stroke='none'))
|
|
||||||
|
|
||||||
def draw_description_text(self, idx: int | None = None):
|
|
||||||
"""For every frame write the amount of precipitation and the time at the top of the graph.
|
|
||||||
If idx is set, only do it for the given idx"""
|
|
||||||
|
|
||||||
times = [e['time'].astimezone(tz=self._tz).strftime('%H:%M') for e in
|
|
||||||
self._animation_data['sequence']]
|
|
||||||
rain_levels = [f"{e['value']}{self._animation_data['unit']}" for e in self._animation_data['sequence']]
|
|
||||||
|
|
||||||
if idx is not None:
|
|
||||||
time = times[idx]
|
|
||||||
rain_level = rain_levels[idx]
|
|
||||||
|
|
||||||
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
|
|
||||||
|
|
||||||
self.write_time_and_rain(paragraph, rain_level, time)
|
|
||||||
return
|
|
||||||
|
|
||||||
for i in range(self._frame_count):
|
|
||||||
time = times[i]
|
|
||||||
rain_level = rain_levels[i]
|
|
||||||
|
|
||||||
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
|
|
||||||
|
|
||||||
values = ['hidden'] * self._frame_count
|
|
||||||
values[i] = 'visible'
|
|
||||||
|
|
||||||
paragraph.add(Animate(
|
|
||||||
attributeName="visibility",
|
|
||||||
values=";".join(values),
|
|
||||||
dur=f"{self._frame_count * 0.3}s",
|
|
||||||
begin="0s",
|
|
||||||
repeatCount="indefinite"
|
|
||||||
))
|
|
||||||
|
|
||||||
self.write_time_and_rain(paragraph, rain_level, time)
|
|
||||||
|
|
||||||
def write_time_and_rain(self, paragraph, rain_level, time):
|
|
||||||
"""Using the paragraph object, write the time and rain level data"""
|
|
||||||
paragraph.add(self._dwg.text(f"{time}", insert=(self._offset, self._top_text_y_pos),
|
|
||||||
text_anchor="start",
|
|
||||||
font_size="16px",
|
|
||||||
fill="white",
|
|
||||||
stroke='none'))
|
|
||||||
paragraph.add(self._dwg.text(f"{rain_level}", insert=(self._svg_width / 2, self._top_text_y_pos),
|
|
||||||
text_anchor="middle",
|
|
||||||
font_size="16px",
|
|
||||||
fill="white",
|
|
||||||
stroke='none'))
|
|
||||||
|
|
||||||
def write_hint(self):
|
|
||||||
"""Add the hint text at the bottom of the graph"""
|
|
||||||
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
|
|
||||||
|
|
||||||
hint = self._animation_data['hint']
|
|
||||||
|
|
||||||
paragraph.add(self._dwg.text(f"{hint}", insert=(self._svg_width / 2, self._bottom_text_y_pos),
|
|
||||||
text_anchor="middle",
|
|
||||||
font_size="16px",
|
|
||||||
fill="white",
|
|
||||||
stroke='none'))
|
|
||||||
|
|
||||||
def draw_chances_path(self):
|
|
||||||
"""Draw the prevision margin area around the main forecast line"""
|
|
||||||
list_lower_points = []
|
|
||||||
list_higher_points = []
|
|
||||||
|
|
||||||
rain_list: List[AnimationFrameData] = self._animation_data['sequence']
|
|
||||||
graph_rect_left = self._offset
|
|
||||||
graph_rect_top = self._top_text_space
|
|
||||||
|
|
||||||
for i in range(len(rain_list)):
|
|
||||||
position_higher = rain_list[i]['position_higher']
|
|
||||||
if position_higher is not None:
|
|
||||||
list_higher_points.append((graph_rect_left, graph_rect_top + (
|
|
||||||
1.0 - position_higher) * self._graph_height))
|
|
||||||
graph_rect_left += self._interval_width
|
|
||||||
|
|
||||||
graph_rect_right = graph_rect_left - self._interval_width
|
|
||||||
for i in range(len(rain_list) - 1, -1, -1):
|
|
||||||
position_lower = rain_list[i]['position_lower']
|
|
||||||
if position_lower is not None:
|
|
||||||
list_lower_points.append((graph_rect_right, graph_rect_top + (
|
|
||||||
1.0 - position_lower) * self._graph_height))
|
|
||||||
graph_rect_right -= self._interval_width
|
|
||||||
|
|
||||||
if list_higher_points and list_lower_points:
|
|
||||||
self.draw_chance_precip(list_higher_points, list_lower_points)
|
|
||||||
|
|
||||||
def draw_chance_precip(self, list_higher_points: List, list_lower_points: List):
|
|
||||||
"""Draw the blue solid line representing the actual rain forecast"""
|
|
||||||
precip_higher_chance_path = self._dwg.path(fill='#63c8fa', stroke='none', opacity=.3)
|
|
||||||
|
|
||||||
list_higher_points[-1] = tuple(list(list_higher_points[-1]) + ['last'])
|
|
||||||
|
|
||||||
self.set_curved_path(precip_higher_chance_path, list_higher_points + list_lower_points)
|
|
||||||
self._dwg.add(precip_higher_chance_path)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def set_curved_path(path, points):
|
|
||||||
"""Pushes points on the path by creating a nice curve between them"""
|
|
||||||
if len(points) < 2:
|
|
||||||
return
|
|
||||||
|
|
||||||
path.push('M', *points[0])
|
|
||||||
|
|
||||||
for i in range(1, len(points)):
|
|
||||||
x_mid = (points[i - 1][0] + points[i][0]) / 2
|
|
||||||
y_mid = (points[i - 1][1] + points[i][1]) / 2
|
|
||||||
|
|
||||||
path.push('Q', points[i - 1][0], points[i - 1][1], x_mid, y_mid)
|
|
||||||
if points[i][-1] == 'last' or points[i - 1][-1] == 'last':
|
|
||||||
path.push('Q', points[i][0], points[i][1], points[i][0], points[i][1])
|
|
||||||
|
|
||||||
path.push('Q', points[-1][0], points[-1][1], points[-1][0], points[-1][1])
|
|
||||||
|
|
||||||
def draw_data_line(self):
|
|
||||||
"""Draw the main data line for the rain forecast"""
|
|
||||||
rain_list: List[AnimationFrameData] = self._animation_data['sequence']
|
|
||||||
graph_rect_left = self._offset
|
|
||||||
graph_rect_top = self._top_text_space
|
|
||||||
|
|
||||||
entry_list = []
|
|
||||||
|
|
||||||
for i in range(len(rain_list)):
|
|
||||||
position = rain_list[i]['position']
|
|
||||||
entry_list.append(
|
|
||||||
(graph_rect_left,
|
|
||||||
graph_rect_top + (1.0 - position) * self._graph_height))
|
|
||||||
graph_rect_left += self._interval_width
|
|
||||||
data_line_path = self._dwg.path(fill='none', stroke='#63c8fa', stroke_width=2)
|
|
||||||
self.set_curved_path(data_line_path, entry_list)
|
|
||||||
self._dwg.add(data_line_path)
|
|
||||||
|
|
||||||
def draw_hour_bars(self):
|
|
||||||
"""Draw the small bars at the bottom to represent the time"""
|
|
||||||
hour_bar_height = 8
|
|
||||||
horizontal_inset = self._offset
|
|
||||||
|
|
||||||
for (i, rain_item) in enumerate(self._animation_data['sequence']):
|
|
||||||
time_image = rain_item['time'].astimezone(tz=self._tz)
|
|
||||||
is_hour_bar = time_image.minute == 0
|
|
||||||
|
|
||||||
x_position = horizontal_inset
|
|
||||||
if i == self._animation_data['most_recent_image_idx']:
|
|
||||||
self._dwg.add(self._dwg.line(start=(x_position, self._top_text_space),
|
|
||||||
end=(x_position, self._graph_bottom),
|
|
||||||
stroke='white',
|
|
||||||
opacity=0.5,
|
|
||||||
stroke_dasharray=4))
|
|
||||||
|
|
||||||
self._dwg.add(self._dwg.line(start=(x_position, self._graph_bottom - hour_bar_height),
|
|
||||||
end=(x_position, self._graph_bottom),
|
|
||||||
stroke='white' if is_hour_bar else 'lightgrey',
|
|
||||||
opacity=0.9 if is_hour_bar else 0.7))
|
|
||||||
|
|
||||||
if is_hour_bar:
|
|
||||||
graph_rect_center_x = x_position
|
|
||||||
graph_rect_center_y = self._graph_bottom + 18
|
|
||||||
|
|
||||||
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
|
|
||||||
paragraph.add(self._dwg.text(f"{time_image.hour}h", insert=(graph_rect_center_x, graph_rect_center_y),
|
|
||||||
text_anchor="middle",
|
|
||||||
font_size="16px",
|
|
||||||
fill="white",
|
|
||||||
stroke='none'))
|
|
||||||
|
|
||||||
horizontal_inset += self._interval_width
|
|
||||||
|
|
||||||
self._dwg.add(self._dwg.line(start=(self._offset, self._graph_bottom),
|
|
||||||
end=(self._graph_width + self._interval_width / 2, self._graph_bottom),
|
|
||||||
stroke='white'))
|
|
||||||
|
|
||||||
def draw_current_fame_line(self, idx: int | None = None):
|
|
||||||
"""Draw a solid white line on the timeline at the position of the given frame index"""
|
|
||||||
x_position = self._offset if idx is None else self._offset + idx * self._interval_width
|
|
||||||
now = self._dwg.add(self._dwg.line(start=(x_position, self._top_text_space),
|
|
||||||
end=(x_position, self._graph_bottom),
|
|
||||||
id='now',
|
|
||||||
stroke='white',
|
|
||||||
opacity=1,
|
|
||||||
stroke_width=2))
|
|
||||||
if idx is not None:
|
|
||||||
return
|
|
||||||
now.add(self._dwg.animateTransform("translate", "transform",
|
|
||||||
id="now",
|
|
||||||
from_=f"{self._offset} 0",
|
|
||||||
to=f"{self._graph_width - self._offset} 0",
|
|
||||||
dur=f"{self._frame_count * 0.3}s",
|
|
||||||
repeatCount="indefinite"))
|
|
||||||
|
|
||||||
def get_svg_string(self, still_image: bool = False) -> bytes:
|
|
||||||
return self._dwg_still.tostring().encode() if still_image else self._dwg_animated.tostring().encode()
|
|
||||||
|
|
||||||
async def insert_background(self):
|
|
||||||
bg_image_path = os.path.join(self._config_dir, self._background_image_path)
|
|
||||||
async with async_open(bg_image_path, 'rb') as f:
|
|
||||||
png_data = base64.b64encode(await f.read()).decode('utf-8')
|
|
||||||
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
|
|
||||||
self._dwg.add(image)
|
|
||||||
|
|
||||||
def insert_cloud_layer(self, idx: int | None = None):
|
|
||||||
imgs = [e['image'] for e in self._animation_data['sequence']]
|
|
||||||
|
|
||||||
if idx is not None:
|
|
||||||
img = imgs[idx]
|
|
||||||
png_data = base64.b64encode(img).decode('utf-8')
|
|
||||||
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
|
|
||||||
self._dwg.add(image)
|
|
||||||
return
|
|
||||||
|
|
||||||
for i, img in enumerate(imgs):
|
|
||||||
png_data = base64.b64encode(img).decode('utf-8')
|
|
||||||
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
|
|
||||||
self._dwg.add(image)
|
|
||||||
|
|
||||||
values = ['hidden'] * self._frame_count
|
|
||||||
values[i] = 'visible'
|
|
||||||
|
|
||||||
image.add(Animate(
|
|
||||||
attributeName="visibility",
|
|
||||||
values=";".join(values),
|
|
||||||
dur=f"{self._frame_count * 0.3}s",
|
|
||||||
begin="0s",
|
|
||||||
repeatCount="indefinite"
|
|
||||||
))
|
|
||||||
|
|
||||||
async def draw_location(self):
|
|
||||||
img = self._animation_data['location']
|
|
||||||
|
|
||||||
_LOGGER.info(f"Draw location layer with img of type {type(img)}")
|
|
||||||
if type(img) is str:
|
|
||||||
result = await self.download_images_from_api([img])
|
|
||||||
img = result[0]
|
|
||||||
self._animation_data['location'] = img
|
|
||||||
png_data = base64.b64encode(img).decode('utf-8')
|
|
||||||
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
|
|
||||||
self._dwg.add(image)
|
|
||||||
|
|
||||||
def get_dwg(self):
|
|
||||||
return copy.deepcopy(self._dwg)
|
|
|
@ -8,13 +8,12 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||||
from homeassistant.core import HomeAssistant
|
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 irm_kmi_api.api import IrmKmiApiClient
|
||||||
|
|
||||||
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, REPAIR_OPT_MOVE,
|
||||||
from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE,
|
REPAIR_OPTIONS, REPAIR_SOLUTION, USER_AGENT)
|
||||||
REPAIR_OPT_MOVE, REPAIR_OPTIONS,
|
from .utils import modify_from_config
|
||||||
REPAIR_SOLUTION)
|
|
||||||
from custom_components.irm_kmi.utils import modify_from_config
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -50,7 +49,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 +85,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."""
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
Before Width: | Height: | Size: 666 KiB |
Binary file not shown.
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
@ -9,13 +9,14 @@ from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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 irm_kmi_api.const import POLLEN_NAMES
|
||||||
|
from irm_kmi_api.data import IrmKmiForecast, IrmKmiRadarForecast
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
|
||||||
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 (CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSOR_ICON,
|
||||||
CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_ICON
|
CURRENT_WEATHER_SENSOR_UNITS, CURRENT_WEATHER_SENSORS,
|
||||||
from custom_components.irm_kmi.data import IrmKmiForecast
|
POLLEN_TO_ICON_MAP)
|
||||||
from custom_components.irm_kmi.pollen import PollenParser
|
|
||||||
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
@ -202,7 +202,7 @@
|
||||||
"name": "Pression atmosphérique"
|
"name": "Pression atmosphérique"
|
||||||
},
|
},
|
||||||
"current_rainfall": {
|
"current_rainfall": {
|
||||||
"name": "Precipitation"
|
"name": "Précipitation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
@ -41,8 +40,3 @@ def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry) -> str:
|
||||||
return get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE)
|
return get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE)
|
||||||
|
|
||||||
|
|
||||||
def next_weekday(current, weekday):
|
|
||||||
days_ahead = weekday - current.weekday()
|
|
||||||
if days_ahead < 0:
|
|
||||||
days_ahead += 7
|
|
||||||
return current + timedelta(days_ahead)
|
|
||||||
|
|
|
@ -167,6 +167,7 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
return [f for f in self.coordinator.data.get('radar_forecast')
|
return [f for f in self.coordinator.data.get('radar_forecast')
|
||||||
if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now]
|
if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now]
|
||||||
|
|
||||||
|
# TODO remove on next breaking changes
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
"""Here to keep the DEPRECATED forecast attribute.
|
"""Here to keep the DEPRECATED forecast attribute.
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
[tool.bumpver]
|
[tool.bumpver]
|
||||||
current_version = "0.2.30"
|
current_version = "0.3.2"
|
||||||
version_pattern = "MAJOR.MINOR.PATCH"
|
version_pattern = "MAJOR.MINOR.PATCH"
|
||||||
commit_message = "bump version {old_version} -> {new_version}"
|
commit_message = "bump version {old_version} -> {new_version}"
|
||||||
tag_message = "{new_version}"
|
tag_message = "{new_version}"
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
aiohttp==3.11.12
|
aiohttp>=3.11.13
|
||||||
async-timeout==4.0.3
|
homeassistant==2025.6.1
|
||||||
homeassistant==2025.2.4
|
|
||||||
voluptuous==0.15.2
|
voluptuous==0.15.2
|
||||||
svgwrite==1.4.3
|
irm-kmi-api==0.2.0
|
||||||
aiofile==3.9.0
|
|
|
@ -1,5 +1,5 @@
|
||||||
homeassistant==2025.2.4
|
homeassistant==2025.6.1
|
||||||
pytest_homeassistant_custom_component==0.13.214
|
pytest_homeassistant_custom_component==0.13.252
|
||||||
pytest
|
pytest
|
||||||
freezegun
|
freezegun
|
||||||
isort
|
isort
|
||||||
|
|
|
@ -2,40 +2,34 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Generator
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Generator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from homeassistant.const import CONF_ZONE
|
from homeassistant.const import CONF_ZONE
|
||||||
|
from irm_kmi_api.api import (IrmKmiApiClientHa, IrmKmiApiError,
|
||||||
|
IrmKmiApiParametersError)
|
||||||
|
from irm_kmi_api.data import AnimationFrameData, RadarAnimationData
|
||||||
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 (
|
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, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
CONF_USE_DEPRECATED_FORECAST, DOMAIN, IRM_KMI_TO_HA_CONDITION_MAP,
|
||||||
OPTION_DEPRECATED_FORECAST_TWICE_DAILY, OPTION_STYLE_STD)
|
OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
|
||||||
|
|
||||||
|
|
||||||
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,24 +115,11 @@ 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
|
||||||
|
irm_kmi.get_radar_forecast.return_value = {}
|
||||||
yield irm_kmi
|
yield irm_kmi
|
||||||
|
|
||||||
|
|
||||||
|
@ -174,111 +155,34 @@ 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
|
||||||
|
|
||||||
|
def get_radar_animation_data() -> RadarAnimationData:
|
||||||
|
with open("tests/fixtures/clouds_be.png", "rb") as file:
|
||||||
|
image_data = file.read()
|
||||||
|
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
|
||||||
|
location = file.read()
|
||||||
|
|
||||||
@pytest.fixture()
|
sequence = [
|
||||||
def mock_image_and_nl_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
AnimationFrameData(
|
||||||
"""Return a mocked IrmKmi api client."""
|
time=datetime.fromisoformat("2023-12-26T18:30:00+00:00") + timedelta(minutes=10 * i),
|
||||||
fixture: str = "forecast_nl.json"
|
image=image_data,
|
||||||
|
value=2,
|
||||||
|
position=.5,
|
||||||
|
position_lower=.4,
|
||||||
|
position_higher=.6
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
return RadarAnimationData(
|
||||||
|
sequence=sequence,
|
||||||
with patch(
|
most_recent_image_idx=2,
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
hint="Testing SVG camera",
|
||||||
) as irm_kmi_api_mock:
|
unit="mm/10min",
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
location=location
|
||||||
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
|
|
||||||
|
|
|
@ -7,14 +7,14 @@ from homeassistant.config_entries import SOURCE_USER
|
||||||
from homeassistant.const import CONF_ZONE
|
from homeassistant.const import CONF_ZONE
|
||||||
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 irm_kmi_api.const import OPTION_STYLE_SATELLITE, OPTION_STYLE_STD
|
||||||
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
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_full_user_flow(
|
async def test_full_user_flow(
|
||||||
|
|
|
@ -1,19 +1,14 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from homeassistant.components.weather import ATTR_CONDITION_CLOUDY
|
||||||
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
|
|
||||||
ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
ATTR_CONDITION_RAINY, Forecast)
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.data import CurrentWeatherData, IrmKmiRadarForecast
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
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.data import ProcessedCoordinatorData
|
||||||
ProcessedCoordinatorData)
|
from tests.conftest import get_api_data, get_api_with_data
|
||||||
from custom_components.irm_kmi.pollen import PollenParser
|
|
||||||
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
|
|
||||||
from tests.conftest import get_api_data
|
|
||||||
|
|
||||||
|
|
||||||
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||||
|
@ -25,215 +20,14 @@ async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||||
assert timedelta(minutes=5) <= coordinator.update_interval
|
assert timedelta(minutes=5) <= coordinator.update_interval
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-01-12T07:10:00+00:00'))
|
|
||||||
async def test_warning_data(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("be_forecast_warning.json")
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
|
|
||||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
first = result[0]
|
|
||||||
|
|
||||||
assert first.get('starts_at').replace(tzinfo=None) < datetime.now()
|
|
||||||
assert first.get('ends_at').replace(tzinfo=None) > datetime.now()
|
|
||||||
|
|
||||||
assert first.get('slug') == 'fog'
|
|
||||||
assert first.get('friendly_name') == 'Fog'
|
|
||||||
assert first.get('id') == 7
|
|
||||||
assert first.get('level') == 1
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T17:30:00+00:00'))
|
|
||||||
async def test_current_weather_be() -> None:
|
|
||||||
api_data = get_api_data("forecast.json")
|
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
|
||||||
temperature=7,
|
|
||||||
wind_speed=5,
|
|
||||||
wind_gust_speed=None,
|
|
||||||
wind_bearing=248,
|
|
||||||
pressure=1020,
|
|
||||||
uv_index=.7
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00"))
|
|
||||||
async def test_current_weather_nl() -> None:
|
|
||||||
api_data = get_api_data("forecast_nl.json")
|
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
|
||||||
temperature=11,
|
|
||||||
wind_speed=40,
|
|
||||||
wind_gust_speed=None,
|
|
||||||
wind_bearing=225,
|
|
||||||
pressure=1008,
|
|
||||||
uv_index=1
|
|
||||||
)
|
|
||||||
|
|
||||||
assert expected == result
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00'))
|
|
||||||
async def test_daily_forecast(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
|
|
||||||
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: 'fr'})
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 8
|
|
||||||
assert result[0]['datetime'] == '2023-12-26'
|
|
||||||
assert not result[0]['is_daytime']
|
|
||||||
expected = IrmKmiForecast(
|
|
||||||
datetime='2023-12-27',
|
|
||||||
condition=ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
native_precipitation=0,
|
|
||||||
native_temperature=9,
|
|
||||||
native_templow=4,
|
|
||||||
native_wind_gust_speed=50,
|
|
||||||
native_wind_speed=20,
|
|
||||||
precipitation_probability=0,
|
|
||||||
wind_bearing=180,
|
|
||||||
is_daytime=True,
|
|
||||||
text='Bar',
|
|
||||||
sunrise="2023-12-27T08:44:00+01:00",
|
|
||||||
sunset="2023-12-27T16:43:00+01:00"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[1] == expected
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00'))
|
|
||||||
async def test_hourly_forecast() -> None:
|
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('hourly')
|
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 49
|
|
||||||
|
|
||||||
expected = Forecast(
|
|
||||||
datetime='2023-12-27T02:00:00+01:00',
|
|
||||||
condition=ATTR_CONDITION_RAINY,
|
|
||||||
native_precipitation=.98,
|
|
||||||
native_temperature=8,
|
|
||||||
native_templow=None,
|
|
||||||
native_wind_gust_speed=None,
|
|
||||||
native_wind_speed=15,
|
|
||||||
precipitation_probability=70,
|
|
||||||
wind_bearing=180,
|
|
||||||
native_pressure=1020,
|
|
||||||
is_daytime=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[8] == expected
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-05-31T01:50:00+02:00'))
|
|
||||||
async def test_hourly_forecast_bis() -> None:
|
|
||||||
api_data = get_api_data("no-midnight-bug-31-05-2024T01-55.json").get('for', {}).get('hourly')
|
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
times = ['2024-05-31T01:00:00+02:00', '2024-05-31T02:00:00+02:00', '2024-05-31T03:00:00+02:00',
|
|
||||||
'2024-05-31T04:00:00+02:00', '2024-05-31T05:00:00+02:00', '2024-05-31T06:00:00+02:00',
|
|
||||||
'2024-05-31T07:00:00+02:00', '2024-05-31T08:00:00+02:00', '2024-05-31T09:00:00+02:00']
|
|
||||||
|
|
||||||
actual = [f['datetime'] for f in result[:9]]
|
|
||||||
|
|
||||||
assert actual == times
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-05-31T00:10:00+02:00'))
|
|
||||||
async def test_hourly_forecast_midnight_bug() -> None:
|
|
||||||
# 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')
|
|
||||||
result = await IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
first = Forecast(
|
|
||||||
datetime='2024-05-31T00:00:00+02:00',
|
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
|
||||||
native_precipitation=0,
|
|
||||||
native_temperature=14,
|
|
||||||
native_templow=None,
|
|
||||||
native_wind_gust_speed=None,
|
|
||||||
native_wind_speed=10,
|
|
||||||
precipitation_probability=0,
|
|
||||||
wind_bearing=293,
|
|
||||||
native_pressure=1010,
|
|
||||||
is_daytime=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[0] == first
|
|
||||||
|
|
||||||
times = ['2024-05-31T00:00:00+02:00', '2024-05-31T01:00:00+02:00', '2024-05-31T02:00:00+02:00',
|
|
||||||
'2024-05-31T03:00:00+02:00', '2024-05-31T04:00:00+02:00', '2024-05-31T05:00:00+02:00',
|
|
||||||
'2024-05-31T06:00:00+02:00', '2024-05-31T07:00:00+02:00', '2024-05-31T08:00:00+02:00']
|
|
||||||
|
|
||||||
actual = [f['datetime'] for f in result[:9]]
|
|
||||||
|
|
||||||
assert actual == times
|
|
||||||
|
|
||||||
assert result[24]['datetime'] == '2024-06-01T00:00:00+02:00'
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2024-05-31T00:10:00+02:00'))
|
|
||||||
async def test_daily_forecast_midnight_bug(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
|
|
||||||
api_data = get_api_data("midnight-bug-31-05-2024T00-13.json").get('for', {}).get('daily')
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['datetime'] == '2024-05-31'
|
|
||||||
assert not result[0]['is_daytime']
|
|
||||||
|
|
||||||
assert result[1]['datetime'] == '2024-05-31'
|
|
||||||
assert result[1]['is_daytime']
|
|
||||||
|
|
||||||
assert result[2]['datetime'] == '2024-06-01'
|
|
||||||
assert result[2]['is_daytime']
|
|
||||||
|
|
||||||
assert result[3]['datetime'] == '2024-06-02'
|
|
||||||
assert result[3]['is_daytime']
|
|
||||||
|
|
||||||
|
|
||||||
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 +44,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 +54,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 +86,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',
|
||||||
|
@ -315,77 +109,3 @@ def test_radar_forecast_rain_interval() -> None:
|
||||||
|
|
||||||
assert result[12] == _12
|
assert result[12] == _12
|
||||||
assert result[13] == _13
|
assert result[13] == _13
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2024-06-09T13:40:00+00:00")
|
|
||||||
async def test_datetime_daily_forecast_nl(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json").get('for', {}).get('daily')
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['datetime'] == '2024-06-09'
|
|
||||||
assert result[0]['is_daytime']
|
|
||||||
|
|
||||||
assert result[1]['datetime'] == '2024-06-10'
|
|
||||||
assert not result[1]['is_daytime']
|
|
||||||
|
|
||||||
assert result[2]['datetime'] == '2024-06-10'
|
|
||||||
assert result[2]['is_daytime']
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2024-06-09T13:40:00+00:00")
|
|
||||||
async def test_current_condition_forecast_nl() -> None:
|
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json")
|
|
||||||
result = await IrmKmiCoordinator.current_weather_from_data(api_data)
|
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
|
||||||
condition=ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
temperature=15,
|
|
||||||
wind_speed=26,
|
|
||||||
wind_gust_speed=None,
|
|
||||||
wind_bearing=270,
|
|
||||||
pressure=1010,
|
|
||||||
uv_index=6
|
|
||||||
)
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2024-06-09T13:40:00+00:00")
|
|
||||||
async def test_sunrise_sunset_nl(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("forecast_ams_no_ww.json").get('for', {}).get('daily')
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[0]['sunrise'] == '2024-06-09T05:19:28+02:00'
|
|
||||||
assert result[0]['sunset'] == '2024-06-09T22:01:09+02:00'
|
|
||||||
|
|
||||||
assert result[1]['sunrise'] is None
|
|
||||||
assert result[1]['sunset'] is None
|
|
||||||
|
|
||||||
assert result[2]['sunrise'] == '2024-06-10T05:19:08+02:00'
|
|
||||||
assert result[2]['sunset'] == '2024-06-10T22:01:53+02:00'
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-12-26T18:30:00+01:00")
|
|
||||||
async def test_sunrise_sunset_be(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
|
|
||||||
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
|
||||||
result = await coordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert result[1]['sunrise'] == '2023-12-27T08:44:00+01:00'
|
|
||||||
assert result[1]['sunset'] == '2023-12-27T16:43:00+01:00'
|
|
||||||
|
|
||||||
assert result[2]['sunrise'] == '2023-12-28T08:45:00+01:00'
|
|
||||||
assert result[2]['sunset'] == '2023-12-28T16:43:00+01:00'
|
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import inspect
|
import inspect
|
||||||
from datetime import datetime, timedelta
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.data import CurrentWeatherData
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
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_SENSOR_CLASS,
|
||||||
CURRENT_WEATHER_SENSOR_CLASS
|
CURRENT_WEATHER_SENSOR_UNITS,
|
||||||
from custom_components.irm_kmi.data import CurrentWeatherData, ProcessedCoordinatorData
|
CURRENT_WEATHER_SENSORS)
|
||||||
from custom_components.irm_kmi.sensor import IrmKmiCurrentWeather, IrmKmiCurrentRainfall
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
from tests.conftest import get_api_data
|
from custom_components.irm_kmi.sensor import IrmKmiCurrentRainfall
|
||||||
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
def test_sensors_in_current_weather_data():
|
def test_sensors_in_current_weather_data():
|
||||||
|
@ -33,103 +34,6 @@ def test_sensors_have_class():
|
||||||
assert sensor in weather_sensor_class_keys
|
assert sensor in weather_sensor_class_keys
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("sensor,expected,filename",
|
|
||||||
[
|
|
||||||
('temperature', -2, 'be_forecast_warning.json'),
|
|
||||||
('temperature', 7, 'forecast.json'),
|
|
||||||
('temperature', 15, 'forecast_ams_no_ww.json'),
|
|
||||||
('temperature', 9, 'forecast_out_of_benelux.json'),
|
|
||||||
('temperature', 13, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('temperature', 4, 'high_low_temp.json'),
|
|
||||||
('temperature', 14, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('temperature', 13, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('wind_speed', 10, 'be_forecast_warning.json'),
|
|
||||||
('wind_speed', 5, 'forecast.json'),
|
|
||||||
('wind_speed', 26, 'forecast_ams_no_ww.json'),
|
|
||||||
('wind_speed', 25, 'forecast_out_of_benelux.json'),
|
|
||||||
('wind_speed', 15, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('wind_speed', 30, 'high_low_temp.json'),
|
|
||||||
('wind_speed', 10, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('wind_speed', 15, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('wind_gust_speed', None, 'be_forecast_warning.json'),
|
|
||||||
('wind_gust_speed', None, 'forecast.json'),
|
|
||||||
('wind_gust_speed', None, 'forecast_ams_no_ww.json'),
|
|
||||||
('wind_gust_speed', None, 'forecast_out_of_benelux.json'),
|
|
||||||
('wind_gust_speed', None, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('wind_gust_speed', 50, 'high_low_temp.json'),
|
|
||||||
('wind_gust_speed', None, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('wind_gust_speed', None, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('wind_bearing', 23, 'be_forecast_warning.json'),
|
|
||||||
('wind_bearing', 248, 'forecast.json'),
|
|
||||||
('wind_bearing', 270, 'forecast_ams_no_ww.json'),
|
|
||||||
('wind_bearing', 180, 'forecast_out_of_benelux.json'),
|
|
||||||
('wind_bearing', 293, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('wind_bearing', 180, 'high_low_temp.json'),
|
|
||||||
('wind_bearing', 293, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('wind_bearing', 270, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('uv_index', 0.7, 'be_forecast_warning.json'),
|
|
||||||
('uv_index', 0.7, 'forecast.json'),
|
|
||||||
('uv_index', 6, 'forecast_ams_no_ww.json'),
|
|
||||||
('uv_index', 0.6, 'forecast_out_of_benelux.json'),
|
|
||||||
('uv_index', None, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('uv_index', 0.7, 'high_low_temp.json'),
|
|
||||||
('uv_index', 5.6, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('uv_index', 5.6, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('pressure', 1034, 'be_forecast_warning.json'),
|
|
||||||
('pressure', 1020, 'forecast.json'),
|
|
||||||
('pressure', 1010, 'forecast_ams_no_ww.json'),
|
|
||||||
('pressure', 1013, 'forecast_out_of_benelux.json'),
|
|
||||||
('pressure', 1006, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('pressure', 1022, 'high_low_temp.json'),
|
|
||||||
('pressure', 1010, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('pressure', 1010, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
|
|
||||||
('rainfall', 0.42, 'be_forecast_warning.json'),
|
|
||||||
('rainfall', 0.15, 'forecast_nl.json'),
|
|
||||||
('rainfall', 0, 'forecast.json'),
|
|
||||||
('rainfall', 0.1341, 'forecast_ams_no_ww.json'),
|
|
||||||
('rainfall', 0, 'forecast_out_of_benelux.json'),
|
|
||||||
('rainfall', 0.33, 'forecast_with_rain_on_radar.json'),
|
|
||||||
('rainfall', 0, 'high_low_temp.json'),
|
|
||||||
('rainfall', 0, 'midnight-bug-31-05-2024T00-13.json'),
|
|
||||||
('rainfall', 0, 'no-midnight-bug-31-05-2024T01-55.json'),
|
|
||||||
])
|
|
||||||
async def test_current_weather_sensors(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_config_entry: MockConfigEntry,
|
|
||||||
sensor,
|
|
||||||
expected,
|
|
||||||
filename
|
|
||||||
) -> None:
|
|
||||||
hass.config.time_zone = 'Europe/Brussels'
|
|
||||||
|
|
||||||
api_data = get_api_data(filename)
|
|
||||||
time = api_data.get('obs').get('timestamp')
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat(time) + timedelta(seconds=45, minutes=1))
|
|
||||||
async def run(mock_config_entry_, sensor_, expected_):
|
|
||||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry_)
|
|
||||||
coordinator.data = ProcessedCoordinatorData(
|
|
||||||
current_weather=await IrmKmiCoordinator.current_weather_from_data(api_data),
|
|
||||||
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', {})),
|
|
||||||
country=api_data.get('country')
|
|
||||||
)
|
|
||||||
|
|
||||||
if sensor_ == 'rainfall':
|
|
||||||
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry_)
|
|
||||||
else:
|
|
||||||
s = IrmKmiCurrentWeather(coordinator, mock_config_entry_, sensor_)
|
|
||||||
|
|
||||||
assert s.native_value == expected_
|
|
||||||
|
|
||||||
await run(mock_config_entry, sensor, expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("expected,filename",
|
@pytest.mark.parametrize("expected,filename",
|
||||||
[
|
[
|
||||||
|
@ -145,13 +49,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=api.get_current_weather(tz),
|
||||||
hourly_forecast=await IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
|
hourly_forecast=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 OPTION_STYLE_STD, async_migrate_entry
|
||||||
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,65 +1,26 @@
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.api import IrmKmiApiError
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
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.pollen import PollenParser
|
from tests.conftest import get_api_with_data
|
||||||
from tests.conftest import get_api_data
|
|
||||||
|
|
||||||
|
|
||||||
def test_svg_pollen_parsing():
|
|
||||||
with open("tests/fixtures/pollen.svg", "r") as file:
|
|
||||||
svg_data = file.read()
|
|
||||||
data = PollenParser(svg_data).get_pollen_data()
|
|
||||||
assert data == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'none', 'alder': 'none',
|
|
||||||
'grasses': 'purple', 'ash': 'none'}
|
|
||||||
|
|
||||||
def test_svg_two_pollen_parsing():
|
|
||||||
with open("tests/fixtures/new_two_pollens.svg", "r") as file:
|
|
||||||
svg_data = file.read()
|
|
||||||
data = PollenParser(svg_data).get_pollen_data()
|
|
||||||
assert data == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'active', 'alder': 'none',
|
|
||||||
'grasses': 'red', 'ash': 'none'}
|
|
||||||
|
|
||||||
def test_svg_two_pollen_parsing_2025_update():
|
|
||||||
with open("tests/fixtures/pollens-2025.svg", "r") as file:
|
|
||||||
svg_data = file.read()
|
|
||||||
data = PollenParser(svg_data).get_pollen_data()
|
|
||||||
assert data == {'birch': 'none', 'oak': 'none', 'hazel': 'active', 'mugwort': 'none', 'alder': 'green',
|
|
||||||
'grasses': 'none', 'ash': 'none'}
|
|
||||||
|
|
||||||
def test_pollen_options():
|
|
||||||
assert set(PollenParser.get_option_values()) == {'green', 'yellow', 'orange', 'red', 'purple', 'active', 'none'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_pollen_default_values():
|
|
||||||
assert PollenParser.get_default_data() == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'none',
|
|
||||||
'alder': 'none', 'grasses': 'none', 'ash': 'none'}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_pollen_data_from_api(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
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)
|
|
||||||
expected = {'mugwort': 'none', 'birch': 'none', 'alder': 'none', 'ash': 'none', 'oak': 'none',
|
|
||||||
'grasses': 'purple', 'hazel': 'none'}
|
|
||||||
assert result == expected
|
|
||||||
|
|
||||||
|
|
||||||
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,266 +0,0 @@
|
||||||
import base64
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from custom_components.irm_kmi.radar_data import AnimationFrameData, RadarAnimationData
|
|
||||||
from custom_components.irm_kmi.rain_graph import RainGraph
|
|
||||||
|
|
||||||
|
|
||||||
def get_radar_animation_data() -> RadarAnimationData:
|
|
||||||
with open("tests/fixtures/clouds_be.png", "rb") as file:
|
|
||||||
image_data = file.read()
|
|
||||||
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
|
|
||||||
location = file.read()
|
|
||||||
|
|
||||||
sequence = [
|
|
||||||
AnimationFrameData(
|
|
||||||
time=datetime.fromisoformat("2023-12-26T18:30:00+00:00") + timedelta(minutes=10 * i),
|
|
||||||
image=image_data,
|
|
||||||
value=2,
|
|
||||||
position=.5,
|
|
||||||
position_lower=.4,
|
|
||||||
position_higher=.6
|
|
||||||
)
|
|
||||||
for i in range(10)
|
|
||||||
]
|
|
||||||
|
|
||||||
return RadarAnimationData(
|
|
||||||
sequence=sequence,
|
|
||||||
most_recent_image_idx=2,
|
|
||||||
hint="Testing SVG camera",
|
|
||||||
unit="mm/10min",
|
|
||||||
location=location
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_svg_frame_setup():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
await rain_graph.draw_svg_frame()
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
with open("custom_components/irm_kmi/resources/roboto_medium.ttf", "rb") as file:
|
|
||||||
font_b64 = base64.b64encode(file.read()).decode('utf-8')
|
|
||||||
|
|
||||||
assert '#385E95' in svg_str
|
|
||||||
assert 'font-family: "Roboto Medium";' in svg_str
|
|
||||||
assert font_b64 in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_svg_hint():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.write_hint()
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert "Testing SVG camera" in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_svg_time_bars():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_hour_bars()
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert "19h" in svg_str
|
|
||||||
assert "20h" in svg_str
|
|
||||||
|
|
||||||
assert "<line" in svg_str
|
|
||||||
assert 'stroke="white"' in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_chances_path():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_chances_path()
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert 'fill="#63c8fa"' in svg_str
|
|
||||||
assert 'opacity="0.3"' in svg_str
|
|
||||||
assert 'stroke="none"' in svg_str
|
|
||||||
assert '<path ' in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_data_line():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_data_line()
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert 'fill="none"' in svg_str
|
|
||||||
assert 'stroke-width="2"' in svg_str
|
|
||||||
assert 'stroke="#63c8fa"' in svg_str
|
|
||||||
assert '<path ' in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
async def test_insert_background():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
await rain_graph.insert_background()
|
|
||||||
|
|
||||||
with open("custom_components/irm_kmi/resources/be_white.png", "rb") as file:
|
|
||||||
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
|
||||||
|
|
||||||
svg_str = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert png_b64 in svg_str
|
|
||||||
assert "<image " in svg_str
|
|
||||||
assert 'height="490"' in svg_str
|
|
||||||
assert 'width="640"' in svg_str
|
|
||||||
assert 'x="0"' in svg_str
|
|
||||||
assert 'y="0"' in svg_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_current_frame_line_moving():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_current_fame_line()
|
|
||||||
|
|
||||||
str_svg = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert '<line' in str_svg
|
|
||||||
assert 'id="now"' in str_svg
|
|
||||||
assert 'opacity="1"' in str_svg
|
|
||||||
assert 'stroke="white"' in str_svg
|
|
||||||
assert 'stroke-width="2"' in str_svg
|
|
||||||
assert 'x1="50' in str_svg
|
|
||||||
assert 'x2="50' in str_svg
|
|
||||||
assert 'y1="520' in str_svg
|
|
||||||
assert 'y2="670' in str_svg
|
|
||||||
|
|
||||||
assert 'animateTransform' in str_svg
|
|
||||||
assert 'attributeName="transform"' in str_svg
|
|
||||||
assert 'repeatCount="indefinite"' in str_svg
|
|
||||||
assert 'type="translate"' in str_svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_current_frame_line_index():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_current_fame_line(0)
|
|
||||||
|
|
||||||
str_svg = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert '<line' in str_svg
|
|
||||||
assert 'id="now"' in str_svg
|
|
||||||
assert 'opacity="1"' in str_svg
|
|
||||||
assert 'stroke="white"' in str_svg
|
|
||||||
assert 'stroke-width="2"' in str_svg
|
|
||||||
assert 'x1="50' in str_svg
|
|
||||||
assert 'x2="50' in str_svg
|
|
||||||
assert 'y1="520' in str_svg
|
|
||||||
assert 'y2="670' in str_svg
|
|
||||||
|
|
||||||
assert 'animateTransform' not in str_svg
|
|
||||||
assert 'attributeName="transform"' not in str_svg
|
|
||||||
assert 'repeatCount="indefinite"' not in str_svg
|
|
||||||
assert 'type="translate"' not in str_svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_description_text():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.draw_description_text()
|
|
||||||
|
|
||||||
str_svg = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
assert "18:30" in str_svg
|
|
||||||
assert "18:40" in str_svg
|
|
||||||
assert "18:50" in str_svg
|
|
||||||
assert "19:00" in str_svg
|
|
||||||
assert "19:10" in str_svg
|
|
||||||
assert "19:20" in str_svg
|
|
||||||
assert "19:30" in str_svg
|
|
||||||
assert "19:40" in str_svg
|
|
||||||
assert "19:50" in str_svg
|
|
||||||
assert "20:00" in str_svg
|
|
||||||
|
|
||||||
assert str_svg.count("2mm/10") == 10
|
|
||||||
assert 'class="roboto"' in str_svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_cloud_layer():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
rain_graph.insert_cloud_layer()
|
|
||||||
|
|
||||||
str_svg = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
with open("tests/fixtures/clouds_be.png", "rb") as file:
|
|
||||||
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
|
||||||
|
|
||||||
assert str_svg.count(png_b64) == 10
|
|
||||||
assert str_svg.count('height="490"') == 10
|
|
||||||
assert str_svg.count('width="640"') == 11 # Is also the width of the SVG itself
|
|
||||||
|
|
||||||
|
|
||||||
async def test_draw_location_layer():
|
|
||||||
data = get_radar_animation_data()
|
|
||||||
rain_graph = RainGraph(
|
|
||||||
animation_data=data,
|
|
||||||
background_image_path="custom_components/irm_kmi/resources/be_white.png",
|
|
||||||
background_size=(640, 490),
|
|
||||||
)
|
|
||||||
|
|
||||||
await rain_graph.draw_location()
|
|
||||||
|
|
||||||
str_svg = rain_graph.get_dwg().tostring()
|
|
||||||
|
|
||||||
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
|
|
||||||
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
|
||||||
|
|
||||||
assert png_b64 in str_svg
|
|
|
@ -1,10 +1,12 @@
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
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 +30,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 +45,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 +56,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 +73,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 +94,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 +112,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 +130,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 +148,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, MagicMock
|
||||||
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -7,8 +8,9 @@ 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.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,
|
||||||
from tests.conftest import get_api_data
|
IrmKmiNextWarning)
|
||||||
|
from tests.conftest import get_api_with_data, get_radar_animation_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 +18,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 +41,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 = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
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 +70,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 = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
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 +102,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 = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
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 +167,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 = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
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 +196,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 = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
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,29 @@
|
||||||
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 irm_kmi_api.data import IrmKmiRadarForecast
|
||||||
|
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 tests.conftest import get_api_with_data
|
||||||
from tests.conftest import get_api_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 +38,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 +64,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 +88,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)
|
||||||
|
@ -145,3 +128,38 @@ async def test_radar_forecast_service(
|
||||||
result_service: List[Forecast] = weather.get_forecasts_radar_service(True)
|
result_service: List[Forecast] = weather.get_forecasts_radar_service(True)
|
||||||
|
|
||||||
assert result_service == expected
|
assert result_service == expected
|
||||||
|
|
||||||
|
def is_serializable(x):
|
||||||
|
try:
|
||||||
|
json.dumps(x)
|
||||||
|
return True
|
||||||
|
except (TypeError, OverflowError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def all_serializable(elements: list[Forecast]):
|
||||||
|
for element in elements:
|
||||||
|
for v in element.values():
|
||||||
|
assert is_serializable(v)
|
||||||
|
|
||||||
|
async def test_forecast_types_are_serializable(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_daily()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_twice_daily()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_hourly()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = weather.get_forecasts_radar_service(True)
|
||||||
|
all_serializable(result)
|
Loading…
Add table
Reference in a new issue