mirror of
https://github.com/jdejaegh/irm-kmi-ha.git
synced 2025-06-27 03:35:56 +02:00
Compare commits
47 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 |
44 changed files with 623 additions and 1978 deletions
3
.github/workflows/pytest.yml
vendored
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
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
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()
|
||||
|
|
|
@ -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]}
|
||||
)
|
||||
|
|
|
@ -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__)
|
||||
|
||||
|
@ -49,7 +40,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
|||
# 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)
|
||||
|
@ -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,7 +81,7 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
|||
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
|
||||
f"Last success time is: {self.last_update_success_time}")
|
||||
|
||||
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
||||
if self._api.get_city() in OUT_OF_BENELUX:
|
||||
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
|
||||
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
|
||||
f"this")
|
||||
|
@ -106,427 +99,51 @@ class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
|||
)
|
||||
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()
|
||||
|
||||
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:]
|
||||
|
||||
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)
|
||||
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)
|
||||
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.27"
|
||||
"version": "0.3.2"
|
||||
}
|
|
@ -1,109 +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 or the dot on the color scale.
|
||||
Then, map the value to the corresponding color on the scale.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
xml_string: str
|
||||
):
|
||||
self._xml = xml_string
|
||||
|
||||
@staticmethod
|
||||
def get_default_data() -> dict:
|
||||
"""Return all the known pollen with 'none' value"""
|
||||
return {k.lower(): 'none' for k in POLLEN_NAMES}
|
||||
|
||||
@staticmethod
|
||||
def get_unavailable_data() -> dict:
|
||||
"""Return all the known pollen with 'none' value"""
|
||||
return {k.lower(): None for k in POLLEN_NAMES}
|
||||
|
||||
@staticmethod
|
||||
def get_option_values() -> List[str]:
|
||||
"""List all the values that the pollen can have"""
|
||||
return list(POLLEN_LEVEL_TO_COLOR.values()) + ['none']
|
||||
|
||||
@staticmethod
|
||||
def _extract_elements(root) -> List[ET.Element]:
|
||||
"""Recursively collect all elements of the SVG in a list"""
|
||||
elements = []
|
||||
for child in root:
|
||||
elements.append(child)
|
||||
elements.extend(PollenParser._extract_elements(child))
|
||||
return elements
|
||||
|
||||
@staticmethod
|
||||
def _get_elem_text(e) -> str | None:
|
||||
if e.text is not None:
|
||||
return e.text.strip()
|
||||
return None
|
||||
|
||||
def get_pollen_data(self) -> dict:
|
||||
"""From the XML string, parse the SVG and extract the pollen data from the image.
|
||||
If an error occurs, return the default value"""
|
||||
pollen_data = self.get_default_data()
|
||||
try:
|
||||
_LOGGER.debug(f"Full SVG: {self._xml}")
|
||||
root = ET.fromstring(self._xml)
|
||||
except ET.ParseError as e:
|
||||
_LOGGER.warning(f"Could not parse SVG pollen XML: {e}")
|
||||
return pollen_data
|
||||
|
||||
elements: List[ET.Element] = self._extract_elements(root)
|
||||
|
||||
pollens = {e.attrib.get('x', None): self._get_elem_text(e).lower()
|
||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_NAMES}
|
||||
|
||||
pollen_levels = {e.attrib.get('x', None): POLLEN_LEVEL_TO_COLOR[self._get_elem_text(e)]
|
||||
for e in elements if 'tspan' in e.tag and self._get_elem_text(e) in POLLEN_LEVEL_TO_COLOR}
|
||||
|
||||
level_dots = {e.attrib.get('cx', None) for e in elements if 'circle' in e.tag}
|
||||
print(level_dots)
|
||||
|
||||
# For each pollen name found, check the text just below.
|
||||
# As of January 2025, the text is always 'active' and the dot shows the real level
|
||||
# If text says 'active', check the dot; else trust the text
|
||||
for position, pollen in pollens.items():
|
||||
# Determine pollen level based on text
|
||||
if position is not None and position in pollen_levels:
|
||||
pollen_data[pollen] = pollen_levels[position]
|
||||
print(f"{pollen} is {pollen_data[pollen]} according to text")
|
||||
|
||||
# If text is 'active' or if there is no text, check the dot as a fallback
|
||||
if pollen_data[pollen] not in {'none', 'active'}:
|
||||
_LOGGER.debug(f"{pollen} trusting text")
|
||||
else:
|
||||
for dot in level_dots:
|
||||
try:
|
||||
relative_x_position = float(position) - float(dot)
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
if 24 <= relative_x_position <= 34:
|
||||
pollen_data[pollen] = 'green'
|
||||
elif 13 <= relative_x_position <= 23:
|
||||
pollen_data[pollen] = 'yellow'
|
||||
elif -5 <= relative_x_position <= 5:
|
||||
pollen_data[pollen] = 'orange'
|
||||
elif -23 <= relative_x_position <= -13:
|
||||
pollen_data[pollen] = 'red'
|
||||
elif -34 <= relative_x_position <= -24:
|
||||
pollen_data[pollen] = 'purple'
|
||||
|
||||
_LOGGER.debug(f"{pollen} is {pollen_data[pollen]} according to dot")
|
||||
|
||||
_LOGGER.debug(f"Pollen data: {pollen_data}")
|
||||
return pollen_data
|
|
@ -1,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."""
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 68 KiB |
Binary file not shown.
Before Width: | Height: | Size: 666 KiB |
Binary file not shown.
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
Before Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
@ -9,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.
|
||||
|
|
18
pyproject.toml
Normal file
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.11.11
|
||||
async-timeout==4.0.3
|
||||
homeassistant==2025.1.4
|
||||
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==2025.1.4
|
||||
pytest_homeassistant_custom_component==0.13.205
|
||||
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
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
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
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
|
||||
|
|
|
@ -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
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,65 +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_svg_two_pollen_parsing_2025_update():
|
||||
with open("tests/fixtures/pollens-2025.svg", "r") as file:
|
||||
svg_data = file.read()
|
||||
data = PollenParser(svg_data).get_pollen_data()
|
||||
assert data == {'birch': 'none', 'oak': 'none', 'hazel': 'active', 'mugwort': 'none', 'alder': 'green',
|
||||
'grasses': 'none', 'ash': 'none'}
|
||||
|
||||
def test_pollen_options():
|
||||
assert set(PollenParser.get_option_values()) == {'green', 'yellow', 'orange', 'red', 'purple', 'active', 'none'}
|
||||
|
||||
|
||||
def test_pollen_default_values():
|
||||
assert PollenParser.get_default_data() == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'none',
|
||||
'alder': 'none', 'grasses': 'none', 'ash': 'none'}
|
||||
|
||||
|
||||
async def test_pollen_data_from_api(
|
||||
hass: HomeAssistant,
|
||||
mock_svg_pollen: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||
api_data = get_api_data("be_forecast_warning.json")
|
||||
|
||||
result = await coordinator._async_pollen_data(api_data)
|
||||
expected = {'mugwort': 'none', 'birch': 'none', 'alder': 'none', 'ash': 'none', 'oak': 'none',
|
||||
'grasses': 'purple', 'hazel': 'none'}
|
||||
assert result == expected
|
||||
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,15 +70,19 @@ 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
|
||||
|
||||
|
@ -94,12 +102,16 @@ 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
|
||||
|
||||
|
@ -155,13 +167,16 @@ 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')
|
||||
|
@ -181,13 +196,16 @@ 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')
|
||||
|
|
|
@ -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)
|
Loading…
Add table
Reference in a new issue