Add docstring and reorder methods

This commit is contained in:
Jules 2025-05-04 11:38:10 +02:00
parent 760a13b19f
commit 717f987083
Signed by: jdejaegh
GPG key ID: 99D6D184CA66933A
6 changed files with 359 additions and 317 deletions

View file

@ -20,7 +20,6 @@ from .data import (AnimationFrameData, CurrentWeatherData, Forecast,
IrmKmiForecast, IrmKmiRadarForecast, RadarAnimationData, IrmKmiForecast, IrmKmiRadarForecast, RadarAnimationData,
WarningData) WarningData)
from .pollen import PollenParser from .pollen import PollenParser
from .utils import next_weekday
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -37,7 +36,6 @@ class IrmKmiApiParametersError(IrmKmiApiError):
"""Exception to indicate a parameter error.""" """Exception to indicate a parameter error."""
class IrmKmiApiClient: class IrmKmiApiClient:
"""API client for IRM KMI weather data""" """API client for IRM KMI weather data"""
COORD_DECIMALS = 6 COORD_DECIMALS = 6
@ -95,6 +93,20 @@ class IrmKmiApiClient:
r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params) r: bytes = await self._api_wrapper(base_url=url, params={} if params is None else params)
return r.decode() return r.decode()
def expire_cache(self) -> None:
"""
Expire items from the cache which have not been accessed since self._cache_max_age (default 2h).
Must be called regularly to clear the cache.
"""
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")
async def _api_wrapper( async def _api_wrapper(
self, self,
params: dict, params: dict,
@ -151,20 +163,6 @@ class IrmKmiApiClient:
"""Get API key.""" """Get API key."""
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest() return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
def expire_cache(self) -> None:
"""
Expire items from the cache which have not been accessed since self._cache_max_age (default 2h).
Must be called regularly to clear the cache.
"""
now = time.time()
keys_to_delete = set()
for key, value in self._cache.items():
if now - value['timestamp'] > self._cache_max_age:
keys_to_delete.add(key)
for key in keys_to_delete:
del self._cache[key]
_LOGGER.info(f"Expired {len(keys_to_delete)} elements from API cache")
class IrmKmiApiClientHa(IrmKmiApiClient): class IrmKmiApiClientHa(IrmKmiApiClient):
"""API client for IRM KMI weather data with additional methods to integrate easily with Home Assistant""" """API client for IRM KMI weather data with additional methods to integrate easily with Home Assistant"""
@ -183,22 +181,6 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
""" """
self._api_data = await self.get_forecasts_coord(coord) self._api_data = await self.get_forecasts_coord(coord)
def get_city(self) -> str | None:
"""
Get the city for which we currently have the forecast
:return: city name as str or None if unavailable
"""
return self._api_data.get('cityName', None)
def get_country(self) -> str | None:
"""
Get the two-letters country code for which we currently have the forecast
:return: country code as str or None if unavailable
"""
return self._api_data.get('country', None)
def get_current_weather(self, tz: ZoneInfo) -> CurrentWeatherData: def get_current_weather(self, tz: ZoneInfo) -> CurrentWeatherData:
""" """
Parse the API data we currently have to build a CurrentWeatherData. Parse the API data we currently have to build a CurrentWeatherData.
@ -272,128 +254,38 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
return current_weather return current_weather
def _get_uv_index(self) -> float | None: def get_radar_forecast(self) -> List[IrmKmiRadarForecast]:
uv_index = None
module_data = self._api_data.get('module', None)
if not (module_data is None or not isinstance(module_data, list)):
for module in module_data:
if module.get('type', None) == 'uv':
uv_index = module.get('data', {}).get('levelValue')
return uv_index
def _get_now_hourly(self, tz: ZoneInfo) -> dict | None:
now_hourly = None
hourly_forecast_data = self._api_data.get('for', {}).get('hourly')
now = datetime.now(tz)
if not (hourly_forecast_data is None
or not isinstance(hourly_forecast_data, list)
or len(hourly_forecast_data) == 0):
for current in hourly_forecast_data[:4]:
if now.strftime('%H') == current['hour']:
now_hourly = current
break
return now_hourly
def get_daily_forecast(self, tz: ZoneInfo, lang: str) -> List[IrmKmiForecast]:
""" """
Parse the API data we currently have to build the daily forecast list. Create a list of short term forecasts for rain based on the data provided by the rain radar
:param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels) :return: chronologically ordered list of 'few'-minutes radar forecasts
:param lang: langage to get data for (must be 'fr', 'nl', 'de' or 'en')
:return: chronologically ordered list of daily forecasts
""" """
data = self._api_data.get('for', {}).get('daily') data = self._api_data.get('animation', {})
if data is None or not isinstance(data, list) or len(data) == 0:
if not isinstance(data, dict):
return [] return []
sequence = data.get("sequence", [])
unit = data.get("unit", {}).get("en", None)
ratios = [f['value'] / f['position'] for f in sequence if f['position'] > 0]
forecasts = list() if len(ratios) > 0:
forecast_day = datetime.now(tz) ratio = mean(ratios)
else:
ratio = 0
for (idx, f) in enumerate(data): forecast = list()
precipitation = None for f in sequence:
if f.get('precipQuantity', None) is not None: forecast.append(
try: IrmKmiRadarForecast(
precipitation = float(f.get('precipQuantity')) datetime=f.get("time"),
except (TypeError, ValueError): native_precipitation=f.get('value'),
pass rain_forecast_max=round(f.get('positionHigher') * ratio, 2),
rain_forecast_min=round(f.get('positionLower') * ratio, 2),
native_wind_gust_speed = None might_rain=f.get('positionHigher') > 0,
if f.get('wind', {}).get('peakSpeed') is not None: unit=unit
try:
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
except (TypeError, ValueError):
pass
wind_bearing = None
if f.get('wind', {}).get('dirText', {}).get('en') != 'VAR':
try:
wind_bearing = (float(f.get('wind', {}).get('dir')) + 180) % 360
except (TypeError, ValueError):
pass
is_daytime = f.get('dayNight', None) == 'd'
day_name = f.get('dayName', {}).get('en', None)
timestamp = f.get('timestamp', None)
if timestamp is not None:
forecast_day = datetime.fromisoformat(timestamp)
elif day_name in WEEKDAYS:
forecast_day = next_weekday(forecast_day, WEEKDAYS.index(day_name))
elif day_name in ['Today', 'Tonight']:
forecast_day = datetime.now(tz)
elif day_name == 'Tomorrow':
forecast_day = datetime.now(tz) + timedelta(days=1)
sunrise_sec = f.get('dawnRiseSeconds', None)
if sunrise_sec is None:
sunrise_sec = f.get('sunRise', None)
sunrise = None
if sunrise_sec is not None:
try:
sunrise = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunrise_sec)))
except (TypeError, ValueError):
pass
sunset_sec = f.get('dawnSetSeconds', None)
if sunset_sec is None:
sunset_sec = f.get('sunSet', None)
sunset = None
if sunset_sec is not None:
try:
sunset = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunset_sec)))
except (TypeError, ValueError):
pass
forecast = IrmKmiForecast(
datetime=(forecast_day.strftime('%Y-%m-%d')),
condition=self._cdt_map.get((f.get('ww1', None), f.get('dayNight', None)), None),
condition_2=self._cdt_map.get((f.get('ww2', None), f.get('dayNight', None)), None),
condition_evol=WWEVOL_TO_ENUM_MAP.get(f.get('wwevol'), 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 return forecast
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
def get_hourly_forecast(self, tz: ZoneInfo) -> List[Forecast]: def get_hourly_forecast(self, tz: ZoneInfo) -> List[Forecast]:
""" """
@ -452,38 +344,105 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
return forecasts return forecasts
def get_radar_forecast(self) -> List[IrmKmiRadarForecast]: def get_daily_forecast(self, tz: ZoneInfo, lang: str) -> List[IrmKmiForecast]:
""" """
Create a list of short term forecasts for rain based on the data provided by the rain radar Parse the API data we currently have to build the daily forecast list.
:return: chronologically ordered list of 'few'-minutes radar forecasts :param tz: time zone to use to interpret the timestamps in the forecast (generally is Europe/Brussels)
:param lang: langage to get data for (must be 'fr', 'nl', 'de' or 'en')
:return: chronologically ordered list of daily forecasts
""" """
data = self._api_data.get('animation', {}) data = self._api_data.get('for', {}).get('daily')
if data is None or not isinstance(data, list) or len(data) == 0:
if not isinstance(data, dict):
return [] return []
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: forecasts = list()
ratio = mean(ratios) forecast_day = datetime.now(tz)
else:
ratio = 0
forecast = list() for (idx, f) in enumerate(data):
for f in sequence: precipitation = None
forecast.append( if f.get('precipQuantity', None) is not None:
IrmKmiRadarForecast( try:
datetime=f.get("time"), precipitation = float(f.get('precipQuantity'))
native_precipitation=f.get('value'), except (TypeError, ValueError):
rain_forecast_max=round(f.get('positionHigher') * ratio, 2), pass
rain_forecast_min=round(f.get('positionLower') * ratio, 2),
might_rain=f.get('positionHigher') > 0, native_wind_gust_speed = None
unit=unit 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 = self._next_weekday(forecast_day, WEEKDAYS.index(day_name))
elif day_name in ['Today', 'Tonight']:
forecast_day = datetime.now(tz)
elif day_name == 'Tomorrow':
forecast_day = datetime.now(tz) + timedelta(days=1)
sunrise_sec = f.get('dawnRiseSeconds', None)
if sunrise_sec is None:
sunrise_sec = f.get('sunRise', None)
sunrise = None
if sunrise_sec is not None:
try:
sunrise = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunrise_sec)))
except (TypeError, ValueError):
pass
sunset_sec = f.get('dawnSetSeconds', None)
if sunset_sec is None:
sunset_sec = f.get('sunSet', None)
sunset = None
if sunset_sec is not None:
try:
sunset = (forecast_day.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
+ timedelta(seconds=float(sunset_sec)))
except (TypeError, ValueError):
pass
forecast = IrmKmiForecast(
datetime=(forecast_day.strftime('%Y-%m-%d')),
condition=self._cdt_map.get((f.get('ww1', None), f.get('dayNight', None)), None),
condition_2=self._cdt_map.get((f.get('ww2', None), f.get('dayNight', None)), None),
condition_evol=WWEVOL_TO_ENUM_MAP.get(f.get('wwevol'), 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
return forecast 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
def get_animation_data(self, tz: ZoneInfo, lang: str, style: str, dark_mode: bool) -> RadarAnimationData: def get_animation_data(self, tz: ZoneInfo, lang: str, style: str, dark_mode: bool) -> RadarAnimationData:
""" """
@ -605,6 +564,45 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
return PollenParser(pollen_svg).get_pollen_data() return PollenParser(pollen_svg).get_pollen_data()
def get_city(self) -> str | None:
"""
Get the city for which we currently have the forecast
:return: city name as str or None if unavailable
"""
return self._api_data.get('cityName', None)
def get_country(self) -> str | None:
"""
Get the two-letters country code for which we currently have the forecast
:return: country code as str or None if unavailable
"""
return self._api_data.get('country', None)
def _get_uv_index(self) -> float | None:
uv_index = None
module_data = self._api_data.get('module', None)
if not (module_data is None or not isinstance(module_data, list)):
for module in module_data:
if module.get('type', None) == 'uv':
uv_index = module.get('data', {}).get('levelValue')
return uv_index
def _get_now_hourly(self, tz: ZoneInfo) -> dict | None:
now_hourly = None
hourly_forecast_data = self._api_data.get('for', {}).get('hourly')
now = datetime.now(tz)
if not (hourly_forecast_data is None
or not isinstance(hourly_forecast_data, list)
or len(hourly_forecast_data) == 0):
for current in hourly_forecast_data[:4]:
if now.strftime('%H') == current['hour']:
now_hourly = current
break
return now_hourly
@staticmethod @staticmethod
def _merge_url_and_params(url: str, params: dict) -> str: def _merge_url_and_params(url: str, params: dict) -> str:
"""Merge query string params in the URL""" """Merge query string params in the URL"""
@ -615,3 +613,9 @@ class IrmKmiApiClientHa(IrmKmiApiClient):
new_url = parsed_url._replace(query=new_query) new_url = parsed_url._replace(query=new_query)
return str(urllib.parse.urlunparse(new_url)) return str(urllib.parse.urlunparse(new_url))
@staticmethod
def _next_weekday(current, weekday):
days_ahead = weekday - current.weekday()
if days_ahead < 0:
days_ahead += 7
return current + timedelta(days_ahead)

View file

@ -6,6 +6,8 @@ POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', '
POLLEN_LEVEL_TO_COLOR = {'null': 'green', 'low': 'yellow', 'moderate': 'orange', 'high': 'red', 'very high': 'purple', POLLEN_LEVEL_TO_COLOR = {'null': 'green', 'low': 'yellow', 'moderate': 'orange', 'high': 'red', 'very high': 'purple',
'active': 'active'} 'active': 'active'}
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
# TODO move those to an Enum
OPTION_STYLE_STD: Final = 'standard_style' OPTION_STYLE_STD: Final = 'standard_style'
OPTION_STYLE_CONTRAST: Final = 'contrast_style' OPTION_STYLE_CONTRAST: Final = 'contrast_style'
OPTION_STYLE_YELLOW_RED: Final = 'yellow_red_style' OPTION_STYLE_YELLOW_RED: Final = 'yellow_red_style'
@ -16,6 +18,7 @@ STYLE_TO_PARAM_MAP: Final = {
OPTION_STYLE_YELLOW_RED: 3, OPTION_STYLE_YELLOW_RED: 3,
OPTION_STYLE_SATELLITE: 4 OPTION_STYLE_SATELLITE: 4
} }
MAP_WARNING_ID_TO_SLUG: Final = { MAP_WARNING_ID_TO_SLUG: Final = {
0: 'wind', 0: 'wind',
1: 'rain', 1: 'rain',

View file

@ -21,39 +21,13 @@ class PollenParser:
): ):
self._xml = xml_string 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: 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""" Parse the SVG and extract the pollen data from the image.
If an error occurs, return the default value.
:return: pollen dict
"""
pollen_data = self.get_default_data() pollen_data = self.get_default_data()
try: try:
_LOGGER.debug(f"Full SVG: {self._xml}") _LOGGER.debug(f"Full SVG: {self._xml}")
@ -106,3 +80,34 @@ class PollenParser:
_LOGGER.debug(f"Pollen data: {pollen_data}") _LOGGER.debug(f"Pollen data: {pollen_data}")
return pollen_data return pollen_data
@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

View file

@ -4,7 +4,7 @@ import base64
import copy import copy
import datetime import datetime
import logging import logging
from typing import Any, List, Self from typing import List, Self
import async_timeout import async_timeout
from svgwrite import Drawing from svgwrite import Drawing
@ -20,6 +20,8 @@ _LOGGER = logging.getLogger(__name__)
class RainGraph: class RainGraph:
"""Create and get rain radar animated SVG"""
def __init__(self, def __init__(self,
animation_data: RadarAnimationData, animation_data: RadarAnimationData,
country: str, country: str,
@ -77,74 +79,110 @@ class RainGraph:
async def build(self) -> Self: async def build(self) -> Self:
"""Build the rain graph by calling all the method in the right order. Returns self when done""" """Build the rain graph by calling all the method in the right order. Returns self when done"""
await self.draw_svg_frame() await self._draw_svg_frame()
self.draw_hour_bars() self._draw_hour_bars()
self.draw_chances_path() self._draw_chances_path()
self.draw_data_line() self._draw_data_line()
self.write_hint() self._write_hint()
await self.insert_background() await self._insert_background()
self._dwg_save = copy.deepcopy(self._dwg) self._dwg_save = copy.deepcopy(self._dwg)
return self return self
async def get_animated(self) -> bytes: 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.""" """
Get the animated SVG. If called for the first time, downloads the cloud images to build the file.
:return: utf-8 encoded animated SVG string
:raises: ValueError if build() was not called before
"""
if self._dwg_save is None:
raise ValueError("You need to call .build() before getting the SVG")
_LOGGER.info(f"Get animated with _dwg_animated {self._dwg_animated}") _LOGGER.info(f"Get animated with _dwg_animated {self._dwg_animated}")
if self._dwg_animated is None: if self._dwg_animated is None:
clouds = self.download_clouds() clouds = self._download_clouds()
self._dwg = copy.deepcopy(self._dwg_save) self._dwg = copy.deepcopy(self._dwg_save)
self.draw_current_fame_line() self._draw_current_fame_line()
self.draw_description_text() self._draw_description_text()
try: try:
await clouds await clouds
self.insert_cloud_layer() self._insert_cloud_layer()
except IrmKmiApiError as err: except IrmKmiApiError as err:
_LOGGER.warning(f"Could not download clouds from API: {err}") _LOGGER.warning(f"Could not download clouds from API: {err}")
await self.draw_location() await self._draw_location()
self._dwg_animated = self._dwg self._dwg_animated = self._dwg
return self.get_svg_string(still_image=False) return self._get_svg_string(still_image=False)
async def get_still(self) -> bytes: 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.""" """
Get the still SVG. If called for the first time, downloads the cloud images to build the file.
:return: utf-8 encoded SVG string
:raises: ValueError if build() was not called before
"""
if self._dwg_save is None:
raise ValueError("You need to call .build() before getting the SVG")
_LOGGER.info(f"Get still with _dwg_still {self._dwg_still}") _LOGGER.info(f"Get still with _dwg_still {self._dwg_still}")
if self._dwg_still is None: if self._dwg_still is None:
idx = self._animation_data['most_recent_image_idx'] idx = self._animation_data['most_recent_image_idx']
cloud = self.download_clouds(idx) cloud = self._download_clouds(idx)
self._dwg = copy.deepcopy(self._dwg_save) self._dwg = copy.deepcopy(self._dwg_save)
self.draw_current_fame_line(idx) self._draw_current_fame_line(idx)
self.draw_description_text(idx) self._draw_description_text(idx)
try: try:
await cloud await cloud
self.insert_cloud_layer(idx) self._insert_cloud_layer(idx)
except IrmKmiApiError as err: except IrmKmiApiError as err:
_LOGGER.warning(f"Could not download clouds from API: {err}") _LOGGER.warning(f"Could not download clouds from API: {err}")
await self.draw_location() await self._draw_location()
self._dwg_still = self._dwg self._dwg_still = self._dwg
return self.get_svg_string(still_image=True) return self._get_svg_string(still_image=True)
async def download_clouds(self, idx = None): def get_hint(self) -> str:
"""
Get hint to display on the rain graph
:return: hint sentence as str
"""
return self._animation_data.get('hint', '')
async def _download_clouds(self, idx=None):
"""
Download cloud images and save the result in the internal state.
:param idx: index of the image to download (if not specified, downloads all the images)
:raises: IrmKmiApiError if communication with the API fails
"""
imgs = [e['image'] for e in self._animation_data['sequence']] imgs = [e['image'] for e in self._animation_data['sequence']]
if idx is not None and type(imgs[idx]) is str: if idx is not None and type(imgs[idx]) is str:
_LOGGER.info("Download single cloud image") _LOGGER.info("Download single cloud image")
print("Download single cloud image") print("Download single cloud image")
result = await self.download_images_from_api([imgs[idx]]) result = await self._download_images_from_api([imgs[idx]])
self._animation_data['sequence'][idx]['image'] = result[0] self._animation_data['sequence'][idx]['image'] = result[0]
else: else:
_LOGGER.info("Download many cloud images") _LOGGER.info("Download many cloud images")
result = await self.download_images_from_api([img for img in imgs if type(img) is str]) 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'])): for i in range(len(self._animation_data['sequence'])):
if type(self._animation_data['sequence'][i]['image']) is str: if type(self._animation_data['sequence'][i]['image']) is str:
self._animation_data['sequence'][i]['image'] = result[0] self._animation_data['sequence'][i]['image'] = result[0]
result = result[1:] result = result[1:]
async def download_images_from_api(self, urls: list[str]) -> list[Any]: async def _download_images_from_api(self, urls: list[str]) -> list[bytes]:
"""Download a batch of images to create the radar frames.""" """
Download a batch of images to create the radar frames.
:param urls: list of urls to download
:return: list images downloaded as bytes
:raises: IrmKmiApiError if communication with the API fails
"""
coroutines = list() coroutines = list()
for url in urls: for url in urls:
@ -155,10 +193,7 @@ class RainGraph:
_LOGGER.info(f"Just downloaded {len(images_from_api)} images") _LOGGER.info(f"Just downloaded {len(images_from_api)} images")
return images_from_api return images_from_api
def get_hint(self) -> str: async def _draw_svg_frame(self):
return self._animation_data.get('hint', None)
async def draw_svg_frame(self):
"""Create the global area to draw the other items""" """Create the global area to draw the other items"""
mimetype = "application/x-font-ttf" mimetype = "application/x-font-ttf"
@ -167,9 +202,7 @@ class RainGraph:
self._dwg.embed_stylesheet(content) self._dwg.embed_stylesheet(content)
self._dwg.embed_stylesheet(""" self._dwg.embed_stylesheet("""
.roboto { .roboto { font-family: "Roboto Medium"; }
font-family: "Roboto Medium";
}
""") """)
fill_color = '#393C40' if self._dark_mode else '#385E95' fill_color = '#393C40' if self._dark_mode else '#385E95'
@ -178,7 +211,7 @@ class RainGraph:
rx=None, ry=None, rx=None, ry=None,
fill=fill_color, stroke='none')) fill=fill_color, stroke='none'))
def draw_description_text(self, idx: int | None = 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. """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""" If idx is set, only do it for the given idx"""
@ -192,7 +225,7 @@ class RainGraph:
paragraph = self._dwg.add(self._dwg.g(class_="roboto", )) paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
self.write_time_and_rain(paragraph, rain_level, time) self._write_time_and_rain(paragraph, rain_level, time)
return return
for i in range(self._frame_count): for i in range(self._frame_count):
@ -212,9 +245,9 @@ class RainGraph:
repeatCount="indefinite" repeatCount="indefinite"
)) ))
self.write_time_and_rain(paragraph, rain_level, time) self._write_time_and_rain(paragraph, rain_level, time)
def write_time_and_rain(self, 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""" """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), paragraph.add(self._dwg.text(f"{time}", insert=(self._offset, self._top_text_y_pos),
text_anchor="start", text_anchor="start",
@ -227,11 +260,11 @@ class RainGraph:
fill="white", fill="white",
stroke='none')) stroke='none'))
def write_hint(self): def _write_hint(self):
"""Add the hint text at the bottom of the graph""" """Add the hint text at the bottom of the graph"""
paragraph = self._dwg.add(self._dwg.g(class_="roboto", )) paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
hint = self._animation_data['hint'] hint = self.get_hint()
paragraph.add(self._dwg.text(f"{hint}", insert=(self._svg_width / 2, self._bottom_text_y_pos), paragraph.add(self._dwg.text(f"{hint}", insert=(self._svg_width / 2, self._bottom_text_y_pos),
text_anchor="middle", text_anchor="middle",
@ -239,7 +272,7 @@ class RainGraph:
fill="white", fill="white",
stroke='none')) stroke='none'))
def draw_chances_path(self): def _draw_chances_path(self):
"""Draw the prevision margin area around the main forecast line""" """Draw the prevision margin area around the main forecast line"""
list_lower_points = [] list_lower_points = []
list_higher_points = [] list_higher_points = []
@ -264,36 +297,9 @@ class RainGraph:
graph_rect_right -= self._interval_width graph_rect_right -= self._interval_width
if list_higher_points and list_lower_points: if list_higher_points and list_lower_points:
self.draw_chance_precip(list_higher_points, 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): def _draw_data_line(self):
"""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""" """Draw the main data line for the rain forecast"""
rain_list: List[AnimationFrameData] = self._animation_data['sequence'] rain_list: List[AnimationFrameData] = self._animation_data['sequence']
graph_rect_left = self._offset graph_rect_left = self._offset
@ -308,10 +314,10 @@ class RainGraph:
graph_rect_top + (1.0 - position) * self._graph_height)) graph_rect_top + (1.0 - position) * self._graph_height))
graph_rect_left += self._interval_width graph_rect_left += self._interval_width
data_line_path = self._dwg.path(fill='none', stroke='#63c8fa', stroke_width=2) data_line_path = self._dwg.path(fill='none', stroke='#63c8fa', stroke_width=2)
self.set_curved_path(data_line_path, entry_list) self._set_curved_path(data_line_path, entry_list)
self._dwg.add(data_line_path) self._dwg.add(data_line_path)
def draw_hour_bars(self): def _draw_hour_bars(self):
"""Draw the small bars at the bottom to represent the time""" """Draw the small bars at the bottom to represent the time"""
hour_bar_height = 8 hour_bar_height = 8
horizontal_inset = self._offset horizontal_inset = self._offset
@ -350,7 +356,7 @@ class RainGraph:
end=(self._graph_width + self._interval_width / 2, self._graph_bottom), end=(self._graph_width + self._interval_width / 2, self._graph_bottom),
stroke='white')) stroke='white'))
def draw_current_fame_line(self, idx: int | None = None): 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""" """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 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), now = self._dwg.add(self._dwg.line(start=(x_position, self._top_text_space),
@ -368,15 +374,21 @@ class RainGraph:
dur=f"{self._frame_count * 0.3}s", dur=f"{self._frame_count * 0.3}s",
repeatCount="indefinite")) repeatCount="indefinite"))
def get_svg_string(self, still_image: bool = False) -> bytes: def _get_svg_string(self, still_image: bool = False) -> bytes:
"""
Get the utf-8 encoded string representing the SVG
:param still_image: if true the non-animated version is returned
:return: utf-8 encoded string
"""
return self._dwg_still.tostring().encode() if still_image else self._dwg_animated.tostring().encode() return self._dwg_still.tostring().encode() if still_image else self._dwg_animated.tostring().encode()
async def insert_background(self): async def _insert_background(self):
png_data = self.get_background_png_b64() png_data = self._get_background_png_b64()
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size) image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image) self._dwg.add(image)
def insert_cloud_layer(self, idx: int | None = None): def _insert_cloud_layer(self, idx: int | None = None):
imgs = [e['image'] for e in self._animation_data['sequence']] imgs = [e['image'] for e in self._animation_data['sequence']]
if idx is not None: if idx is not None:
@ -402,23 +414,22 @@ class RainGraph:
repeatCount="indefinite" repeatCount="indefinite"
)) ))
async def draw_location(self): async def _draw_location(self):
img = self._animation_data['location'] img = self._animation_data['location']
_LOGGER.info(f"Draw location layer with img of type {type(img)}") _LOGGER.info(f"Draw location layer with img of type {type(img)}")
if type(img) is str: if type(img) is str:
result = await self.download_images_from_api([img]) result = await self._download_images_from_api([img])
img = result[0] img = result[0]
self._animation_data['location'] = img self._animation_data['location'] = img
png_data = base64.b64encode(img).decode('utf-8') 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) image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image) self._dwg.add(image)
def get_dwg(self): def _get_dwg(self) -> Drawing:
return copy.deepcopy(self._dwg) return copy.deepcopy(self._dwg)
def get_background_png_b64(self): def _get_background_png_b64(self) -> str:
_LOGGER.debug(f"Get b64 for {self._country} {self._style} {'dark' if self._dark_mode else 'light'} mode")
if self._country == 'NL': if self._country == 'NL':
return nl.nl_b64 return nl.nl_b64
elif self._style == OPTION_STYLE_SATELLITE: elif self._style == OPTION_STYLE_SATELLITE:
@ -427,3 +438,30 @@ class RainGraph:
return be_black.be_black_b64 return be_black.be_black_b64
else: else:
return be_white.be_white_b64 return be_white.be_white_b64
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])

View file

@ -1,8 +0,0 @@
from datetime import timedelta
def next_weekday(current, weekday):
days_ahead = weekday - current.weekday()
if days_ahead < 0:
days_ahead += 7
return current + timedelta(days_ahead)

View file

@ -48,9 +48,9 @@ async def test_svg_frame_setup():
style='STD', style='STD',
) )
await rain_graph.draw_svg_frame() await rain_graph._draw_svg_frame()
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
with open("irm_kmi_api/resources/roboto_medium.ttf", "rb") as file: with open("irm_kmi_api/resources/roboto_medium.ttf", "rb") as file:
font_b64 = base64.b64encode(file.read()).decode('utf-8') font_b64 = base64.b64encode(file.read()).decode('utf-8')
@ -68,9 +68,9 @@ def test_svg_hint():
style='STD', style='STD',
) )
rain_graph.write_hint() rain_graph._write_hint()
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
assert "Testing SVG camera" in svg_str assert "Testing SVG camera" in svg_str
@ -84,9 +84,9 @@ def test_svg_time_bars():
style='STD', style='STD',
) )
rain_graph.draw_hour_bars() rain_graph._draw_hour_bars()
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
assert "19h" in svg_str assert "19h" in svg_str
assert "20h" in svg_str assert "20h" in svg_str
@ -103,9 +103,9 @@ def test_draw_chances_path():
style='STD', style='STD',
) )
rain_graph.draw_chances_path() rain_graph._draw_chances_path()
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
assert 'fill="#63c8fa"' in svg_str assert 'fill="#63c8fa"' in svg_str
assert 'opacity="0.3"' in svg_str assert 'opacity="0.3"' in svg_str
@ -121,9 +121,9 @@ def test_draw_data_line():
style='STD', style='STD',
) )
rain_graph.draw_data_line() rain_graph._draw_data_line()
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
assert 'fill="none"' in svg_str assert 'fill="none"' in svg_str
assert 'stroke-width="2"' in svg_str assert 'stroke-width="2"' in svg_str
@ -139,12 +139,12 @@ async def test_insert_background():
style='STD', style='STD',
) )
await rain_graph.insert_background() await rain_graph._insert_background()
with open("irm_kmi_api/resources/be_white.png", "rb") as file: with open("irm_kmi_api/resources/be_white.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8') png_b64 = base64.b64encode(file.read()).decode('utf-8')
svg_str = rain_graph.get_dwg().tostring() svg_str = rain_graph._get_dwg().tostring()
assert png_b64 in svg_str assert png_b64 in svg_str
assert "<image " in svg_str assert "<image " in svg_str
@ -162,9 +162,9 @@ def test_draw_current_frame_line_moving():
style='STD', style='STD',
) )
rain_graph.draw_current_fame_line() rain_graph._draw_current_fame_line()
str_svg = rain_graph.get_dwg().tostring() str_svg = rain_graph._get_dwg().tostring()
assert '<line' in str_svg assert '<line' in str_svg
assert 'id="now"' in str_svg assert 'id="now"' in str_svg
@ -190,9 +190,9 @@ def test_draw_current_frame_line_index():
style='STD', style='STD',
) )
rain_graph.draw_current_fame_line(0) rain_graph._draw_current_fame_line(0)
str_svg = rain_graph.get_dwg().tostring() str_svg = rain_graph._get_dwg().tostring()
assert '<line' in str_svg assert '<line' in str_svg
assert 'id="now"' in str_svg assert 'id="now"' in str_svg
@ -219,9 +219,9 @@ def test_draw_description_text():
style='STD', style='STD',
) )
rain_graph.draw_description_text() rain_graph._draw_description_text()
str_svg = rain_graph.get_dwg().tostring() str_svg = rain_graph._get_dwg().tostring()
assert "18:30" in str_svg assert "18:30" in str_svg
assert "18:40" in str_svg assert "18:40" in str_svg
@ -246,9 +246,9 @@ def test_draw_cloud_layer():
style='STD', style='STD',
) )
rain_graph.insert_cloud_layer() rain_graph._insert_cloud_layer()
str_svg = rain_graph.get_dwg().tostring() str_svg = rain_graph._get_dwg().tostring()
with open("tests/fixtures/clouds_be.png", "rb") as file: with open("tests/fixtures/clouds_be.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8') png_b64 = base64.b64encode(file.read()).decode('utf-8')
@ -266,9 +266,9 @@ async def test_draw_location_layer():
style='STD', style='STD',
) )
await rain_graph.draw_location() await rain_graph._draw_location()
str_svg = rain_graph.get_dwg().tostring() str_svg = rain_graph._get_dwg().tostring()
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file: with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8') png_b64 = base64.b64encode(file.read()).decode('utf-8')
@ -312,7 +312,7 @@ async def test_download_single_cloud():
rain_graph._api_client = MagicMock() rain_graph._api_client = MagicMock()
rain_graph._api_client.get_image = AsyncMock() rain_graph._api_client.get_image = AsyncMock()
await rain_graph.download_clouds(2) await rain_graph._download_clouds(2)
rain_graph._api_client.get_image.assert_called_once_with('image-url-2') rain_graph._api_client.get_image.assert_called_once_with('image-url-2')
@ -330,7 +330,7 @@ async def test_download_many_clouds():
rain_graph._api_client = MagicMock() rain_graph._api_client = MagicMock()
rain_graph._api_client.get_image = AsyncMock() rain_graph._api_client.get_image = AsyncMock()
await rain_graph.download_clouds() await rain_graph._download_clouds()
for i in range(10): for i in range(10):
rain_graph._api_client.get_image.assert_any_call(f'image-url-{i}') rain_graph._api_client.get_image.assert_any_call(f'image-url-{i}')