diff --git a/README.md b/README.md index 59e81e7..71bd54c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This integration provides the following things: - Weather forecasts (hourly, daily and twice-daily) [using the service `weather.get_forecasts`](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts) - A camera entity for rain radar and short-term rain previsions - A binary sensor for weather warnings +- Sensors for active pollens The following options are available: @@ -108,6 +109,18 @@ The following table summarizes the different known warning types. Other warning The sensor has an attribute called `active_warnings_friendly_names`, holding a comma separated list of the friendly names of the currently active warnings (e.g. `Fog, Ice or snow`). There is no particular order for the list. +## Pollen details + +One sensor per pollen is created and each sensor can have on of the following values: active, green, yellow, orange, +red, purple or none. + +The exact meaning of each color can be found on the IRM KMI webpage: [Pollen allergy and hay fever](https://www.meteo.be/en/weather/forecasts/pollen-allergy-and-hay-fever) + +Pollen data + +This data sent to the app would result in oak and ash have the 'active' state, birch would be 'purple' and alder would be 'green'. +All the other pollens would be 'none'. + ## Disclaimer This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [The Royal Meteorological diff --git a/custom_components/irm_kmi/api.py b/custom_components/irm_kmi/api.py index 09d7648..2f0a326 100644 --- a/custom_components/irm_kmi/api.py +++ b/custom_components/irm_kmi/api.py @@ -54,6 +54,11 @@ class IrmKmiApiClient: 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, diff --git a/custom_components/irm_kmi/binary_sensor.py b/custom_components/irm_kmi/binary_sensor.py index 567309d..e911b13 100644 --- a/custom_components/irm_kmi/binary_sensor.py +++ b/custom_components/irm_kmi/binary_sensor.py @@ -8,7 +8,6 @@ from homeassistant.components.binary_sensor import (BinarySensorDeviceClass, BinarySensorEntity) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,12 +35,7 @@ class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity): self._attr_unique_id = entry.entry_id self.entity_id = binary_sensor.ENTITY_ID_FORMAT.format(f"weather_warning_{str(entry.title).lower()}") self._attr_name = f"Warning {entry.title}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="IRM KMI", - name=f"Warning {entry.title}" - ) + self._attr_device_info = coordinator.shared_device_info @property def is_on(self) -> bool | None: diff --git a/custom_components/irm_kmi/camera.py b/custom_components/irm_kmi/camera.py index df5ca56..6f5d2fd 100644 --- a/custom_components/irm_kmi/camera.py +++ b/custom_components/irm_kmi/camera.py @@ -6,7 +6,6 @@ from aiohttp import web from homeassistant.components.camera import Camera, async_get_still_stream from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,12 +35,7 @@ class IrmKmiRadar(CoordinatorEntity, Camera): self.content_type = 'image/svg+xml' self._name = f"Radar {entry.title}" self._attr_unique_id = entry.entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="IRM KMI", - name=f"Radar {entry.title}" - ) + self._attr_device_info = coordinator.shared_device_info self._image_index = False diff --git a/custom_components/irm_kmi/const.py b/custom_components/irm_kmi/const.py index ead55f2..31c2bdc 100644 --- a/custom_components/irm_kmi/const.py +++ b/custom_components/irm_kmi/const.py @@ -14,7 +14,7 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT, from homeassistant.const import Platform DOMAIN: Final = 'irm_kmi' -PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR] +PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_FLOW_VERSION = 3 OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)", @@ -133,3 +133,10 @@ MAP_WARNING_ID_TO_SLUG: Final = { 14: 'thunderstorm_large_rainfall', 15: 'storm_surge', 17: 'coldspell'} + +POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', 'Oak'} + +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' +} diff --git a/custom_components/irm_kmi/coordinator.py b/custom_components/irm_kmi/coordinator.py index ca9150a..33a21e0 100644 --- a/custom_components/irm_kmi/coordinator.py +++ b/custom_components/irm_kmi/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, UpdateFailed) @@ -23,6 +24,7 @@ from .const import MAP_WARNING_ID_TO_SLUG as SLUG_MAP from .const import OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast, ProcessedCoordinatorData, RadarAnimationData, WarningData) +from .pollen import PollenParser from .rain_graph import RainGraph from .utils import disable_from_config, get_config_value @@ -47,6 +49,12 @@ class IrmKmiCoordinator(DataUpdateCoordinator): self._dark_mode = get_config_value(entry, CONF_DARK_MODE) self._style = get_config_value(entry, CONF_STYLE) self._config_entry = entry + self.shared_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="IRM KMI", + name=f"{entry.title}" + ) async def _async_update_data(self) -> ProcessedCoordinatorData: """Fetch data from API endpoint. @@ -124,6 +132,28 @@ class IrmKmiCoordinator(DataUpdateCoordinator): 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: + _LOGGER.debug("Getting pollen data from API") + svg_url = None + for module in api_data.get('module', []): + _LOGGER.debug(f"module: {module}") + if module.get('type', None) == 'svg': + url = module.get('data', {}).get('url', {}).get('en', '') + if 'pollen' in url: + svg_url = url + break + if svg_url is None: + return PollenParser.get_default_data() + + try: + _LOGGER.debug(f"Requesting pollen SVG at url {svg_url}") + pollen_svg: str = await self._api_client.get_svg(svg_url) + except IrmKmiApiError: + _LOGGER.warning(f"Could not get pollen data from the API") + return PollenParser.get_default_data() + + return PollenParser(pollen_svg).get_pollen_data() + async def process_api_data(self, api_data: dict) -> ProcessedCoordinatorData: """From the API data, create the object that will be used in the entities""" return ProcessedCoordinatorData( @@ -131,7 +161,8 @@ class IrmKmiCoordinator(DataUpdateCoordinator): daily_forecast=self.daily_list_to_forecast(api_data.get('for', {}).get('daily')), hourly_forecast=IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')), animation=await self._async_animation_data(api_data=api_data), - warnings=self.warnings_from_data(api_data.get('for', {}).get('warning')) + warnings=self.warnings_from_data(api_data.get('for', {}).get('warning')), + pollen=await self._async_pollen_data(api_data=api_data) ) async def download_images_from_api(self, diff --git a/custom_components/irm_kmi/data.py b/custom_components/irm_kmi/data.py index 02b3989..2f12224 100644 --- a/custom_components/irm_kmi/data.py +++ b/custom_components/irm_kmi/data.py @@ -62,3 +62,4 @@ class ProcessedCoordinatorData(TypedDict, total=False): daily_forecast: List[IrmKmiForecast] | None animation: RadarAnimationData warnings: List[WarningData] + pollen: dict diff --git a/custom_components/irm_kmi/pollen.py b/custom_components/irm_kmi/pollen.py new file mode 100644 index 0000000..09b880b --- /dev/null +++ b/custom_components/irm_kmi/pollen.py @@ -0,0 +1,131 @@ +"""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_NAMES + +_LOGGER = logging.getLogger(__name__) + + +class PollenParser: + """ + The SVG looks as follows (see test fixture for a real example) + + Active pollens + --------------------------------- + Oak active + Ash active + --------------------------------- + Birch ---|---|---|---|-*- + Alder -*-|---|---|---|--- + + This classe parses the oak and ash as active, birch as purple and alder as green in the example. + For active pollen, check if an active text is present on the same line as the pollen name + For the color scale, look for a white dot (nearly) at the same level as the pollen name. From the white dot + horizontal position, determine the level + """ + def __init__( + self, + xml_string: str + ): + self._xml = xml_string + + @staticmethod + def _validate_svg(elements: List[ET.Element]) -> bool: + """Make sure that the colors of the scale are still where we expect them""" + x_values = {"rectgreen": 80, + "rectyellow": 95, + "rectorange": 110, + "rectred": 125, + "rectpurple": 140} + for e in elements: + if e.attrib.get('id', '') in x_values.keys(): + try: + if float(e.attrib.get('x', '0')) != x_values.get(e.attrib.get('id')): + return False + except ValueError: + return False + return True + + @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_option_values() -> List[str]: + """List all the values that the pollen can have""" + return ['active', 'green', 'yellow', 'orange', 'red', 'purple', '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 _dot_to_color_value(dot: ET.Element) -> str: + """Map the dot horizontal position to a color or 'none'""" + try: + cx = float(dot.attrib.get('cx')) + except ValueError: + return 'none' + + if cx > 155: + return 'none' + elif cx > 140: + return 'purple' + elif cx > 125: + return 'red' + elif cx > 110: + return 'orange' + elif cx > 95: + return 'yellow' + elif cx > 80: + return 'green' + else: + 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: + _LOGGER.warning("Could not parse SVG pollen XML") + return pollen_data + + elements: List[ET.Element] = self._extract_elements(root) + + if not self._validate_svg(elements): + _LOGGER.warning("Could not validate SVG pollen data") + return pollen_data + + pollens = [e for e in elements if 'tspan' in e.tag and e.text in POLLEN_NAMES] + active = [e for e in elements if 'tspan' in e.tag and e.text == 'active'] + dots = [e for e in elements if 'ellipse' in e.tag + and 'fill:#ffffff' in e.attrib.get('style', '') + and 3 == float(e.attrib.get('rx', '0'))] + + for pollen in pollens: + try: + y = float(pollen.attrib.get('y')) + if y in [float(e.attrib.get('y')) for e in active]: + pollen_data[pollen.text.lower()] = 'active' + else: + dot = [d for d in dots if y - 3 <= float(d.attrib.get('cy', '0')) <= y + 3] + if len(dot) == 1: + dot = dot[0] + pollen_data[pollen.text.lower()] = self._dot_to_color_value(dot) + except ValueError | NameError: + _LOGGER.warning("Skipped some data in the pollen SVG") + + _LOGGER.debug(f"Pollen data: {pollen_data}") + return pollen_data + diff --git a/custom_components/irm_kmi/sensor.py b/custom_components/irm_kmi/sensor.py new file mode 100644 index 0000000..fd1e3fd --- /dev/null +++ b/custom_components/irm_kmi/sensor.py @@ -0,0 +1,47 @@ +"""Sensor for pollen from the IRM KMI""" +import logging + +from homeassistant.components import sensor +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +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.pollen import PollenParser + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + """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]) + + +class IrmKmiPollen(CoordinatorEntity, SensorEntity): + """Representation of a pollen sensor""" + _attr_has_entity_name = True + _attr_device_class = SensorDeviceClass.ENUM + + def __init__(self, + coordinator: IrmKmiCoordinator, + entry: ConfigEntry, + pollen: str + ) -> None: + super().__init__(coordinator) + SensorEntity.__init__(self) + self._attr_unique_id = f"{entry.entry_id}-pollen-{pollen}" + self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_{pollen}_level") + self._attr_options = PollenParser.get_option_values() + self._attr_device_info = coordinator.shared_device_info + self._pollen = pollen + self._attr_translation_key = f"pollen_{pollen}" + self._attr_icon = POLLEN_TO_ICON_MAP[pollen] + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self.coordinator.data.get('pollen', {}).get(self._pollen, None) diff --git a/custom_components/irm_kmi/translations/en.json b/custom_components/irm_kmi/translations/en.json index 7fd5c43..ebd4add 100644 --- a/custom_components/irm_kmi/translations/en.json +++ b/custom_components/irm_kmi/translations/en.json @@ -74,5 +74,93 @@ } } } + }, + "entity": { + "sensor": { + "pollen_alder": { + "name": "Alder pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_ash": { + "name": "Ash pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_birch": { + "name": "Birch pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_grasses": { + "name": "Grass pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_hazel": { + "name": "Hazel pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_mugwort": { + "name": "Mugwort pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + }, + "pollen_oak": { + "name": "Oak pollen", + "state": { + "active": "Active", + "green": "Green", + "yellow": "Yellow", + "orange": "Orange", + "red": "Red", + "purple": "Purple", + "none": "None" + } + } + } } } diff --git a/custom_components/irm_kmi/translations/fr.json b/custom_components/irm_kmi/translations/fr.json index 39fd18c..547ddb2 100644 --- a/custom_components/irm_kmi/translations/fr.json +++ b/custom_components/irm_kmi/translations/fr.json @@ -74,5 +74,93 @@ } } } + }, + "entity": { + "sensor": { + "pollen_alder": { + "name": "Pollen d'aulne", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_ash": { + "name": "Pollen de frêne", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_birch": { + "name": "Pollen de bouleau", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_grasses": { + "name": "Pollen de graminées", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_hazel": { + "name": "Pollen de noisetier", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_mugwort": { + "name": "Pollen d'armoise", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + }, + "pollen_oak": { + "name": "Pollen de chêne", + "state": { + "active": "Actif", + "green": "Vert", + "yellow": "Jaune", + "orange": "Orange", + "red": "Rouge", + "purple": "Violet", + "none": "Aucun" + } + } + } } } diff --git a/custom_components/irm_kmi/translations/nl.json b/custom_components/irm_kmi/translations/nl.json index e520411..620e7a8 100644 --- a/custom_components/irm_kmi/translations/nl.json +++ b/custom_components/irm_kmi/translations/nl.json @@ -74,5 +74,93 @@ } } } + }, + "entity": { + "sensor": { + "pollen_alder": { + "name": "Elzenpollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_ash": { + "name": "Essen pollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_birch": { + "name": "Berken pollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_grasses": { + "name": "Graspollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_hazel": { + "name": "Hazelaar pollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_mugwort": { + "name": "Alsem pollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + }, + "pollen_oak": { + "name": "Eiken pollen", + "state": { + "active": "Actief", + "green": "Groen", + "yellow": "Geel", + "orange": "Oranje", + "red": "Rood", + "purple": "Paars", + "none": "Geen" + } + } + } } } diff --git a/custom_components/irm_kmi/weather.py b/custom_components/irm_kmi/weather.py index 0adcc46..02172ff 100644 --- a/custom_components/irm_kmi/weather.py +++ b/custom_components/irm_kmi/weather.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -39,12 +38,8 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity): WeatherEntity.__init__(self) self._name = entry.title self._attr_unique_id = entry.entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="IRM KMI", - name=entry.title - ) + self._attr_device_info = coordinator.shared_device_info + self._deprecated_forecast_as = get_config_value(entry, CONF_USE_DEPRECATED_FORECAST) @property diff --git a/img/pollens.png b/img/pollens.png new file mode 100644 index 0000000..dca48af Binary files /dev/null and b/img/pollens.png differ diff --git a/img/sensors.png b/img/sensors.png index df99686..b26a9ad 100644 Binary files a/img/sensors.png and b/img/sensors.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 8230708..98b27a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -196,6 +196,32 @@ def mock_image_and_high_temp_irm_kmi_api(request: pytest.FixtureRequest) -> Gene 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.""" diff --git a/tests/fixtures/high_low_temp.json b/tests/fixtures/high_low_temp.json index 596d675..d865dae 100644 --- a/tests/fixtures/high_low_temp.json +++ b/tests/fixtures/high_low_temp.json @@ -1325,18 +1325,6 @@ ] }, "module": [ - { - "type": "svg", - "data": { - "url": { - "nl": "https:\/\/app.meteo.be\/services\/appv4\/?s=getSvg&ins=92094&e=pollen&l=nl&k=32003c7eac2900f3d73c50f9e27330ab", - "fr": "https:\/\/app.meteo.be\/services\/appv4\/?s=getSvg&ins=92094&e=pollen&l=fr&k=32003c7eac2900f3d73c50f9e27330ab", - "en": "https:\/\/app.meteo.be\/services\/appv4\/?s=getSvg&ins=92094&e=pollen&l=en&k=32003c7eac2900f3d73c50f9e27330ab", - "de": "https:\/\/app.meteo.be\/services\/appv4\/?s=getSvg&ins=92094&e=pollen&l=de&k=32003c7eac2900f3d73c50f9e27330ab" - }, - "ratio": 3.0458333333333334 - } - }, { "type": "uv", "data": { diff --git a/tests/fixtures/pollen.svg b/tests/fixtures/pollen.svg new file mode 100644 index 0000000..0c23b2a --- /dev/null +++ b/tests/fixtures/pollen.svg @@ -0,0 +1,2 @@ + + Active pollen Oak activeAsh active Birch Alder \ No newline at end of file diff --git a/tests/fixtures/pollen_three.svg b/tests/fixtures/pollen_three.svg new file mode 100644 index 0000000..04db508 --- /dev/null +++ b/tests/fixtures/pollen_three.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + Active pollen + + + Alder + active + Ash + active + Oak + active + + \ No newline at end of file diff --git a/tests/fixtures/pollen_two.svg b/tests/fixtures/pollen_two.svg new file mode 100644 index 0000000..4bfe1b6 --- /dev/null +++ b/tests/fixtures/pollen_two.svg @@ -0,0 +1,2 @@ + + Active pollen Alder activeAsh activeOak active Birch \ No newline at end of file diff --git a/tests/test_pollen.py b/tests/test_pollen.py new file mode 100644 index 0000000..667ca1c --- /dev/null +++ b/tests/test_pollen.py @@ -0,0 +1,63 @@ +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant +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': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'green', + 'grasses': 'none', 'ash': 'active'} + + with open("tests/fixtures/pollen_two.svg", "r") as file: + svg_data = file.read() + data = PollenParser(svg_data).get_pollen_data() + assert data == {'birch': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active', + 'grasses': 'none', 'ash': 'active'} + + with open("tests/fixtures/pollen_three.svg", "r") as file: + svg_data = file.read() + data = PollenParser(svg_data).get_pollen_data() + assert data == {'birch': 'none', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active', + 'grasses': 'none', 'ash': 'active'} + +def test_pollen_options(): + assert PollenParser.get_option_values() == ['active', 'green', 'yellow', 'orange', 'red', 'purple', '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': 'purple', 'alder': 'green', 'ash': 'active', 'oak': 'active', + 'grasses': 'none', 'hazel': 'none'} + assert result == expected + + +async def test_pollen_error_leads_to_default_values( + 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") + + result = await coordinator._async_pollen_data(api_data) + expected = PollenParser.get_default_data() + assert result == expected