Compare commits
68 commits
Author | SHA1 | Date | |
---|---|---|---|
52be58a9ef | |||
f1b18fe29a | |||
77e94d802b | |||
76a670427b | |||
866b1f3fa0 | |||
914dd75d7b | |||
5c320b57fb | |||
68bcb8aeb4 | |||
702f687a8d | |||
d5a687fff5 | |||
9e178378fc | |||
ef5d3ad126 | |||
d0d542c3fe | |||
fd8aa3029f | |||
fb43a882f8 | |||
57cce48c5f | |||
7951bafefb | |||
f0a1853f67 | |||
2707950ad9 | |||
1a33b3b594 | |||
6476f0e57a | |||
5932884c7a | |||
1e35e24c15 | |||
f729d59d9f | |||
16a5399edb | |||
36bfe49ce2 | |||
16a1991063 | |||
be30c160f4 | |||
9064326860 | |||
ea23f0da2c | |||
7f9cca4960 | |||
fee2a10f5e | |||
18040eb577 | |||
be0a7425d4 | |||
1844d02639 | |||
ca98e12e88 | |||
fb59936c79 | |||
fbab30e33f | |||
7e75e4f184 | |||
5d93102ada | |||
0776cff6d6 | |||
93bda52ac8 | |||
48fca3197f | |||
196d4cc178 | |||
225a853b27 | |||
3ef90ba688 | |||
0a64e7eec2 | |||
117c2d5030 | |||
67a8647f7b | |||
52812487f9 | |||
![]() |
3947273ef7 | ||
b78c8a6779 | |||
72e9d5dc99 | |||
3957eac952 | |||
fdec55e021 | |||
b666f7dd10 | |||
082770f480 | |||
6661bac5ad | |||
9f06486512 | |||
3ede45af43 | |||
34cb9e1bb5 | |||
0de029b30b | |||
4978a92385 | |||
1254ae7157 | |||
91d46dcb6c | |||
fe412dfec3 | |||
41fd90bf63 | |||
cf7519e7db |
3
.github/workflows/pytest.yml
vendored
|
@ -3,6 +3,7 @@ name: Run Python tests
|
|||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
@ -10,7 +11,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
python-version: ["3.13"]
|
||||
|
||||
steps:
|
||||
- uses: MathRobin/timezone-action@v1.1
|
||||
|
|
27
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
name: Create release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
release:
|
||||
name: Release pushed tag
|
||||
needs: [tests]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" \
|
||||
--repo="$GITHUB_REPOSITORY" \
|
||||
--title="${tag#v}" \
|
||||
--generate-notes
|
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023-2024 Jules Dejaeghere
|
||||
Copyright (c) 2023-2025 Jules Dejaeghere
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
@ -119,6 +119,7 @@ The following table summarizes the different known warning types. Other warning
|
|||
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
|
||||
| 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 |
|
||||
|
|
|
@ -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, OPTION_STYLE_STD,
|
||||
PLATFORMS)
|
||||
OPTION_DEPRECATED_FORECAST_NOT_USED, PLATFORMS)
|
||||
from .coordinator import IrmKmiCoordinator
|
||||
from .weather import IrmKmiWeather
|
||||
|
||||
|
@ -22,6 +22,8 @@ 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()
|
||||
|
@ -45,8 +47,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass, config_entry: ConfigEntry):
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
"""API Client for IRM KMI weather"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from aiohttp import ClientResponse
|
||||
|
||||
_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
|
||||
|
||||
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 = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
||||
return await response.json()
|
||||
|
||||
async def get_image(self, url, params: dict | None = None) -> bytes:
|
||||
"""Get the image at the specified url with the parameters"""
|
||||
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||
return await r.read()
|
||||
|
||||
async def get_svg(self, url, params: dict | None = None) -> str:
|
||||
"""Get SVG as str at the specified url with the parameters"""
|
||||
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||
return await r.text()
|
||||
|
||||
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,
|
||||
) -> any:
|
||||
"""Get information from the API."""
|
||||
if headers is None:
|
||||
headers = {'User-Agent': 'github.com/jdejaegh/irm-kmi-ha'}
|
||||
else:
|
||||
headers['User-Agent'] = 'github.com/jdejaegh/irm-kmi-ha'
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(60):
|
||||
response = await self._session.request(
|
||||
method=method,
|
||||
url=f"{self._base_url if base_url is None else base_url}{path}",
|
||||
headers=headers,
|
||||
json=data,
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
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
|
|
@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt
|
||||
|
||||
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||
from . import DOMAIN, IrmKmiCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
@ -46,19 +46,15 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
|||
"""Return the interval between frames of the mjpeg stream."""
|
||||
return 1
|
||||
|
||||
def camera_image(self,
|
||||
width: int | None = None,
|
||||
height: int | None = None) -> bytes | None:
|
||||
"""Return still image to be used as thumbnail."""
|
||||
return self.coordinator.data.get('animation', {}).get('svg_still')
|
||||
|
||||
async def async_camera_image(
|
||||
self,
|
||||
width: int | None = None,
|
||||
height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return still image to be used as thumbnail."""
|
||||
return self.camera_image()
|
||||
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."""
|
||||
|
@ -73,8 +69,8 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
|||
"""Returns the animated svg for camera display"""
|
||||
# If this is not done this way, the live view can only be opened once
|
||||
self._image_index = not self._image_index
|
||||
if self._image_index:
|
||||
return self.coordinator.data.get('animation', {}).get('svg_animated')
|
||||
if self._image_index and self.coordinator.data.get('animation', None) is not None:
|
||||
return await self.coordinator.data.get('animation').get_animated()
|
||||
else:
|
||||
return None
|
||||
|
||||
|
@ -86,5 +82,7 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
|||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""Return the camera state attributes."""
|
||||
attrs = {"hint": self.coordinator.data.get('animation', {}).get('hint')}
|
||||
rain_graph = self.coordinator.data.get('animation', None)
|
||||
hint = rain_graph.get_hint() if rain_graph is not None else None
|
||||
attrs = {"hint": hint}
|
||||
return attrs
|
||||
|
|
|
@ -14,14 +14,15 @@ from homeassistant.helpers.selector import (EntitySelector,
|
|||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode)
|
||||
from irm_kmi_api.api import IrmKmiApiClient
|
||||
|
||||
from .api import IrmKmiApiClient
|
||||
from . import OPTION_STYLE_STD
|
||||
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE,
|
||||
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,
|
||||
OPTION_STYLE_STD, OUT_OF_BENELUX)
|
||||
OUT_OF_BENELUX, USER_AGENT)
|
||||
from .utils import get_config_value
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@ -50,9 +51,11 @@ 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)).get_forecasts_coord(
|
||||
session=async_get_clientsession(self.hass),
|
||||
user_agent=USER_AGENT
|
||||
).get_forecasts_coord(
|
||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||
)
|
||||
|
@ -107,7 +110,7 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||
class IrmKmiOptionFlow(OptionsFlow):
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
self.current_config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
|
@ -119,21 +122,21 @@ class IrmKmiOptionFlow(OptionsFlow):
|
|||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_STYLE, default=get_config_value(self.config_entry, CONF_STYLE)):
|
||||
vol.Optional(CONF_STYLE, default=get_config_value(self.current_config_entry, CONF_STYLE)):
|
||||
SelectSelector(SelectSelectorConfig(options=CONF_STYLE_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_STYLE)),
|
||||
|
||||
vol.Optional(CONF_DARK_MODE, default=get_config_value(self.config_entry, CONF_DARK_MODE)): bool,
|
||||
vol.Optional(CONF_DARK_MODE, default=get_config_value(self.current_config_entry, CONF_DARK_MODE)): bool,
|
||||
|
||||
vol.Optional(CONF_USE_DEPRECATED_FORECAST,
|
||||
default=get_config_value(self.config_entry, CONF_USE_DEPRECATED_FORECAST)):
|
||||
default=get_config_value(self.current_config_entry, CONF_USE_DEPRECATED_FORECAST)):
|
||||
SelectSelector(SelectSelectorConfig(options=CONF_USE_DEPRECATED_FORECAST_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_USE_DEPRECATED_FORECAST)),
|
||||
|
||||
vol.Optional(CONF_LANGUAGE_OVERRIDE,
|
||||
default=get_config_value(self.config_entry, CONF_LANGUAGE_OVERRIDE)):
|
||||
default=get_config_value(self.current_config_entry, CONF_LANGUAGE_OVERRIDE)):
|
||||
SelectSelector(SelectSelectorConfig(options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_LANGUAGE_OVERRIDE))
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""Constants for the IRM KMI integration."""
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_FOG,
|
||||
|
@ -11,7 +12,10 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
|||
ATTR_CONDITION_SNOWY,
|
||||
ATTR_CONDITION_SNOWY_RAINY,
|
||||
ATTR_CONDITION_SUNNY)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import (DEGREE, Platform, UnitOfPressure, UnitOfSpeed,
|
||||
UnitOfTemperature)
|
||||
from irm_kmi_api.const import (OPTION_STYLE_CONTRAST, OPTION_STYLE_SATELLITE,
|
||||
OPTION_STYLE_STD, OPTION_STYLE_YELLOW_RED)
|
||||
|
||||
DOMAIN: Final = 'irm_kmi'
|
||||
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
@ -23,10 +27,6 @@ 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,13 +38,6 @@ 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'
|
||||
|
@ -129,23 +122,6 @@ 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'
|
||||
|
@ -158,4 +134,30 @@ IRM_KMI_NAME: Final = {
|
|||
'en': 'Royal Meteorological Institute of Belgium'
|
||||
}
|
||||
|
||||
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.3.2'
|
||||
|
||||
CURRENT_WEATHER_SENSORS: Final = {'temperature', 'wind_speed', 'wind_gust_speed', 'wind_bearing', 'uv_index',
|
||||
'pressure'}
|
||||
|
||||
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': '',
|
||||
'pressure': UnitOfPressure.HPA}
|
||||
|
||||
CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE,
|
||||
'wind_speed': SensorDeviceClass.WIND_SPEED,
|
||||
'wind_gust_speed': SensorDeviceClass.WIND_SPEED,
|
||||
'wind_bearing': None,
|
||||
'uv_index': None,
|
||||
'pressure': SensorDeviceClass.ATMOSPHERIC_PRESSURE}
|
||||
|
||||
# Leave None when we want the default icon to be shown
|
||||
CURRENT_WEATHER_SENSOR_ICON: Final = {'temperature': None,
|
||||
'wind_speed': None,
|
||||
'wind_gust_speed': None,
|
||||
'wind_bearing': 'mdi:compass',
|
||||
'uv_index': 'mdi:sun-wireless',
|
||||
'pressure': None}
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from statistics import mean
|
||||
from typing import Any, List, Tuple
|
||||
from datetime import timedelta
|
||||
|
||||
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
|
||||
|
@ -17,20 +13,15 @@ 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 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 (AnimationFrameData, CurrentWeatherData, IrmKmiForecast,
|
||||
IrmKmiRadarForecast, ProcessedCoordinatorData,
|
||||
RadarAnimationData, WarningData)
|
||||
from .pollen import PollenParser
|
||||
from .rain_graph import RainGraph
|
||||
from .utils import (disable_from_config, get_config_value, next_weekday,
|
||||
preferred_language)
|
||||
from .const import OUT_OF_BENELUX, USER_AGENT
|
||||
from .data import ProcessedCoordinatorData
|
||||
from .utils import disable_from_config, get_config_value, preferred_language
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -43,20 +34,20 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
|||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
# Name of the data. For logging purposes.
|
||||
name="IRM KMI weather",
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=timedelta(minutes=7),
|
||||
)
|
||||
self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass))
|
||||
self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP)
|
||||
self._zone = get_config_value(entry, CONF_ZONE)
|
||||
self._dark_mode = get_config_value(entry, CONF_DARK_MODE)
|
||||
self._style = get_config_value(entry, CONF_STYLE)
|
||||
self._config_entry = entry
|
||||
self.shared_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, self._config_entry)),
|
||||
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, self.config_entry)),
|
||||
name=f"{entry.title}"
|
||||
)
|
||||
|
||||
|
@ -66,18 +57,20 @@ 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()
|
||||
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):
|
||||
api_data = await self._api_client.get_forecasts_coord(
|
||||
await self._api.refresh_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 \
|
||||
|
@ -88,11 +81,11 @@ 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 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."
|
||||
if self._api.get_city() in OUT_OF_BENELUX:
|
||||
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
|
||||
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
|
||||
f"this")
|
||||
disable_from_config(self.hass, self._config_entry)
|
||||
disable_from_config(self.hass, self.config_entry)
|
||||
|
||||
issue_registry.async_create_issue(
|
||||
self.hass,
|
||||
|
@ -101,433 +94,56 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
|||
is_fixable=True,
|
||||
severity=issue_registry.IssueSeverity.ERROR,
|
||||
translation_key='zone_moved',
|
||||
data={'config_entry_id': self._config_entry.entry_id, 'zone': self._zone},
|
||||
data={'config_entry_id': self.config_entry.entry_id, 'zone': self._zone},
|
||||
translation_placeholders={'zone': self._zone}
|
||||
)
|
||||
return ProcessedCoordinatorData()
|
||||
|
||||
return await self.process_api_data(api_data)
|
||||
return await self.process_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 _async_animation_data(self, api_data: dict) -> RadarAnimationData:
|
||||
"""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', '')
|
||||
|
||||
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
||||
return RadarAnimationData()
|
||||
|
||||
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:
|
||||
images_from_api = await self.download_images_from_api(animation_data, country, localisation_layer_url)
|
||||
except IrmKmiApiError as err:
|
||||
_LOGGER.warning(f"Could not get images for weather radar: {err}. Keep the existing radar data.")
|
||||
return self.data.get('animation', RadarAnimationData()) if self.data is not None else RadarAnimationData()
|
||||
|
||||
localisation = images_from_api[0]
|
||||
images_from_api = images_from_api[1:]
|
||||
|
||||
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 = await self.create_rain_graph(radar_animation, animation_data, country, images_from_api)
|
||||
print(rain_graph)
|
||||
radar_animation['svg_animated'] = rain_graph.get_svg_string()
|
||||
radar_animation['svg_still'] = rain_graph.get_svg_string(still_image=True)
|
||||
return radar_animation
|
||||
|
||||
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)
|
||||
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.")
|
||||
return self.data.get('pollen', PollenParser.get_unavailable_data()) \
|
||||
pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \
|
||||
if self.data is not None else PollenParser.get_unavailable_data()
|
||||
|
||||
return PollenParser(pollen_svg).get_pollen_data()
|
||||
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
|
||||
|
||||
|
||||
# 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)
|
||||
]
|
||||
|
||||
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')
|
||||
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()
|
||||
)
|
||||
|
||||
async def download_images_from_api(self,
|
||||
animation_data: list,
|
||||
country: str,
|
||||
localisation_layer_url: str) -> tuple[Any]:
|
||||
"""Download a batch of images to create the radar frames."""
|
||||
coroutines = list()
|
||||
coroutines.append(
|
||||
self._api_client.get_image(localisation_layer_url,
|
||||
params={'th': 'd' if country == 'NL' or not self._dark_mode else 'n'}))
|
||||
|
||||
for frame in animation_data:
|
||||
if frame.get('uri', None) is not None:
|
||||
coroutines.append(
|
||||
self._api_client.get_image(frame.get('uri'), params={'rs': STYLE_TO_PARAM_MAP[self._style]}))
|
||||
async with async_timeout.timeout(60):
|
||||
images_from_api = await asyncio.gather(*coroutines)
|
||||
|
||||
_LOGGER.debug(f"Just downloaded {len(images_from_api)} images")
|
||||
return images_from_api
|
||||
|
||||
@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", [])
|
||||
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
|
||||
)
|
||||
)
|
||||
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: Tuple[bytes],
|
||||
) -> 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).build()
|
||||
|
||||
def warnings_from_data(self, warning_data: list | None) -> List[WarningData]:
|
||||
"""Create a list of warning data instances based on the api data"""
|
||||
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
|
||||
return []
|
||||
|
||||
lang = preferred_language(self.hass, self._config_entry)
|
||||
result = list()
|
||||
for data in warning_data:
|
||||
try:
|
||||
warning_id = int(data.get('warningType', {}).get('id'))
|
||||
start = datetime.fromisoformat(data.get('fromTimestamp', None))
|
||||
end = datetime.fromisoformat(data.get('toTimestamp', None))
|
||||
except (TypeError, ValueError):
|
||||
# Without this data, the warning is useless
|
||||
continue
|
||||
|
||||
try:
|
||||
level = int(data.get('warningLevel'))
|
||||
except TypeError:
|
||||
level = None
|
||||
|
||||
result.append(
|
||||
WarningData(
|
||||
slug=SLUG_MAP.get(warning_id, 'unknown'),
|
||||
id=warning_id,
|
||||
level=level,
|
||||
friendly_name=data.get('warningType', {}).get('name', {}).get(lang, ''),
|
||||
text=data.get('text', {}).get(lang, ''),
|
||||
starts_at=start,
|
||||
ends_at=end
|
||||
)
|
||||
)
|
||||
|
||||
return result if len(result) > 0 else []
|
||||
|
|
|
@ -1,67 +1,8 @@
|
|||
"""Data classes for IRM KMI integration"""
|
||||
from datetime import datetime
|
||||
from typing import List, TypedDict
|
||||
|
||||
from homeassistant.components.weather import Forecast
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
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 AnimationFrameData(TypedDict, total=False):
|
||||
"""Holds one single frame of the radar camera, along with the timestamp of the frame"""
|
||||
time: datetime | None
|
||||
image: bytes | 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 | None
|
||||
svg_still: bytes | None
|
||||
svg_animated: bytes | 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
|
||||
from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
|
||||
from irm_kmi_api.rain_graph import RainGraph
|
||||
|
||||
|
||||
class ProcessedCoordinatorData(TypedDict, total=False):
|
||||
|
@ -70,7 +11,7 @@ class ProcessedCoordinatorData(TypedDict, total=False):
|
|||
hourly_forecast: List[Forecast] | None
|
||||
daily_forecast: List[IrmKmiForecast] | None
|
||||
radar_forecast: List[Forecast] | None
|
||||
animation: RadarAnimationData
|
||||
animation: RainGraph | None
|
||||
warnings: List[WarningData]
|
||||
pollen: dict
|
||||
country: str
|
||||
|
|
|
@ -9,8 +9,7 @@
|
|||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
|
||||
"requirements": [
|
||||
"svgwrite==1.4.3",
|
||||
"aiofile==3.9.0"
|
||||
"irm-kmi-api==0.2.0"
|
||||
],
|
||||
"version": "0.2.23"
|
||||
"version": "0.3.2"
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
"""Parse pollen info from SVG from IRM KMI api"""
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import List
|
||||
|
||||
from custom_components.irm_kmi.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. 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}
|
||||
|
||||
for position, pollen in pollens.items():
|
||||
if position is not None and position in pollen_levels:
|
||||
pollen_data[pollen] = pollen_levels[position]
|
||||
|
||||
_LOGGER.debug(f"Pollen data: {pollen_data}")
|
||||
return pollen_data
|
|
@ -1,352 +0,0 @@
|
|||
"""Create graphs for rain short term forecast."""
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Self
|
||||
|
||||
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 custom_components.irm_kmi.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,
|
||||
):
|
||||
|
||||
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._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 = Drawing()
|
||||
self._dwg_animated: Drawing = Drawing()
|
||||
self._dwg_still: Drawing = Drawing()
|
||||
|
||||
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)
|
||||
|
||||
self.draw_current_fame_line()
|
||||
self.draw_description_text()
|
||||
self.insert_cloud_layer()
|
||||
self.draw_location()
|
||||
self._dwg_animated = self._dwg
|
||||
|
||||
self._dwg = self._dwg_save
|
||||
idx = self._animation_data['most_recent_image_idx']
|
||||
self.draw_current_fame_line(idx)
|
||||
self.draw_description_text(idx)
|
||||
self.insert_cloud_layer(idx)
|
||||
self.draw_location()
|
||||
self._dwg_still = self._dwg
|
||||
return self
|
||||
|
||||
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"
|
||||
))
|
||||
|
||||
def draw_location(self):
|
||||
img = self._animation_data['location']
|
||||
png_data = base64.b64encode(img).decode('utf-8')
|
||||
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
|
||||
self._dwg.add(image)
|
||||
|
||||
def get_dwg(self):
|
||||
return copy.deepcopy(self._dwg)
|
|
@ -8,13 +8,12 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
|||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from irm_kmi_api.api import IrmKmiApiClient
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -50,7 +49,9 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
|
|||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
api_data = await IrmKmiApiClient(
|
||||
session=async_get_clientsession(self.hass)).get_forecasts_coord(
|
||||
session=async_get_clientsession(self.hass),
|
||||
user_agent=USER_AGENT
|
||||
).get_forecasts_coord(
|
||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||
)
|
||||
|
@ -84,8 +85,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."""
|
||||
|
|
Before Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 666 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 60 KiB |
|
@ -9,11 +9,14 @@ 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 custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||
from custom_components.irm_kmi.const import POLLEN_NAMES, POLLEN_TO_ICON_MAP
|
||||
from custom_components.irm_kmi.data import IrmKmiForecast
|
||||
from custom_components.irm_kmi.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)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -22,7 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
|||
"""Set up the sensor platform"""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([IrmKmiPollen(coordinator, entry, pollen.lower()) for pollen in POLLEN_NAMES])
|
||||
async_add_entities([IrmKmiNextWarning(coordinator, entry),])
|
||||
async_add_entities([IrmKmiCurrentWeather(coordinator, entry, name) for name in CURRENT_WEATHER_SENSORS])
|
||||
async_add_entities([IrmKmiNextWarning(coordinator, entry),
|
||||
IrmKmiCurrentRainfall(coordinator, entry)])
|
||||
|
||||
if coordinator.data.get('country') != 'NL':
|
||||
async_add_entities([IrmKmiNextSunMove(coordinator, entry, move) for move in ['sunset', 'sunrise']])
|
||||
|
@ -135,3 +140,91 @@ class IrmKmiNextSunMove(CoordinatorEntity, SensorEntity):
|
|||
if len(upcoming) > 0:
|
||||
return upcoming[0]
|
||||
return None
|
||||
|
||||
|
||||
class IrmKmiCurrentWeather(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a current weather sensor"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||
|
||||
def __init__(self,
|
||||
coordinator: IrmKmiCoordinator,
|
||||
entry: ConfigEntry,
|
||||
sensor_name: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
SensorEntity.__init__(self)
|
||||
self._attr_unique_id = f"{entry.entry_id}-current-{sensor_name}"
|
||||
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_{sensor_name}")
|
||||
self._attr_device_info = coordinator.shared_device_info
|
||||
self._attr_translation_key = f"current_{sensor_name}"
|
||||
self._sensor_name: str = sensor_name
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value of the sensor"""
|
||||
return self.coordinator.data.get('current_weather', {}).get(self._sensor_name, None)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
return CURRENT_WEATHER_SENSOR_UNITS[self._sensor_name]
|
||||
|
||||
@property
|
||||
def device_class(self) -> SensorDeviceClass | None:
|
||||
return CURRENT_WEATHER_SENSOR_CLASS[self._sensor_name]
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return CURRENT_WEATHER_SENSOR_ICON[self._sensor_name]
|
||||
|
||||
|
||||
class IrmKmiCurrentRainfall(CoordinatorEntity, SensorEntity):
|
||||
"""Representation of a current rainfall sensor"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||
|
||||
def __init__(self,
|
||||
coordinator: IrmKmiCoordinator,
|
||||
entry: ConfigEntry) -> None:
|
||||
super().__init__(coordinator)
|
||||
SensorEntity.__init__(self)
|
||||
self._attr_unique_id = f"{entry.entry_id}-current-rainfall"
|
||||
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_rainfall")
|
||||
self._attr_device_info = coordinator.shared_device_info
|
||||
self._attr_translation_key = "current_rainfall"
|
||||
self._attr_icon = 'mdi:weather-pouring'
|
||||
|
||||
def _current_forecast(self) -> IrmKmiRadarForecast | None:
|
||||
now = dt.now()
|
||||
forecasts = self.coordinator.data.get('radar_forecast', None)
|
||||
|
||||
if forecasts is None:
|
||||
return None
|
||||
|
||||
prev = forecasts[0]
|
||||
for f in forecasts:
|
||||
if datetime.fromisoformat(f.get('datetime')) > now:
|
||||
return prev
|
||||
prev = f
|
||||
|
||||
return forecasts[-1]
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value of the sensor"""
|
||||
current = self._current_forecast()
|
||||
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return current.get('native_precipitation', None)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
current = self._current_forecast()
|
||||
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return current.get('unit', None)
|
|
@ -182,6 +182,27 @@
|
|||
"purple": "Purple",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"current_temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"current_wind_speed": {
|
||||
"name": "Wind speed"
|
||||
},
|
||||
"current_wind_gust_speed": {
|
||||
"name": "Wind gust speed"
|
||||
},
|
||||
"current_wind_bearing": {
|
||||
"name": "Wind bearing"
|
||||
},
|
||||
"current_uv_index": {
|
||||
"name": "UV index"
|
||||
},
|
||||
"current_pressure": {
|
||||
"name": "Atmospheric pressure"
|
||||
},
|
||||
"current_rainfall": {
|
||||
"name": "Rainfall"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -182,6 +182,27 @@
|
|||
"purple": "Violet",
|
||||
"none": "Aucun"
|
||||
}
|
||||
},
|
||||
"current_temperature": {
|
||||
"name": "Température"
|
||||
},
|
||||
"current_wind_speed": {
|
||||
"name": "Vitesse du vent"
|
||||
},
|
||||
"current_wind_gust_speed": {
|
||||
"name": "Vitesse des rafales de vent"
|
||||
},
|
||||
"current_wind_bearing": {
|
||||
"name": "Direction du vent"
|
||||
},
|
||||
"current_uv_index": {
|
||||
"name": "Index UV"
|
||||
},
|
||||
"current_pressure": {
|
||||
"name": "Pression atmosphérique"
|
||||
},
|
||||
"current_rainfall": {
|
||||
"name": "Précipitation"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -182,6 +182,27 @@
|
|||
"purple": "Paars",
|
||||
"none": "Geen"
|
||||
}
|
||||
},
|
||||
"current_temperature": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
"current_wind_speed": {
|
||||
"name": "Windsnelheid"
|
||||
},
|
||||
"current_wind_gust_speed": {
|
||||
"name": "Snelheid windvlaag"
|
||||
},
|
||||
"current_wind_bearing": {
|
||||
"name": "Windrichting"
|
||||
},
|
||||
"current_uv_index": {
|
||||
"name": "UV-index"
|
||||
},
|
||||
"current_pressure": {
|
||||
"name": "Luchtdruk"
|
||||
},
|
||||
"current_rainfall": {
|
||||
"name": "Neerslag"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -182,6 +182,27 @@
|
|||
"purple": "Roxo",
|
||||
"none": "Nenhum"
|
||||
}
|
||||
},
|
||||
"current_temperature": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
"current_wind_speed": {
|
||||
"name": "Velocidade do vento"
|
||||
},
|
||||
"current_wind_gust_speed": {
|
||||
"name": "Velocidade da rajada de vento"
|
||||
},
|
||||
"current_wind_bearing": {
|
||||
"name": "Direção do vento"
|
||||
},
|
||||
"current_uv_index": {
|
||||
"name": "Índice UV"
|
||||
},
|
||||
"current_pressure": {
|
||||
"name": "Pressão atmosférica"
|
||||
},
|
||||
"current_rainfall": {
|
||||
"name": "Precipitação"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
@ -41,8 +40,3 @@ def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry) -> str:
|
|||
return get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE)
|
||||
|
||||
|
||||
def next_weekday(current, weekday):
|
||||
days_ahead = weekday - current.weekday()
|
||||
if days_ahead < 0:
|
||||
days_ahead += 7
|
||||
return current + timedelta(days_ahead)
|
||||
|
|
|
@ -167,6 +167,7 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
|||
return [f for f in self.coordinator.data.get('radar_forecast')
|
||||
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.
|
||||
|
|
BIN
img/pollens.png
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 85 KiB |
9
img/radar_example.svg
Normal file
After Width: | Height: | Size: 471 KiB |
18
pyproject.toml
Normal file
|
@ -0,0 +1,18 @@
|
|||
[tool.bumpver]
|
||||
current_version = "0.3.2"
|
||||
version_pattern = "MAJOR.MINOR.PATCH"
|
||||
commit_message = "bump version {old_version} -> {new_version}"
|
||||
tag_message = "{new_version}"
|
||||
tag_scope = "default"
|
||||
pre_commit_hook = ""
|
||||
post_commit_hook = ""
|
||||
commit = true
|
||||
tag = true
|
||||
push = true
|
||||
|
||||
[tool.bumpver.file_patterns]
|
||||
"pyproject.toml" = [
|
||||
'current_version = "{version}"',
|
||||
]
|
||||
"custom_components/irm_kmi/manifest.json" = ['"version": "{version}"']
|
||||
"custom_components/irm_kmi/const.py" = ["'github.com/jdejaegh/irm-kmi-ha {version}'"]
|
|
@ -1,6 +1,4 @@
|
|||
aiohttp==3.10.10
|
||||
async-timeout==4.0.3
|
||||
homeassistant==2024.11.1
|
||||
aiohttp>=3.11.13
|
||||
homeassistant==2025.6.1
|
||||
voluptuous==0.15.2
|
||||
svgwrite==1.4.3
|
||||
aiofile==3.9.0
|
||||
irm-kmi-api==0.2.0
|
|
@ -1,5 +1,6 @@
|
|||
homeassistant==2024.11.1
|
||||
pytest_homeassistant_custom_component==0.13.182
|
||||
homeassistant==2025.6.1
|
||||
pytest_homeassistant_custom_component==0.13.252
|
||||
pytest
|
||||
freezegun
|
||||
isort
|
||||
bumpver
|
|
@ -2,40 +2,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timedelta
|
||||
from typing 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.api import (IrmKmiApiError,
|
||||
IrmKmiApiParametersError)
|
||||
from custom_components.irm_kmi import OPTION_STYLE_STD
|
||||
from custom_components.irm_kmi.const import (
|
||||
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||
CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||
OPTION_DEPRECATED_FORECAST_TWICE_DAILY, OPTION_STYLE_STD)
|
||||
CONF_USE_DEPRECATED_FORECAST, DOMAIN, IRM_KMI_TO_HA_CONDITION_MAP,
|
||||
OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||
OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
|
||||
|
||||
|
||||
def get_api_data(fixture: str) -> dict:
|
||||
return json.loads(load_fixture(fixture))
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
@ -121,24 +115,11 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
|
|||
|
||||
forecast = json.loads(load_fixture(fixture))
|
||||
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
|
||||
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
|
||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||
) as irm_kmi_api_mock:
|
||||
irm_kmi = irm_kmi_api_mock.return_value
|
||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
||||
irm_kmi.get_radar_forecast.return_value = {}
|
||||
yield irm_kmi
|
||||
|
||||
|
||||
|
@ -174,111 +155,34 @@ def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Ge
|
|||
def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||
"""Return a mocked IrmKmi api client."""
|
||||
with patch(
|
||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||
) as irm_kmi_api_mock:
|
||||
irm_kmi = irm_kmi_api_mock.return_value
|
||||
irm_kmi.get_forecasts_coord.side_effect = IrmKmiApiParametersError
|
||||
irm_kmi.refresh_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()
|
||||
|
||||
@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"
|
||||
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)
|
||||
]
|
||||
|
||||
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
|
||||
return RadarAnimationData(
|
||||
sequence=sequence,
|
||||
most_recent_image_idx=2,
|
||||
hint="Testing SVG camera",
|
||||
unit="mm/10min",
|
||||
location=location
|
||||
)
|
||||
|
|
2
tests/fixtures/be_forecast_warning.json
vendored
|
@ -1474,7 +1474,7 @@
|
|||
{
|
||||
"time": "2024-01-12T10:10:00+01:00",
|
||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202401120920&f=2&k=2160a92594985471351907ee5cc75d1f&d=202401120900",
|
||||
"value": 0,
|
||||
"value": 0.42,
|
||||
"position": 0,
|
||||
"positionLower": 0,
|
||||
"positionHigher": 0
|
||||
|
|
2
tests/fixtures/forecast_ams_no_ww.json
vendored
|
@ -1642,7 +1642,7 @@
|
|||
{
|
||||
"time": "2024-06-09T13:40:00+00:00",
|
||||
"uri": "https:\/\/cdn.knmi.nl\/knmi\/map\/page\/weer\/actueel-weer\/neerslagradar\/weerapp\/RAD_NL25_PCP_CM_202406091340_640.png",
|
||||
"value": 0,
|
||||
"value": 0.1341,
|
||||
"position": 0,
|
||||
"positionLower": 0,
|
||||
"positionHigher": 0
|
||||
|
|
4
tests/fixtures/forecast_nl.json
vendored
|
@ -6,7 +6,7 @@
|
|||
"municipality_code": "0995",
|
||||
"temp": 11,
|
||||
"windSpeedKm": 40,
|
||||
"timestamp": "2023-12-28T14:20:00+00:00",
|
||||
"timestamp": "2023-12-28T14:30:00+00:00",
|
||||
"windDirection": 45,
|
||||
"municipality": "Lelystad",
|
||||
"windDirectionText": {
|
||||
|
@ -1337,7 +1337,7 @@
|
|||
{
|
||||
"time": "2023-12-28T14:25:00+00:00",
|
||||
"uri": "https:\/\/cdn.knmi.nl\/knmi\/map\/page\/weer\/actueel-weer\/neerslagradar\/weerapp\/RAD_NL25_PCP_CM_202312281425_640.png",
|
||||
"value": 0,
|
||||
"value": 0.15,
|
||||
"position": 0,
|
||||
"positionLower": 0,
|
||||
"positionHigher": 0
|
||||
|
|
712
tests/fixtures/getWarnings-all-countries.json
vendored
Normal file
|
@ -0,0 +1,712 @@
|
|||
[
|
||||
{
|
||||
"icon_country": "BE",
|
||||
"warningType": {
|
||||
"id": "2",
|
||||
"name": {
|
||||
"nl": "Gladheid",
|
||||
"fr": "Conditions glissantes",
|
||||
"de": "Gl\u00e4tte",
|
||||
"en": "Ice or snow"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"nl": "Vanochtend vriest het op de meeste plaatsen. Na de neerslag van gisteren zijn de wegen nog voldoende vochtig en ligt er nog plaatselijk sneeuw die kan bevriezen en eveneens voor gladde plekken zorgen.\n",
|
||||
"fr": "Ce matin, le gel concernera la plupart des r\u00e9gions. Le risque de plaque de glace sera assez \u00e9lev\u00e9 avec de nombreuses routes glissantes.\n"
|
||||
},
|
||||
"legendUri": {
|
||||
"nl": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=nl",
|
||||
"fr": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=fr",
|
||||
"de": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=de",
|
||||
"en": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=en"
|
||||
},
|
||||
"region": [
|
||||
{
|
||||
"name": {
|
||||
"nl": "Brussel",
|
||||
"fr": "Bruxelles",
|
||||
"en": "Brussels",
|
||||
"de": "Br\u00fcssel"
|
||||
},
|
||||
"district": [
|
||||
{
|
||||
"region": {
|
||||
"code": 6446,
|
||||
"name": {
|
||||
"nl": "Brussel",
|
||||
"fr": "Bruxelles",
|
||||
"en": "Brussels",
|
||||
"de": "Br\u00fcssel"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": {
|
||||
"nl": "Vlaanderen",
|
||||
"fr": "Flandre",
|
||||
"en": "Flanders",
|
||||
"de": "Flandern"
|
||||
},
|
||||
"district": [
|
||||
{
|
||||
"region": {
|
||||
"code": 6404,
|
||||
"name": {
|
||||
"nl": "Kust",
|
||||
"fr": "C\u00f4te",
|
||||
"en": "Coast",
|
||||
"de": "K\u00fcste"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6407,
|
||||
"name": {
|
||||
"nl": "West-Vlaanderen",
|
||||
"fr": "Flandre-Occidentale",
|
||||
"en": "West Flanders",
|
||||
"de": "Westflandern"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6431,
|
||||
"name": {
|
||||
"nl": "Oost-Vlaanderen",
|
||||
"fr": "Flandre-Orientale",
|
||||
"en": "East Flanders",
|
||||
"de": "Ostflandern"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6450,
|
||||
"name": {
|
||||
"nl": "Antwerpen",
|
||||
"fr": "Anvers",
|
||||
"en": "Antwerp",
|
||||
"de": "Antwerpen"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6479,
|
||||
"name": {
|
||||
"nl": "Limburg",
|
||||
"fr": "Limbourg",
|
||||
"en": "Limburg",
|
||||
"de": "Limburg"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6446,
|
||||
"name": {
|
||||
"nl": "Vlaams-Brabant",
|
||||
"fr": "Brabant flamand",
|
||||
"en": "Flemish Brabant",
|
||||
"de": "Fl\u00e4misch-Brabant"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": {
|
||||
"nl": "Walloni\u00eb",
|
||||
"fr": "Wallonie",
|
||||
"en": "Wallonia",
|
||||
"de": "Wallonien"
|
||||
},
|
||||
"district": [
|
||||
{
|
||||
"region": {
|
||||
"code": 6478,
|
||||
"name": {
|
||||
"nl": "Luik",
|
||||
"fr": "Li\u00e8ge",
|
||||
"en": "Liege",
|
||||
"de": "L\u00fcttich"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6432,
|
||||
"name": {
|
||||
"nl": "Henegouwen",
|
||||
"fr": "Hainaut",
|
||||
"en": "Hainaut",
|
||||
"de": "Hennegau"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6456,
|
||||
"name": {
|
||||
"nl": "Namen",
|
||||
"fr": "Namur",
|
||||
"en": "Namur",
|
||||
"de": "Nam\u00fcr"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6476,
|
||||
"name": {
|
||||
"nl": "Luxemburg",
|
||||
"fr": "Luxembourg",
|
||||
"en": "Luxembourg",
|
||||
"de": "Luxemburg"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 6446,
|
||||
"name": {
|
||||
"nl": "Waals-Brabant",
|
||||
"fr": "Brabant wallon",
|
||||
"en": "Walloon Brabant",
|
||||
"de": "Wallonisch-Brabant"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon_country": "NL",
|
||||
"warningType": {
|
||||
"id": "2",
|
||||
"name": {
|
||||
"nl": "Sneeuw en gladheid",
|
||||
"fr": "Neige ou verglas",
|
||||
"en": "Ice or snow",
|
||||
"de": "Gl\u00e4tte"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"fr": "Vanochtend op veel plaatsen glad\n\nVanochtend is het op veel plaatsen glad door bevriezing van natte weggedeelten. In het noorden ook door een enkele (winterse) bui met mogelijk wat ijzel. Vanaf ongeveer 10 uur verdwijnt de gladheid.\n\nEr is kans op ongelukken door gladde bruggen, wegen, fietspaden en voetpaden.\n\n \n\n Meer details \nTot ca. 10 uur is het op veel plaatsen glad door bevriezing (verraderlijk) glad. In het noorden komen er ook enkele (winterse) buien voor met een kleine kans op ijzel (regen op een bevroren ondergrond). Na ongeveer 10 uur verdwijnt de gladheid.\nUitgifte: 10\/01\/2025 07:52 uur LT",
|
||||
"nl": "Vanochtend op veel plaatsen glad\n\nVanochtend is het op veel plaatsen glad door bevriezing van natte weggedeelten. In het noorden ook door een enkele (winterse) bui met mogelijk wat ijzel. Vanaf ongeveer 10 uur verdwijnt de gladheid.\n\nEr is kans op ongelukken door gladde bruggen, wegen, fietspaden en voetpaden.\n\n \n\n Meer details \nTot ca. 10 uur is het op veel plaatsen glad door bevriezing (verraderlijk) glad. In het noorden komen er ook enkele (winterse) buien voor met een kleine kans op ijzel (regen op een bevroren ondergrond). Na ongeveer 10 uur verdwijnt de gladheid.\nUitgifte: 10\/01\/2025 07:52 uur LT"
|
||||
},
|
||||
"legendUri": {
|
||||
"nl": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||
"fr": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||
"de": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||
"en": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen"
|
||||
},
|
||||
"region": [
|
||||
{
|
||||
"name": {
|
||||
"nl": "Nederland",
|
||||
"fr": "Pays-Bas",
|
||||
"en": "Netherlands",
|
||||
"de": "Niederlande"
|
||||
},
|
||||
"district": [
|
||||
{
|
||||
"region": {
|
||||
"code": "200009",
|
||||
"name": {
|
||||
"nl": "Drenthe",
|
||||
"fr": "Drenthe",
|
||||
"en": "Drenthe",
|
||||
"de": "Drenthe"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200001",
|
||||
"name": {
|
||||
"nl": "Flevoland",
|
||||
"fr": "Flevoland",
|
||||
"en": "Flevoland",
|
||||
"de": "Flevoland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200011",
|
||||
"name": {
|
||||
"nl": "Friesland",
|
||||
"fr": "Friesland",
|
||||
"en": "Friesland",
|
||||
"de": "Friesland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200005",
|
||||
"name": {
|
||||
"nl": "Gelderland",
|
||||
"fr": "Gelderland",
|
||||
"en": "Gelderland",
|
||||
"de": "Gelderland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200010",
|
||||
"name": {
|
||||
"nl": "Groningen",
|
||||
"fr": "Groningen",
|
||||
"en": "Groningen",
|
||||
"de": "Groningen"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 200014,
|
||||
"name": {
|
||||
"nl": "IJsselmeergebied",
|
||||
"fr": "IJsselmeergebied",
|
||||
"en": "IJsselmeergebied",
|
||||
"de": "IJsselmeergebied"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": 0,
|
||||
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200007",
|
||||
"name": {
|
||||
"nl": "Limburg",
|
||||
"fr": "Limburg",
|
||||
"en": "Limburg",
|
||||
"de": "Limburg"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T11:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T17:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200004",
|
||||
"name": {
|
||||
"nl": "Noord-Brabant",
|
||||
"fr": "Noord-Brabant",
|
||||
"en": "Noord-Brabant",
|
||||
"de": "Noord-Brabant"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200000",
|
||||
"name": {
|
||||
"nl": "Noord-Holland",
|
||||
"fr": "Noord-Holland",
|
||||
"en": "Noord-Holland",
|
||||
"de": "Noord-Holland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200008",
|
||||
"name": {
|
||||
"nl": "Overijssel",
|
||||
"fr": "Overijssel",
|
||||
"en": "Overijssel",
|
||||
"de": "Overijssel"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200002",
|
||||
"name": {
|
||||
"nl": "Utrecht",
|
||||
"fr": "Utrecht",
|
||||
"en": "Utrecht",
|
||||
"de": "Utrecht"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 200012,
|
||||
"name": {
|
||||
"nl": "Waddeneilanden",
|
||||
"fr": "Waddeneilanden",
|
||||
"en": "Waddeneilanden",
|
||||
"de": "Waddeneilanden"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": 0,
|
||||
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": 200013,
|
||||
"name": {
|
||||
"nl": "Waddenzee",
|
||||
"fr": "Waddenzee",
|
||||
"en": "Waddenzee",
|
||||
"de": "Waddenzee"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": 0,
|
||||
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200006",
|
||||
"name": {
|
||||
"nl": "Zeeland",
|
||||
"fr": "Zeeland",
|
||||
"en": "Zeeland",
|
||||
"de": "Zeeland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "200003",
|
||||
"name": {
|
||||
"nl": "Zuid-Holland",
|
||||
"fr": "Zuid-Holland",
|
||||
"en": "Zuid-Holland",
|
||||
"de": "Zuid-Holland"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon_country": "LU",
|
||||
"warningType": {
|
||||
"id": "15",
|
||||
"name": {
|
||||
"nl": "Overstroming",
|
||||
"fr": "Crue",
|
||||
"en": "Flood",
|
||||
"de": "Hochwasser"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"fr": "vendredi 00:00 \u00e0\u00a0vendredi 17:59, pour le nord du pays: Crue mineure pouvant entra\u00eener des inondations et dommages locaux. Vigilance particuli\u00e8re dans le cas d\u2019activit\u00e9s saisonni\u00e8res et\/ou expos\u00e9es. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) vendredi 00:00 \u00e0\u00a0samedi 19:59, pour le sud du pays: Crue mineure pouvant entra\u00eener des inondations et dommages locaux. Vigilance particuli\u00e8re dans le cas d\u2019activit\u00e9s saisonni\u00e8res et\/ou expos\u00e9es. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) samedi 20:00 \u00e0\u00a0dimanche 06:59, pour le sud du pays: Situation m\u00e9t\u00e9orologique indiquant un risque \u00e9ventuel de crue. Soyez vigilant. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) (MeteoLux - www.meteolux.lu)",
|
||||
"nl": "",
|
||||
"de": "Freitag 00:00 bis\u00a0Freitag 17:59, f\u00fcr den Norden des Landes: Geringes Hochwasser, welches lokal zu \u00dcberschwemmungen und Sch\u00e4den f\u00fchren kann. Bei Aktivit\u00e4ten in betroffenen Gebieten ist Vorsicht geboten. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) Freitag 00:00 bis\u00a0Samstag 19:59, f\u00fcr den S\u00fcden des Landes: Geringes Hochwasser, welches lokal zu \u00dcberschwemmungen und Sch\u00e4den f\u00fchren kann. Bei Aktivit\u00e4ten in betroffenen Gebieten ist Vorsicht geboten. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) Samstag 20:00 bis\u00a0Sonntag 06:59, f\u00fcr den S\u00fcden des Landes: Die meteorologische Situation deutet auf eine m\u00f6gliche \u00dcberschwemmungsgefahr hin. Bleiben Sie wachsam. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) (MeteoLux - www.meteolux.lu)"
|
||||
},
|
||||
"legendUri": {
|
||||
"nl": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=nl",
|
||||
"fr": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=fr",
|
||||
"de": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=de",
|
||||
"en": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=en"
|
||||
},
|
||||
"region": [
|
||||
{
|
||||
"name": {
|
||||
"nl": "Groothertogdom Luxemburg",
|
||||
"fr": "Grand-Duch\u00e9 de Luxembourg",
|
||||
"en": "Grand Duchy of Luxembourg",
|
||||
"de": "Gro\u00dfherzogtum Luxemburg"
|
||||
},
|
||||
"district": [
|
||||
{
|
||||
"region": {
|
||||
"code": "6590",
|
||||
"name": {
|
||||
"nl": "Luxemburg Zuid",
|
||||
"fr": "Luxembourg Sud",
|
||||
"en": "Luxembourg South",
|
||||
"de": "Luxemburg S\u00fcd"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "1",
|
||||
"fromTimestamp": "2025-01-11T19:00:00+00:00",
|
||||
"toTimestamp": "2025-01-12T06:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"level": "2",
|
||||
"fromTimestamp": "2025-01-09T23:00:00+00:00",
|
||||
"toTimestamp": "2025-01-11T19:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"region": {
|
||||
"code": "6585",
|
||||
"name": {
|
||||
"nl": "Luxemburg Noord",
|
||||
"fr": "Luxembourg Nord",
|
||||
"en": "Luxembourg North",
|
||||
"de": "Luxemburg Nord"
|
||||
}
|
||||
},
|
||||
"intervals": [
|
||||
{
|
||||
"level": "2",
|
||||
"fromTimestamp": "2025-01-09T23:00:00+00:00",
|
||||
"toTimestamp": "2025-01-10T17:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
55
tests/fixtures/pollens-2025.svg
vendored
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="35 88 129.0 50.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs id="defs737"/>
|
||||
<g id="layer1">
|
||||
<rect id="rectangle-white" x="35" y="88" width="129.0" height="50.0" rx="5" fill="grey" fill-opacity="1"/>
|
||||
<g id="g1495" transform="translate(30.342966,94.25)">
|
||||
<g id="layer1-2">
|
||||
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
|
||||
rx="1.6348698" ry="1.5258785"/>
|
||||
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
|
||||
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
|
||||
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
|
||||
height="3.5655735" x="12.923257" y="1.401318"/>
|
||||
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
|
||||
height="2.5535111" x="15.866023" y="2.36669"/>
|
||||
</g>
|
||||
</g>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
|
||||
<text xml:space="preserve"
|
||||
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"/></text>
|
||||
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
|
||||
x="35.451504" y="105.5"/>
|
||||
<g transform="translate(36.0,120.0) scale(0.4,0.4)">
|
||||
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.833 30.1555C141.065 22.499 137.677 15.3011 132.188 9.81192C125.906 3.52946 117.385 6.7078e-07 108.5 0C99.6153 -6.7078e-07 91.0944 3.52945 84.8119 9.81192C79.3228 15.3011 75.9352 22.499 75.1673 30.1555C74.9826 31.9964 76.4998 33.5 78.35 33.5V33.5C80.2002 33.5 81.6784 31.9943 81.909 30.1586C82.6472 24.2828 85.3177 18.7814 89.5495 14.5495C94.5755 9.52356 101.392 6.7 108.5 6.7C115.608 6.7 122.424 9.52356 127.45 14.5495C131.682 18.7814 134.353 24.2828 135.091 30.1586C135.322 31.9943 136.8 33.5 138.65 33.5V33.5Z"
|
||||
fill="#70AC48" id="path12394"/>
|
||||
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.242 24.271 139.101 18.6259 135.602 13.8092C131.443 8.0858 125.58 3.82575 118.852 1.63961C112.123 -0.546536 104.876 -0.546535 98.1475 1.63961C91.4192 3.82575 85.5558 8.0858 81.3975 13.8092L86.8179 17.7474C90.1445 13.1686 94.8353 9.7606 100.218 8.01168C105.6 6.26277 111.399 6.26277 116.781 8.01168C122.164 9.7606 126.855 13.1686 130.181 17.7474C132.849 21.4187 134.529 25.6916 135.09 30.1586C135.321 31.9943 136.799 33.5 138.65 33.5V33.5Z"
|
||||
fill="#FED966" id="path12396"/>
|
||||
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.418 26.0285 140.24 22.0042 138.348 18.2913C135.948 13.5809 132.467 9.50535 128.19 6.39793C123.913 3.29052 118.962 1.23946 113.74 0.412444C108.519 -0.414569 103.175 0.00594664 98.1475 1.63961L100.218 8.01169C104.24 6.70476 108.515 6.36834 112.692 7.02995C116.869 7.69157 120.831 9.33242 124.252 11.8183C127.674 14.3043 130.458 17.5647 132.379 21.3331C133.79 24.1034 134.705 27.0904 135.09 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||
fill="#EE7D31" id="path12398"/>
|
||||
<path d="M138.65 33.5C140.5 33.5 142.017 31.9965 141.832 30.1555C141.242 24.271 139.101 18.626 135.602 13.8092C131.443 8.08584 125.58 3.82579 118.852 1.63965L116.781 8.01173C122.164 9.76064 126.855 13.1687 130.181 17.7474C132.849 21.4188 134.529 25.6917 135.091 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||
fill="#C00000" id="path12400"/>
|
||||
<path d="M138.65 33.4999C140.5 33.4999 142.017 31.9963 141.833 30.1554C141.242 24.2709 139.102 18.6258 135.602 13.8091L130.182 17.7472C132.849 21.4186 134.53 25.6915 135.091 30.1585C135.322 31.9942 136.8 33.4999 138.65 33.4999V33.4999Z"
|
||||
fill="#70309F" id="path12402"/>
|
||||
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.833 30.1555C242.065 22.499 238.677 15.3011 233.188 9.81192C226.906 3.52946 218.385 6.7078e-07 209.5 0C200.615 -6.7078e-07 192.094 3.52945 185.812 9.81192C180.323 15.3011 176.935 22.499 176.167 30.1555C175.983 31.9964 177.5 33.5 179.35 33.5V33.5C181.2 33.5 182.678 31.9943 182.909 30.1586C183.647 24.2828 186.318 18.7814 190.55 14.5495C195.576 9.52356 202.392 6.7 209.5 6.7C216.608 6.7 223.424 9.52356 228.45 14.5495C232.682 18.7814 235.353 24.2828 236.091 30.1586C236.322 31.9943 237.8 33.5 239.65 33.5V33.5Z"
|
||||
fill="#cccccc" id="path12404"/>
|
||||
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.242 24.271 240.101 18.6259 236.602 13.8092C232.443 8.0858 226.58 3.82575 219.852 1.63961C213.123 -0.546536 205.876 -0.546535 199.147 1.63961C192.419 3.82575 186.556 8.0858 182.397 13.8092L187.818 17.7474C191.145 13.1686 195.835 9.7606 201.218 8.01168C206.6 6.26277 212.399 6.26277 217.781 8.01168C223.164 9.7606 227.855 13.1686 231.181 17.7474C233.849 21.4187 235.529 25.6916 236.09 30.1586C236.321 31.9943 237.799 33.5 239.65 33.5V33.5Z"
|
||||
fill="#cccccc" id="path12406"/>
|
||||
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.418 26.0285 241.24 22.0042 239.348 18.2913C236.948 13.5809 233.467 9.50535 229.19 6.39793C224.913 3.29052 219.962 1.23946 214.74 0.412444C209.519 -0.414569 204.175 0.00594664 199.147 1.63961L201.218 8.01169C205.24 6.70476 209.515 6.36834 213.692 7.02995C217.869 7.69157 221.831 9.33242 225.252 11.8183C228.674 14.3043 231.458 17.5647 233.379 21.3331C234.79 24.1034 235.705 27.0904 236.09 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||
fill="#cccccc" id="path12408"/>
|
||||
<path d="M239.65 33.5C241.5 33.5 243.017 31.9965 242.832 30.1555C242.242 24.271 240.101 18.626 236.602 13.8092C232.443 8.08584 226.58 3.82579 219.852 1.63965L217.781 8.01173C223.164 9.76064 227.855 13.1687 231.181 17.7474C233.849 21.4188 235.529 25.6917 236.091 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||
fill="#cccccc" id="path12410"/>
|
||||
<path d="M239.65 33.4999C241.5 33.4999 243.017 31.9963 242.833 30.1554C242.242 24.2709 240.102 18.6258 236.602 13.8091L231.182 17.7472C233.849 21.4186 235.53 25.6915 236.091 30.1585C236.322 31.9942 237.8 33.4999 239.65 33.4999V33.4999Z"
|
||||
fill="#cccccc" id="path12412"/>
|
||||
<text xml:space="preserve" id="text12907"><tspan id="tspan12905" x="109.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> active</tspan></text>
|
||||
<text xml:space="preserve" id="text13637"><tspan id="tspan13635" x="209.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> active</tspan></text>
|
||||
<text xml:space="preserve" id="text13641"><tspan id="tspan13639" x="109.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Alder</tspan></text>
|
||||
<text xml:space="preserve" id="text13645"><tspan id="tspan13643" x="209.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Hazel</tspan></text>
|
||||
<circle style="fill:#ffffff" id="cursor1" cx="79.99277625299781" cy="24.274981671564106" r="4.0"/>
|
||||
<circle style="fill:#ffffff" id="cursor2" cx="-99999999" cy="-99999999" r="4.0"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 7.8 KiB |
|
@ -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_STYLE_SATELLITE,
|
||||
OPTION_STYLE_STD)
|
||||
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||
|
||||
|
||||
async def test_full_user_flow(
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
import zoneinfo
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from freezegun import freeze_time
|
||||
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_RAINY, Forecast)
|
||||
from homeassistant.components.weather import ATTR_CONDITION_CLOUDY
|
||||
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 (CurrentWeatherData, IrmKmiForecast,
|
||||
IrmKmiRadarForecast,
|
||||
ProcessedCoordinatorData,
|
||||
RadarAnimationData)
|
||||
from custom_components.irm_kmi.pollen import PollenParser
|
||||
from tests.conftest import get_api_data
|
||||
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||
from tests.conftest import get_api_data, get_api_with_data
|
||||
|
||||
|
||||
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||
|
@ -27,219 +20,18 @@ 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
|
||||
):
|
||||
hass.states.async_set(
|
||||
"zone.home",
|
||||
0,
|
||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
coordinator._api._api_data = get_api_data("forecast.json")
|
||||
|
||||
result = await coordinator._async_update_data()
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||
|
||||
assert result.get('animation') == dict()
|
||||
assert result.get('animation').get_hint() == "No rain forecasted shortly"
|
||||
|
||||
assert result.get('pollen') == PollenParser.get_unavailable_data()
|
||||
|
||||
|
@ -247,62 +39,63 @@ async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
|||
current_weather=CurrentWeatherData(),
|
||||
daily_forecast=[],
|
||||
hourly_forecast=[],
|
||||
animation=RadarAnimationData(hint="This will remain unchanged"),
|
||||
animation=None,
|
||||
warnings=[],
|
||||
pollen={'foo': 'bar'}
|
||||
)
|
||||
coordinator.data = existing_data
|
||||
result = await coordinator._async_update_data()
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||
|
||||
assert result.get('animation').get('hint') == "This will remain unchanged"
|
||||
assert result.get('animation').get_hint() == "No rain forecasted shortly"
|
||||
|
||||
assert result.get('pollen') == {'foo': 'bar'}
|
||||
|
||||
|
||||
def test_radar_forecast() -> None:
|
||||
api_data = get_api_data("forecast.json")
|
||||
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
||||
api = get_api_with_data("forecast.json")
|
||||
result = api.get_radar_forecast()
|
||||
|
||||
expected = [
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0)
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min')
|
||||
]
|
||||
|
||||
assert expected == result
|
||||
|
||||
|
||||
def test_radar_forecast_rain_interval() -> None:
|
||||
api_data = get_api_data('forecast_with_rain_on_radar.json')
|
||||
result = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
||||
api = get_api_with_data('forecast_with_rain_on_radar.json')
|
||||
result = api.get_radar_forecast()
|
||||
|
||||
_12 = IrmKmiRadarForecast(
|
||||
datetime='2024-05-30T18:00:00+02:00',
|
||||
native_precipitation=0.89,
|
||||
might_rain=True,
|
||||
rain_forecast_max=1.12,
|
||||
rain_forecast_min=0.50
|
||||
rain_forecast_min=0.50,
|
||||
unit='mm/10min'
|
||||
)
|
||||
|
||||
_13 = IrmKmiRadarForecast(
|
||||
|
@ -310,82 +103,9 @@ def test_radar_forecast_rain_interval() -> None:
|
|||
native_precipitation=0.83,
|
||||
might_rain=True,
|
||||
rain_forecast_max=1.09,
|
||||
rain_forecast_min=0.64
|
||||
rain_forecast_min=0.64,
|
||||
unit='mm/10min'
|
||||
)
|
||||
|
||||
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'
|
||||
|
|
64
tests/test_current_weather_sensors.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import inspect
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
|
||||
def test_sensors_in_current_weather_data():
|
||||
weather_data_keys = inspect.get_annotations(CurrentWeatherData).keys()
|
||||
|
||||
for sensor in CURRENT_WEATHER_SENSORS:
|
||||
assert sensor in weather_data_keys
|
||||
|
||||
def test_sensors_have_unit():
|
||||
weather_sensor_units_keys = CURRENT_WEATHER_SENSOR_UNITS.keys()
|
||||
|
||||
for sensor in CURRENT_WEATHER_SENSORS:
|
||||
assert sensor in weather_sensor_units_keys
|
||||
|
||||
def test_sensors_have_class():
|
||||
weather_sensor_class_keys = CURRENT_WEATHER_SENSOR_CLASS.keys()
|
||||
|
||||
for sensor in CURRENT_WEATHER_SENSORS:
|
||||
assert sensor in weather_sensor_class_keys
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected,filename",
|
||||
[
|
||||
('mm/h', 'forecast_ams_no_ww.json'),
|
||||
('mm/10min', 'forecast_out_of_benelux.json'),
|
||||
('mm/10min', 'forecast_with_rain_on_radar.json'),
|
||||
])
|
||||
async def test_current_rainfall_unit(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
expected,
|
||||
filename
|
||||
) -> None:
|
||||
hass.config.time_zone = 'Europe/Brussels'
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
api = get_api_with_data(filename)
|
||||
tz = ZoneInfo("Europe/Brussels")
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry)
|
||||
|
||||
assert s.native_unit_of_measurement == expected
|
|
@ -8,18 +8,17 @@ 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 async_migrate_entry
|
||||
from custom_components.irm_kmi import OPTION_STYLE_STD, 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_STYLE_STD)
|
||||
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||
|
||||
|
||||
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(
|
||||
|
@ -57,7 +56,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.get_forecasts_coord.call_count == 1
|
||||
assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
|
|
|
@ -1,59 +1,26 @@
|
|||
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 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_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
|
||||
from tests.conftest import get_api_with_data
|
||||
|
||||
|
||||
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_data = get_api_data("be_forecast_warning.json")
|
||||
api = get_api_with_data("be_forecast_warning.json")
|
||||
|
||||
result = await coordinator._async_pollen_data(api_data)
|
||||
api.get_svg = AsyncMock()
|
||||
api.get_svg.side_effect = IrmKmiApiError
|
||||
|
||||
coordinator._api = api
|
||||
|
||||
result = await coordinator.process_api_data()
|
||||
expected = PollenParser.get_unavailable_data()
|
||||
assert result == expected
|
||||
assert result['pollen'] == expected
|
||||
|
|
|
@ -1,267 +0,0 @@
|
|||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from custom_components.irm_kmi.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
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
rain_graph.draw_location()
|
||||
|
||||
str_svg = rain_graph.get_dwg().tostring()
|
||||
|
||||
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
|
||||
png_b64 = base64.b64encode(file.read()).decode('utf-8')
|
||||
|
||||
assert png_b64 in str_svg
|
|
@ -1,10 +1,12 @@
|
|||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, 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
|
||||
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||
load_fixture)
|
||||
|
||||
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||
from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE,
|
||||
|
@ -28,6 +30,11 @@ async def get_repair_flow(
|
|||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.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")
|
||||
|
@ -38,7 +45,6 @@ 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(
|
||||
|
@ -50,6 +56,8 @@ 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)
|
||||
|
@ -65,7 +73,6 @@ 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:
|
||||
|
@ -87,7 +94,6 @@ 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:
|
||||
|
@ -106,7 +112,6 @@ 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:
|
||||
|
@ -125,7 +130,6 @@ 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:
|
||||
|
@ -144,7 +148,6 @@ 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)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
@ -9,7 +10,7 @@ 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_data
|
||||
from tests.conftest import get_api_with_data, get_radar_animation_data
|
||||
|
||||
|
||||
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
|
||||
|
@ -17,10 +18,10 @@ async def test_warning_data(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
api_data = get_api_data("be_forecast_warning.json")
|
||||
api = get_api_with_data("be_forecast_warning.json")
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
|
||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
||||
result = api.get_warnings('en')
|
||||
|
||||
coordinator.data = {'warnings': result}
|
||||
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||
|
@ -40,15 +41,18 @@ 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_data = get_api_data("be_forecast_warning.json")
|
||||
api = get_api_with_data("be_forecast_warning.json")
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
|
||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
||||
api.get_pollen = AsyncMock()
|
||||
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||
coordinator._api = api
|
||||
|
||||
coordinator.data = {'warnings': result}
|
||||
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
coordinator.data = {'warnings': result['warnings']}
|
||||
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||
warning.hass = hass
|
||||
|
||||
|
@ -66,18 +70,27 @@ async def test_next_warning_when_data_available(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
api_data = get_api_data("be_forecast_warning.json")
|
||||
api = get_api_with_data("be_forecast_warning.json")
|
||||
await hass.config_entries.async_add(mock_config_entry)
|
||||
hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'de'})
|
||||
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
|
||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
||||
api.get_pollen = AsyncMock()
|
||||
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||
coordinator._api = api
|
||||
|
||||
coordinator.data = {'warnings': result}
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
coordinator.data = {'warnings': result['warnings']}
|
||||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||
warning.hass = hass
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
warning._attr_translation_key = None
|
||||
|
||||
assert warning.state == "2024-01-12T06:00:00+00:00"
|
||||
assert len(warning.extra_state_attributes['next_warnings']) == 2
|
||||
|
||||
|
@ -89,15 +102,24 @@ async def test_next_warning_none_when_only_active_warnings(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
api_data = get_api_data("be_forecast_warning.json")
|
||||
api = get_api_with_data("be_forecast_warning.json")
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
|
||||
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
|
||||
api.get_pollen = AsyncMock()
|
||||
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||
coordinator._api = api
|
||||
|
||||
coordinator.data = {'warnings': result}
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
coordinator.data = {'warnings': result['warnings']}
|
||||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||
warning.hass = hass
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
warning._attr_translation_key = None
|
||||
|
||||
assert warning.state is None
|
||||
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||
|
||||
|
@ -115,6 +137,11 @@ async def test_next_warning_none_when_no_warnings(
|
|||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||
warning.hass = hass
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
warning._attr_translation_key = None
|
||||
|
||||
assert warning.state is None
|
||||
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||
|
||||
|
@ -124,6 +151,11 @@ async def test_next_warning_none_when_no_warnings(
|
|||
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||
warning.hass = hass
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
warning._attr_translation_key = None
|
||||
|
||||
assert warning.state is None
|
||||
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||
|
||||
|
@ -135,17 +167,26 @@ async def test_next_sunrise_sunset(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
api_data = get_api_data("forecast.json")
|
||||
api = get_api_with_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.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
coordinator.data = {'daily_forecast': result}
|
||||
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||
|
||||
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextSunMove'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
sunrise._attr_translation_key = None
|
||||
sunset._attr_translation_key = None
|
||||
|
||||
assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00')
|
||||
assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-27T16:43:00+01:00')
|
||||
|
||||
|
@ -155,16 +196,25 @@ async def test_next_sunrise_sunset_bis(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
api_data = get_api_data("forecast.json")
|
||||
api = get_api_with_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.daily_list_to_forecast(api_data.get('for', {}).get('daily'))
|
||||
result = await coordinator.process_api_data()
|
||||
|
||||
coordinator.data = {'daily_forecast': result}
|
||||
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||
|
||||
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||
|
||||
# This somehow fixes the following error that popped since 2024.12.0
|
||||
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextSunMove'> cannot have a translation key for
|
||||
# unit of measurement before being added to the entity platform
|
||||
sunrise._attr_translation_key = None
|
||||
sunset._attr_translation_key = None
|
||||
|
||||
assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00')
|
||||
assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-26T16:42:00+01:00')
|
||||
|
|
|
@ -1,36 +1,29 @@
|
|||
import os
|
||||
import json
|
||||
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.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
from irm_kmi_api.data import IrmKmiRadarForecast
|
||||
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||
load_fixture)
|
||||
|
||||
from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather
|
||||
from custom_components.irm_kmi.data import (IrmKmiRadarForecast,
|
||||
ProcessedCoordinatorData)
|
||||
from tests.conftest import get_api_data
|
||||
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||
from tests.conftest import get_api_with_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:
|
||||
hass.states.async_set(
|
||||
"zone.home",
|
||||
0,
|
||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||
)
|
||||
hass.config.config_dir = os.getcwd()
|
||||
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
await coordinator.async_refresh()
|
||||
forecast = json.loads(load_fixture("forecast_nl.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()
|
||||
|
||||
|
@ -45,19 +38,14 @@ 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
|
||||
hass.states.async_set(
|
||||
"zone.home",
|
||||
0,
|
||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||
)
|
||||
hass.config.config_dir = os.getcwd()
|
||||
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
await coordinator.async_refresh()
|
||||
forecast = json.loads(load_fixture("high_low_temp.json"))
|
||||
coordinator._api._api_data = forecast
|
||||
|
||||
coordinator.data = await coordinator.process_api_data()
|
||||
|
||||
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||
result: List[Forecast] = await weather.async_forecast_daily()
|
||||
|
@ -76,18 +64,13 @@ 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:
|
||||
hass.states.async_set(
|
||||
"zone.home",
|
||||
0,
|
||||
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||
)
|
||||
hass.config.config_dir = os.getcwd()
|
||||
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
|
||||
await coordinator.async_refresh()
|
||||
forecast = json.loads(load_fixture("forecast.json"))
|
||||
coordinator._api._api_data = forecast
|
||||
|
||||
coordinator.data = await coordinator.process_api_data()
|
||||
|
||||
weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated)
|
||||
|
||||
|
@ -105,11 +88,10 @@ async def test_radar_forecast_service(
|
|||
hass.config.time_zone = 'Europe/Brussels'
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
|
||||
api_data = get_api_data("forecast.json")
|
||||
data = IrmKmiCoordinator.radar_list_to_forecast(api_data.get('animation'))
|
||||
coordinator._api = get_api_with_data("forecast.json")
|
||||
|
||||
coordinator.data = ProcessedCoordinatorData(
|
||||
radar_forecast=data
|
||||
radar_forecast=coordinator._api.get_radar_forecast()
|
||||
)
|
||||
|
||||
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||
|
@ -118,27 +100,27 @@ async def test_radar_forecast_service(
|
|||
|
||||
expected = [
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0),
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||
IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False,
|
||||
rain_forecast_max=0, rain_forecast_min=0)
|
||||
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min')
|
||||
]
|
||||
|
||||
assert result_service == expected[5:]
|
||||
|
@ -146,3 +128,38 @@ 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)
|