Merge pull request #18 from jdejaegh/12-pollen-and-uv-index-data-as-attributes

Fetch pollen level data from API
This commit is contained in:
Jules 2024-04-03 14:22:53 +02:00 committed by GitHub
commit 28cebe63c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 643 additions and 35 deletions

View file

@ -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)
<img height="200" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/pollens.png" alt="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

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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'
}

View file

@ -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,

View file

@ -62,3 +62,4 @@ class ProcessedCoordinatorData(TypedDict, total=False):
daily_forecast: List[IrmKmiForecast] | None
animation: RadarAnimationData
warnings: List[WarningData]
pollen: dict

View file

@ -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

View file

@ -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)

View file

@ -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"
}
}
}
}
}

View file

@ -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"
}
}
}
}
}

View file

@ -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"
}
}
}
}
}

View file

@ -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

BIN
img/pollens.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -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."""

View file

@ -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": {

2
tests/fixtures/pollen.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

45
tests/fixtures/pollen_three.svg vendored Normal file
View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="35 88 129 63.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg">
<defs id="defs737"/>
<g id="layer1">
<rect id="rectangle-white" x="35" y="88" width="129" height="63.0" rx="5" fill="white" fill-opacity="0.15"/>
<g id="g1495" transform="translate(30.342966,94.25)">
<g id="layer1-2">
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
rx="1.6348698" ry="1.5258785"/>
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
height="3.5655735" x="12.923257" y="1.401318"/>
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
height="2.5535111" x="15.866023" y="2.36669"/>
</g>
</g>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"></tspan></text>
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
x="35.451504" y="105.5"/>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

2
tests/fixtures/pollen_two.svg vendored Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="35 88 129 63.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <defs id="defs737" /> <g id="layer1"> <rect id="rectangle-white" x="35" y="88" width="129" height="63.0" rx="5" fill="white" fill-opacity="0.15"/> <g id="g1495" transform="translate(30.342966,94.25)"> <g id="layer1-2"> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0" rx="1.6348698" ry="1.5258785" /> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567" cy="1.463598" rx="1.1366237" ry="1.1366239" /> <rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966" height="3.5655735" x="12.923257" y="1.401318" /> <rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834" height="2.5535111" x="15.866023" y="2.36669" /> </g> </g> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"></tspan></text> <rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145" x="35.451504" y="105.5" /><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text><rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145" x="35.451504" y="141.0" /> <rect style="fill:#70ad47;fill-opacity:1;stroke-width:0.2187" id="rectgreen" width="15.0" height="4.0" x="80.0" y="144.0" /> <rect style="fill:#ffd966;fill-opacity:1;stroke-width:0.2187" id="rectyellow" width="15.0" height="4.0" x="95.0" y="144.0" /> <rect style="fill:#ed7d31;fill-opacity:1;stroke-width:0.2187" id="rectorange" width="15.0" height="4.0" x="110.0" y="144.0" /> <rect style="fill:#c00000;fill-opacity:1;stroke-width:0.2187" id="rectred" width="15.0" height="4.0" x="125.0" y="144.0" /> <rect style="fill:#7030a0;fill-opacity:1;stroke-width:0.2187" id="rectpurple" width="15.0" height="4.0" x="140.0" y="144.0" /> <ellipse style="fill:#70ad47;fill-opacity:1;stroke-width:0.264583" id="path1639" cx="80.0" cy="146.0" rx="2.0" ry="2.0" /> <ellipse style="fill:#7030a0;fill-opacity:1;stroke-width:0.264583" id="path1641" cx="155.0" cy="146.0" rx="2.0" ry="2.0" /> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.351849" y="148.0" id="text396"><tspan id="tspan394" style="stroke-width:0.264583" x="42.351849" y="148.0">Birch</tspan></text> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path1644" cx="147.5" cy="146.0" rx="3.0" ry="3.0" /></g> </svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

63
tests/test_pollen.py Normal file
View file

@ -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