Compare commits

..

No commits in common. "main" and "0.2.30" have entirely different histories.
main ... 0.2.30

37 changed files with 2140 additions and 293 deletions

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023-2025 Jules Dejaeghere
Copyright (c) 2023-2024 Jules Dejaeghere
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -119,7 +119,6 @@ The following table summarizes the different known warning types. Other warning
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
| fog | 7 | Fog, Brouillard, Mist, Nebel |
| 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 |
| 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 |

View file

@ -6,11 +6,11 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
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,
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
OPTION_DEPRECATED_FORECAST_NOT_USED, PLATFORMS)
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD,
PLATFORMS)
from .coordinator import IrmKmiCoordinator
from .weather import IrmKmiWeather
@ -22,8 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
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:
# 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()

View file

@ -0,0 +1,125 @@
"""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")

View file

@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt
from . import DOMAIN, IrmKmiCoordinator
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
_LOGGER = logging.getLogger(__name__)

View file

@ -54,7 +54,6 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
"""Return still image to be used as thumbnail."""
if self.coordinator.data.get('animation', None) is not None:
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:
"""Generate an HTTP MJPEG stream from camera images."""

View file

@ -14,15 +14,14 @@ from homeassistant.helpers.selector import (EntitySelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode)
from irm_kmi_api.api import IrmKmiApiClient
from . import OPTION_STYLE_STD
from .api import IrmKmiApiClient
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE,
CONF_LANGUAGE_OVERRIDE_OPTIONS, CONF_STYLE,
CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST,
CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION,
DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
OUT_OF_BENELUX, USER_AGENT)
OPTION_STYLE_STD, OUT_OF_BENELUX)
from .utils import get_config_value
_LOGGER = logging.getLogger(__name__)
@ -51,11 +50,9 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors:
api_data = {}
try:
async with (async_timeout.timeout(60)):
async with async_timeout.timeout(60):
api_data = await IrmKmiApiClient(
session=async_get_clientsession(self.hass),
user_agent=USER_AGENT
).get_forecasts_coord(
session=async_get_clientsession(self.hass)).get_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'long': zone.attributes[ATTR_LONGITUDE]}
)

View file

@ -12,10 +12,7 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY)
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)
from homeassistant.const import Platform, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, DEGREE
DOMAIN: Final = 'irm_kmi'
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
@ -27,6 +24,10 @@ OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
"Buiten de Benelux (Brussel)"]
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_OPTIONS: Final = [
@ -38,6 +39,13 @@ CONF_STYLE_OPTIONS: Final = [
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'
OPTION_DEPRECATED_FORECAST_NOT_USED: Final = 'do_not_use_deprecated_forecast'
OPTION_DEPRECATED_FORECAST_DAILY: Final = 'daily_in_deprecated_forecast'
@ -122,6 +130,23 @@ IRM_KMI_TO_HA_CONDITION_MAP: Final = {
(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 = {
'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree',
'mugwort': 'mdi:sprout', 'oak': 'mdi:tree'
@ -134,7 +159,9 @@ IRM_KMI_NAME: Final = {
'en': 'Royal Meteorological Institute of Belgium'
}
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.3.2'
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
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',
'pressure'}
@ -143,8 +170,7 @@ CURRENT_WEATHER_SENSOR_UNITS: Final = {'temperature': UnitOfTemperature.CELSIUS,
'wind_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
'wind_gust_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
'wind_bearing': DEGREE,
# Need to put '', else the history shows a bar graph instead of a chart
'uv_index': '',
'uv_index': None,
'pressure': UnitOfPressure.HPA}
CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE,

View file

@ -1,8 +1,12 @@
"""DataUpdateCoordinator for the IRM KMI integration."""
import logging
from datetime import timedelta
from datetime import datetime, timedelta
from statistics import mean
from typing import List
import urllib.parse
import async_timeout
from homeassistant.components.weather import Forecast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
from homeassistant.core import HomeAssistant
@ -13,15 +17,21 @@ from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator, UpdateFailed)
from homeassistant.util import dt
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 IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
from .const import OUT_OF_BENELUX, USER_AGENT
from .data import ProcessedCoordinatorData
from .utils import disable_from_config, get_config_value, preferred_language
from .const import MAP_WARNING_ID_TO_SLUG as SLUG_MAP
from .const import (OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP,
WEEKDAYS)
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__)
@ -40,7 +50,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(minutes=7),
)
self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP)
self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass))
self._zone = get_config_value(entry, CONF_ZONE)
self._dark_mode = get_config_value(entry, CONF_DARK_MODE)
self._style = get_config_value(entry, CONF_STYLE)
@ -57,20 +67,19 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
# 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()
self._api_client.expire_cache()
if (zone := self.hass.states.get(self._zone)) is None:
raise UpdateFailed(f"Zone '{self._zone}' not found")
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with async_timeout.timeout(60):
await self._api.refresh_forecasts_coord(
api_data = await self._api_client.get_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'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:
if self.last_update_success_time is not None \
@ -81,7 +90,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
f"Last success time is: {self.last_update_success_time}")
if self._api.get_city() in OUT_OF_BENELUX:
if api_data.get('cityName', None) in OUT_OF_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"this")
@ -99,51 +108,414 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
)
return ProcessedCoordinatorData()
return await self.process_api_data()
return await self.process_api_data(api_data)
async def async_refresh(self) -> None:
"""Refresh data and log errors."""
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
async def process_api_data(self) -> ProcessedCoordinatorData:
"""From the API data, create the object that will be used in the entities"""
tz = await dt.async_get_time_zone('Europe/Brussels')
lang = preferred_language(self.hass, self.config_entry)
try:
pollen = await self._api.get_pollen()
except IrmKmiApiError as err:
_LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.")
pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \
if self.data is not None else PollenParser.get_unavailable_data()
async def _async_animation_data(self, api_data: dict) -> RainGraph | None:
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
Frames from the API are merged with the background map and the location marker to create each frame."""
animation_data = api_data.get('animation', {}).get('sequence')
localisation_layer_url = api_data.get('animation', {}).get('localisationLayer')
country = api_data.get('country', '')
try:
radar_animation = self._api.get_animation_data(tz, lang, self._style, self._dark_mode)
animation = await RainGraph(radar_animation,
country=self._api.get_country(),
style=self._style,
tz=tz,
dark_mode=self._dark_mode,
api_client=self._api
).build()
except ValueError:
animation = None
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
return None
# 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)
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
]
return ProcessedCoordinatorData(
current_weather=self._api.get_current_weather(tz),
daily_forecast=daily_forecast,
hourly_forecast=self._api.get_hourly_forecast(tz),
radar_forecast=self._api.get_radar_forecast(),
animation=animation,
warnings=self._api.get_warnings(lang),
pollen=pollen,
country=self._api.get_country()
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:
_LOGGER.debug(f"Requesting pollen SVG at url {svg_url}")
pollen_svg: str = await self._api_client.get_svg(svg_url)
except IrmKmiApiError as err:
_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()) \
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:
pressure = float(now_hourly.get('pressure', None)) if now_hourly is not None else None
except (TypeError, ValueError):
pressure = None
try:
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
except (TypeError, ValueError):
wind_speed = None
try:
wind_gust_speed = float(now_hourly.get('windPeakSpeedKm', None)) if now_hourly is not None else None
except (TypeError, ValueError):
wind_gust_speed = None
try:
temperature = float(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:
current_weather['wind_bearing'] = None
# Since June 2024, the NL weather does not include the condition in the 'ww' key, so we get it from the current
# hourly forecast instead if it is missing
if current_weather['condition'] is None:
try:
current_weather['condition'] = CDT_MAP.get((int(now_hourly.get('ww')), now_hourly.get('dayNight')))
except (TypeError, ValueError, AttributeError):
current_weather['condition'] = None
return current_weather
@staticmethod
async def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
"""Parse data from the API to create a list of hourly forecasts"""
if data is None or not isinstance(data, list) or len(data) == 0:
return None
forecasts = list()
tz = await dt.async_get_time_zone('Europe/Brussels')
day = dt.now(time_zone=tz).replace(hour=0, minute=0, second=0, microsecond=0)
for idx, f in enumerate(data):
if 'dateShow' in f and idx > 0:
day = day + timedelta(days=1)
hour = f.get('hour', None)
if hour is None:
continue
day = day.replace(hour=int(hour))
precipitation_probability = None
if f.get('precipChance', None) is not None:
precipitation_probability = int(f.get('precipChance'))
ww = None
if f.get('ww', None) is not None:
ww = int(f.get('ww'))
wind_bearing = None
if f.get('windDirectionText', {}).get('en') != 'VAR':
try:
wind_bearing = (float(f.get('windDirection')) + 180) % 360
except (TypeError, ValueError):
pass
forecast = Forecast(
datetime=day.isoformat(),
condition=CDT_MAP.get((ww, f.get('dayNight', None)), None),
native_precipitation=f.get('precipQuantity', None),
native_temperature=f.get('temp', None),
native_templow=None,
native_wind_gust_speed=f.get('windPeakSpeedKm', None),
native_wind_speed=f.get('windSpeedKm', None),
precipitation_probability=precipitation_probability,
wind_bearing=wind_bearing,
native_pressure=f.get('pressure', None),
is_daytime=f.get('dayNight', None) == 'd'
)
forecasts.append(forecast)
return forecasts
@staticmethod
def radar_list_to_forecast(data: dict | None) -> List[IrmKmiRadarForecast] | None:
"""Create a list of short term forecasts for rain based on the data provided by the rain radar"""
if data is None:
return None
sequence = data.get("sequence", [])
unit = data.get("unit", {}).get("en", None)
ratios = [f['value'] / f['position'] for f in sequence if f['position'] > 0]
if len(ratios) > 0:
ratio = mean(ratios)
else:
ratio = 0
forecast = list()
for f in sequence:
forecast.append(
IrmKmiRadarForecast(
datetime=f.get("time"),
native_precipitation=f.get('value'),
rain_forecast_max=round(f.get('positionHigher') * ratio, 2),
rain_forecast_min=round(f.get('positionLower') * ratio, 2),
might_rain=f.get('positionHigher') > 0,
unit=unit
)
)
return forecast
async def daily_list_to_forecast(self, data: List[dict] | None) -> List[IrmKmiForecast] | None:
"""Parse data from the API to create a list of daily forecasts"""
if data is None or not isinstance(data, list) or len(data) == 0:
return None
forecasts = list()
lang = preferred_language(self.hass, self.config_entry)
tz = await dt.async_get_time_zone('Europe/Brussels')
forecast_day = dt.now(tz)
for (idx, f) in enumerate(data):
precipitation = None
if f.get('precipQuantity', None) is not None:
try:
precipitation = float(f.get('precipQuantity'))
except (TypeError, ValueError):
pass
native_wind_gust_speed = None
if f.get('wind', {}).get('peakSpeed') is not None:
try:
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
except (TypeError, ValueError):
pass
wind_bearing = None
if f.get('wind', {}).get('dirText', {}).get('en') != 'VAR':
try:
wind_bearing = (float(f.get('wind', {}).get('dir')) + 180) % 360
except (TypeError, ValueError):
pass
is_daytime = f.get('dayNight', None) == 'd'
day_name = f.get('dayName', {}).get('en', None)
timestamp = f.get('timestamp', None)
if timestamp is not None:
forecast_day = datetime.fromisoformat(timestamp)
elif day_name in WEEKDAYS:
forecast_day = next_weekday(forecast_day, WEEKDAYS.index(day_name))
elif day_name in ['Today', 'Tonight']:
forecast_day = dt.now(tz)
elif day_name == 'Tomorrow':
forecast_day = dt.now(tz) + timedelta(days=1)
sunrise_sec = f.get('dawnRiseSeconds', None)
if sunrise_sec is None:
sunrise_sec = f.get('sunRise', None)
sunrise = None
if sunrise_sec is not None:
try:
sunrise = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunrise_sec)))
except (TypeError, ValueError):
pass
sunset_sec = f.get('dawnSetSeconds', None)
if sunset_sec is None:
sunset_sec = f.get('sunSet', None)
sunset = None
if sunset_sec is not None:
try:
sunset = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunset_sec)))
except (TypeError, ValueError):
pass
forecast = IrmKmiForecast(
datetime=(forecast_day.strftime('%Y-%m-%d')),
condition=CDT_MAP.get((f.get('ww1', None), f.get('dayNight', None)), None),
native_precipitation=precipitation,
native_temperature=f.get('tempMax', None),
native_templow=f.get('tempMin', None),
native_wind_gust_speed=native_wind_gust_speed,
native_wind_speed=f.get('wind', {}).get('speed'),
precipitation_probability=f.get('precipChance', None),
wind_bearing=wind_bearing,
is_daytime=is_daytime,
text=f.get('text', {}).get(lang, ""),
sunrise=sunrise.isoformat() if sunrise is not None else None,
sunset=sunset.isoformat() if sunset is not None else None
)
# Swap temperature and templow if needed
if (forecast['native_templow'] is not None
and forecast['native_temperature'] is not None
and forecast['native_templow'] > forecast['native_temperature']):
(forecast['native_templow'], forecast['native_temperature']) = \
(forecast['native_temperature'], forecast['native_templow'])
forecasts.append(forecast)
return forecasts
async def create_rain_graph(self,
radar_animation: RadarAnimationData,
api_animation_data: List[dict],
country: str,
images_from_api: list[str],
) -> RainGraph:
"""Create a RainGraph object that is ready to output animated and still SVG images"""
sequence: List[AnimationFrameData] = list()
tz = await dt.async_get_time_zone(self.hass.config.time_zone)
current_time = dt.now(time_zone=tz)
most_recent_frame = None
for idx, item in enumerate(api_animation_data):
frame = AnimationFrameData(
image=images_from_api[idx],
time=datetime.fromisoformat(item.get('time')) if item.get('time', None) is not None else None,
value=item.get('value', 0),
position=item.get('position', 0),
position_lower=item.get('positionLower', 0),
position_higher=item.get('positionHigher', 0)
)
sequence.append(frame)
if most_recent_frame is None and current_time < frame['time']:
most_recent_frame = idx - 1 if idx > 0 else idx
radar_animation['sequence'] = sequence
radar_animation['most_recent_image_idx'] = most_recent_frame
satellite_mode = self._style == OPTION_STYLE_SATELLITE
if country == 'NL':
image_path = "custom_components/irm_kmi/resources/nl.png"
bg_size = (640, 600)
else:
image_path = (f"custom_components/irm_kmi/resources/be_"
f"{'satellite' if satellite_mode else 'black' if self._dark_mode else 'white'}.png")
bg_size = (640, 490)
return await RainGraph(radar_animation, image_path, bg_size, tz=tz, config_dir=self.hass.config.config_dir,
dark_mode=self._dark_mode, api_client=self._api_client).build()
def warnings_from_data(self, warning_data: list | None) -> List[WarningData]:
"""Create a list of warning data instances based on the api data"""
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
return []
lang = preferred_language(self.hass, self.config_entry)
result = list()
for data in warning_data:
try:
warning_id = int(data.get('warningType', {}).get('id'))
start = datetime.fromisoformat(data.get('fromTimestamp', None))
end = datetime.fromisoformat(data.get('toTimestamp', None))
except (TypeError, ValueError):
# Without this data, the warning is useless
continue
try:
level = int(data.get('warningLevel'))
except TypeError:
level = None
result.append(
WarningData(
slug=SLUG_MAP.get(warning_id, 'unknown'),
id=warning_id,
level=level,
friendly_name=data.get('warningType', {}).get('name', {}).get(lang, ''),
text=data.get('text', {}).get(lang, ''),
starts_at=start,
ends_at=end
)
)
return result if len(result) > 0 else []

View file

@ -1,8 +1,41 @@
"""Data classes for IRM KMI integration"""
from datetime import datetime
from typing import List, TypedDict
from homeassistant.components.weather import Forecast
from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
from irm_kmi_api.rain_graph import RainGraph
from .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):

View file

@ -9,7 +9,8 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
"requirements": [
"irm-kmi-api==0.2.0"
"svgwrite==1.4.3",
"aiofile==3.9.0"
],
"version": "0.3.2"
"version": "0.2.30"
}

View file

@ -0,0 +1,108 @@
"""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

View file

@ -0,0 +1,35 @@
"""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

View file

@ -0,0 +1,412 @@
"""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)

View file

@ -8,12 +8,13 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from irm_kmi_api.api import IrmKmiApiClient
from . import async_reload_entry
from .const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE, REPAIR_OPT_MOVE,
REPAIR_OPTIONS, REPAIR_SOLUTION, USER_AGENT)
from .utils import modify_from_config
from custom_components.irm_kmi import async_reload_entry
from custom_components.irm_kmi.api import IrmKmiApiClient
from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE,
REPAIR_OPT_MOVE, REPAIR_OPTIONS,
REPAIR_SOLUTION)
from custom_components.irm_kmi.utils import modify_from_config
_LOGGER = logging.getLogger(__name__)
@ -49,9 +50,7 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
try:
async with async_timeout.timeout(10):
api_data = await IrmKmiApiClient(
session=async_get_clientsession(self.hass),
user_agent=USER_AGENT
).get_forecasts_coord(
session=async_get_clientsession(self.hass)).get_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'long': zone.attributes[ATTR_LONGITUDE]}
)
@ -85,8 +84,8 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
async def async_create_fix_flow(
_hass: HomeAssistant,
_issue_id: str,
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> OutOfBeneluxRepairFlow:
"""Create flow."""

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

View file

@ -9,14 +9,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
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 . import DOMAIN, IrmKmiCoordinator
from .const import (CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSOR_ICON,
CURRENT_WEATHER_SENSOR_UNITS, CURRENT_WEATHER_SENSORS,
POLLEN_TO_ICON_MAP)
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
from custom_components.irm_kmi.const import POLLEN_NAMES, POLLEN_TO_ICON_MAP, CURRENT_WEATHER_SENSOR_UNITS, \
CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_ICON
from custom_components.irm_kmi.data import IrmKmiForecast
from custom_components.irm_kmi.pollen import PollenParser
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
_LOGGER = logging.getLogger(__name__)

View file

@ -202,7 +202,7 @@
"name": "Pression atmosphérique"
},
"current_rainfall": {
"name": "Précipitation"
"name": "Precipitation"
}
}
},

View file

@ -1,4 +1,5 @@
import logging
from datetime import timedelta
from typing import Any
from homeassistant.config_entries import ConfigEntry
@ -40,3 +41,8 @@ def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry) -> str:
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)

View file

@ -167,7 +167,6 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
return [f for f in self.coordinator.data.get('radar_forecast')
if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now]
# TODO remove on next breaking changes
@property
def extra_state_attributes(self) -> dict:
"""Here to keep the DEPRECATED forecast attribute.

View file

@ -1,5 +1,5 @@
[tool.bumpver]
current_version = "0.3.2"
current_version = "0.2.30"
version_pattern = "MAJOR.MINOR.PATCH"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"

View file

@ -1,4 +1,6 @@
aiohttp>=3.11.13
homeassistant==2025.6.1
aiohttp==3.11.12
async-timeout==4.0.3
homeassistant==2025.2.4
voluptuous==0.15.2
irm-kmi-api==0.2.0
svgwrite==1.4.3
aiofile==3.9.0

View file

@ -1,5 +1,5 @@
homeassistant==2025.6.1
pytest_homeassistant_custom_component==0.13.252
homeassistant==2025.2.4
pytest_homeassistant_custom_component==0.13.214
pytest
freezegun
isort

View file

@ -2,34 +2,40 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from typing import Generator
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
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,
load_fixture)
from custom_components.irm_kmi import OPTION_STYLE_STD
from custom_components.irm_kmi.api import (IrmKmiApiError,
IrmKmiApiParametersError)
from custom_components.irm_kmi.const import (
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
CONF_USE_DEPRECATED_FORECAST, DOMAIN, IRM_KMI_TO_HA_CONDITION_MAP,
OPTION_DEPRECATED_FORECAST_NOT_USED,
OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
OPTION_DEPRECATED_FORECAST_TWICE_DAILY, OPTION_STYLE_STD)
def get_api_data(fixture: str) -> dict:
return json.loads(load_fixture(fixture))
def get_api_with_data(fixture: str) -> IrmKmiApiClientHa:
api = IrmKmiApiClientHa(session=MagicMock(), user_agent='', cdt_map=IRM_KMI_TO_HA_CONDITION_MAP)
api._api_data = get_api_data(fixture)
return api
async def patched(url: str, params: dict | None = None) -> bytes:
if "cdn.knmi.nl" in url:
file_name = "tests/fixtures/clouds_nl.png"
elif "app.meteo.be/services/appv4/?s=getIncaImage" in url:
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)
@ -115,11 +121,24 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
"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
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:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
irm_kmi.get_radar_forecast.return_value = {}
yield irm_kmi
@ -155,34 +174,111 @@ 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]:
"""Return a mocked IrmKmi api client."""
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiParametersError
irm_kmi.get_forecasts_coord.side_effect = IrmKmiApiParametersError
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()
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)
]
@pytest.fixture()
def mock_image_and_nl_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast_nl.json"
return RadarAnimationData(
sequence=sequence,
most_recent_image_idx=2,
hint="Testing SVG camera",
unit="mm/10min",
location=location
)
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_image_and_high_temp_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "high_low_temp.json"
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_image_and_simple_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast.json"
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_svg.return_value = ""
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "pollen.svg"
svg_str = load_fixture(fixture)
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_svg.return_value = svg_str
yield irm_kmi
@pytest.fixture()
def mock_exception_irm_kmi_api_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_svg.side_effect = IrmKmiApiParametersError
yield irm_kmi
@pytest.fixture()
def mock_coordinator(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked coordinator."""
with patch(
"custom_components.irm_kmi.IrmKmiCoordinator", autospec=True
) as coordinator_mock:
coord = coordinator_mock.return_value
coord._async_animation_data.return_value = {'animation': None}
yield coord
@pytest.fixture()
def mock_irm_kmi_api_works_but_pollen_and_radar_fail(request: pytest.FixtureRequest) -> Generator[
None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast.json"
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
irm_kmi.get_svg.side_effect = IrmKmiApiError
irm_kmi.get_image.side_effect = IrmKmiApiError
yield irm_kmi

View file

@ -7,14 +7,14 @@ from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ZONE
from homeassistant.core import HomeAssistant
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 custom_components.irm_kmi import async_migrate_entry
from custom_components.irm_kmi.const import (
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
OPTION_DEPRECATED_FORECAST_NOT_USED)
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_SATELLITE,
OPTION_STYLE_STD)
async def test_full_user_flow(

View file

@ -1,14 +1,19 @@
from datetime import timedelta
from datetime import datetime, timedelta
from homeassistant.components.weather import ATTR_CONDITION_CLOUDY
from freezegun import freeze_time
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_RAINY, Forecast)
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 custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
from custom_components.irm_kmi.data import ProcessedCoordinatorData
from tests.conftest import get_api_data, get_api_with_data
from custom_components.irm_kmi.data import (CurrentWeatherData, IrmKmiForecast,
ProcessedCoordinatorData)
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(
@ -20,14 +25,215 @@ async def test_jules_forgot_to_revert_update_interval_before_pushing(
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(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api_works_but_pollen_and_radar_fail
):
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
coordinator._api._api_data = get_api_data("forecast.json")
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = "."
mock_config_entry.add_to_hass(hass)
result = await coordinator.process_api_data()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
result = await coordinator._async_update_data()
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
@ -44,7 +250,7 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail(
pollen={'foo': 'bar'}
)
coordinator.data = existing_data
result = await coordinator.process_api_data()
result = await coordinator._async_update_data()
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
@ -54,8 +260,8 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail(
def test_radar_forecast() -> None:
api = get_api_with_data("forecast.json")
result = api.get_radar_forecast()
api_data = get_api_data("forecast.json")
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
expected = [
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
@ -86,8 +292,8 @@ def test_radar_forecast() -> None:
def test_radar_forecast_rain_interval() -> None:
api = get_api_with_data('forecast_with_rain_on_radar.json')
result = api.get_radar_forecast()
api_data = get_api_data('forecast_with_rain_on_radar.json')
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
_12 = IrmKmiRadarForecast(
datetime='2024-05-30T18:00:00+02:00',
@ -109,3 +315,77 @@ def test_radar_forecast_rain_interval() -> None:
assert result[12] == _12
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'

View file

@ -1,18 +1,17 @@
import inspect
from zoneinfo import ZoneInfo
from datetime import datetime, timedelta
import pytest
from freezegun import freeze_time
from homeassistant.core import HomeAssistant
from irm_kmi_api.data import CurrentWeatherData
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.irm_kmi import IrmKmiCoordinator
from custom_components.irm_kmi.const import (CURRENT_WEATHER_SENSOR_CLASS,
CURRENT_WEATHER_SENSOR_UNITS,
CURRENT_WEATHER_SENSORS)
from custom_components.irm_kmi.data import ProcessedCoordinatorData
from custom_components.irm_kmi.sensor import IrmKmiCurrentRainfall
from tests.conftest import get_api_with_data
from custom_components.irm_kmi.const import CURRENT_WEATHER_SENSORS, CURRENT_WEATHER_SENSOR_UNITS, \
CURRENT_WEATHER_SENSOR_CLASS
from custom_components.irm_kmi.data import CurrentWeatherData, ProcessedCoordinatorData
from custom_components.irm_kmi.sensor import IrmKmiCurrentWeather, IrmKmiCurrentRainfall
from tests.conftest import get_api_data
def test_sensors_in_current_weather_data():
@ -34,6 +33,103 @@ def test_sensors_have_class():
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",
[
@ -49,14 +145,13 @@ async def test_current_rainfall_unit(
) -> None:
hass.config.time_zone = 'Europe/Brussels'
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
api = get_api_with_data(filename)
tz = ZoneInfo("Europe/Brussels")
api_data = get_api_data(filename)
coordinator.data = ProcessedCoordinatorData(
current_weather=api.get_current_weather(tz),
hourly_forecast=api.get_hourly_forecast(tz),
radar_forecast=api.get_radar_forecast(),
country=api.get_country()
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')
)
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry)

View file

@ -8,17 +8,18 @@ from homeassistant.const import CONF_ZONE
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.irm_kmi import OPTION_STYLE_STD, async_migrate_entry
from custom_components.irm_kmi import async_migrate_entry
from custom_components.irm_kmi.const import (
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
OPTION_DEPRECATED_FORECAST_NOT_USED)
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD)
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api: AsyncMock,
mock_coordinator: AsyncMock
) -> None:
"""Test the IRM KMI configuration entry loading/unloading."""
hass.states.async_set(
@ -56,7 +57,7 @@ async def test_config_entry_not_ready(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1
assert mock_exception_irm_kmi_api.get_forecasts_coord.call_count == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View file

@ -1,26 +1,65 @@
from unittest.mock import AsyncMock
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 custom_components.irm_kmi import IrmKmiCoordinator
from tests.conftest import get_api_with_data
from custom_components.irm_kmi.pollen import PollenParser
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(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_exception_irm_kmi_api_svg_pollen: AsyncMock
) -> None:
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
api = get_api_with_data("be_forecast_warning.json")
api_data = get_api_data("be_forecast_warning.json")
api.get_svg = AsyncMock()
api.get_svg.side_effect = IrmKmiApiError
coordinator._api = api
result = await coordinator.process_api_data()
result = await coordinator._async_pollen_data(api_data)
expected = PollenParser.get_unavailable_data()
assert result['pollen'] == expected
assert result == expected

266
tests/test_rain_graph.py Normal file
View file

@ -0,0 +1,266 @@
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

View file

@ -1,12 +1,10 @@
import json
import logging
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import issue_registry
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
load_fixture)
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE,
@ -30,11 +28,6 @@ async def get_repair_flow(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
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()
ir = issue_registry.async_get(hass)
issue = ir.async_get_issue(DOMAIN, "zone_moved")
@ -45,6 +38,7 @@ async def get_repair_flow(
async def test_repair_triggers_when_out_of_benelux(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
hass.states.async_set(
@ -56,8 +50,6 @@ async def test_repair_triggers_when_out_of_benelux(
mock_config_entry.add_to_hass(hass)
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()
ir = issue_registry.async_get(hass)
@ -73,6 +65,7 @@ async def test_repair_triggers_when_out_of_benelux(
async def test_repair_flow(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_in_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -94,6 +87,7 @@ async def test_repair_flow(
async def test_repair_flow_invalid_choice(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_in_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -112,6 +106,7 @@ async def test_repair_flow_invalid_choice(
async def test_repair_flow_api_error(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_get_forecast_api_error_repair: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -130,6 +125,7 @@ async def test_repair_flow_api_error(
async def test_repair_flow_out_of_benelux(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_out_of_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -148,6 +144,7 @@ async def test_repair_flow_out_of_benelux(
async def test_repair_flow_delete_entry(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
repair_flow = await get_repair_flow(hass, mock_config_entry)

View file

@ -1,5 +1,4 @@
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
from freezegun import freeze_time
from homeassistant.core import HomeAssistant
@ -8,9 +7,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.irm_kmi import IrmKmiCoordinator
from custom_components.irm_kmi.binary_sensor import IrmKmiWarning
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
from custom_components.irm_kmi.sensor import (IrmKmiNextSunMove,
IrmKmiNextWarning)
from tests.conftest import get_api_with_data, get_radar_animation_data
from custom_components.irm_kmi.sensor import IrmKmiNextSunMove, IrmKmiNextWarning
from tests.conftest import get_api_data
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
@ -18,10 +16,10 @@ async def test_warning_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api = get_api_with_data("be_forecast_warning.json")
api_data = get_api_data("be_forecast_warning.json")
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
result = api.get_warnings('en')
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
coordinator.data = {'warnings': result}
warning = IrmKmiWarning(coordinator, mock_config_entry)
@ -41,18 +39,15 @@ async def test_warning_data_unknown_lang(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
# When language is unknown, default to english setting
hass.config.language = "foo"
api = get_api_with_data("be_forecast_warning.json")
api_data = get_api_data("be_forecast_warning.json")
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 = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
result = await coordinator.process_api_data()
coordinator.data = {'warnings': result['warnings']}
coordinator.data = {'warnings': result}
warning = IrmKmiWarning(coordinator, mock_config_entry)
warning.hass = hass
@ -70,19 +65,15 @@ async def test_next_warning_when_data_available(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api = get_api_with_data("be_forecast_warning.json")
api_data = get_api_data("be_forecast_warning.json")
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'})
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 = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
result = await coordinator.process_api_data()
coordinator.data = {'warnings': result['warnings']}
coordinator.data = {'warnings': result}
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
warning.hass = hass
@ -102,16 +93,12 @@ async def test_next_warning_none_when_only_active_warnings(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api = get_api_with_data("be_forecast_warning.json")
api_data = get_api_data("be_forecast_warning.json")
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 = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
result = await coordinator.process_api_data()
coordinator.data = {'warnings': result['warnings']}
coordinator.data = {'warnings': result}
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
warning.hass = hass
@ -167,16 +154,13 @@ async def test_next_sunrise_sunset(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api = get_api_with_data("forecast.json")
api_data = get_api_data("forecast.json")
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.process_api_data()
result = await coordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
coordinator.data = {'daily_forecast': result['daily_forecast']}
coordinator.data = {'daily_forecast': result}
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
@ -196,16 +180,13 @@ async def test_next_sunrise_sunset_bis(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api = get_api_with_data("forecast.json")
api_data = get_api_data("forecast.json")
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.process_api_data()
result = await coordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
coordinator.data = {'daily_forecast': result['daily_forecast']}
coordinator.data = {'daily_forecast': result}
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')

View file

@ -1,29 +1,35 @@
import json
import os
from datetime import datetime
from typing import List
from unittest.mock import AsyncMock
from freezegun import freeze_time
from homeassistant.components.weather import Forecast
from homeassistant.core import HomeAssistant
from irm_kmi_api.data import IrmKmiRadarForecast
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
load_fixture)
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather
from custom_components.irm_kmi.data import ProcessedCoordinatorData
from tests.conftest import get_api_with_data
from custom_components.irm_kmi.data import (ProcessedCoordinatorData)
from custom_components.irm_kmi.radar_data import IrmKmiRadarForecast
from tests.conftest import get_api_data
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00"))
async def test_weather_nl(
hass: HomeAssistant,
mock_image_and_nl_forecast_irm_kmi_api: AsyncMock,
mock_config_entry: MockConfigEntry
) -> None:
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
forecast = json.loads(load_fixture("forecast_nl.json"))
coordinator._api._api_data = forecast
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator.data = await coordinator.process_api_data()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
await coordinator.async_refresh()
print(coordinator.data)
weather = IrmKmiWeather(coordinator, mock_config_entry)
result = await weather.async_forecast_daily()
@ -38,14 +44,19 @@ async def test_weather_nl(
@freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00"))
async def test_weather_higher_temp_at_night(
hass: HomeAssistant,
mock_image_and_high_temp_irm_kmi_api: AsyncMock,
mock_config_entry: MockConfigEntry
) -> None:
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
forecast = json.loads(load_fixture("high_low_temp.json"))
coordinator._api._api_data = forecast
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator.data = await coordinator.process_api_data()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
await coordinator.async_refresh()
weather = IrmKmiWeather(coordinator, mock_config_entry)
result: List[Forecast] = await weather.async_forecast_daily()
@ -64,13 +75,18 @@ async def test_weather_higher_temp_at_night(
@freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00"))
async def test_forecast_attribute_same_as_service_call(
hass: HomeAssistant,
mock_image_and_simple_forecast_irm_kmi_api: AsyncMock,
mock_config_entry_with_deprecated: MockConfigEntry
) -> None:
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
forecast = json.loads(load_fixture("forecast.json"))
coordinator._api._api_data = forecast
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator.data = await coordinator.process_api_data()
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
await coordinator.async_refresh()
weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated)
@ -88,10 +104,11 @@ async def test_radar_forecast_service(
hass.config.time_zone = 'Europe/Brussels'
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
coordinator._api = get_api_with_data("forecast.json")
api_data = get_api_data("forecast.json")
data = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
coordinator.data = ProcessedCoordinatorData(
radar_forecast=coordinator._api.get_radar_forecast()
radar_forecast=data
)
weather = IrmKmiWeather(coordinator, mock_config_entry)
@ -128,38 +145,3 @@ async def test_radar_forecast_service(
result_service: List[Forecast] = weather.get_forecasts_radar_service(True)
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)