Initial support for pollen data

This commit is contained in:
Jules 2024-04-01 18:08:16 +02:00
parent 18737439db
commit c8b2954ef3
Signed by: jdejaegh
GPG key ID: 99D6D184CA66933A
8 changed files with 118 additions and 39 deletions

View file

@ -54,6 +54,11 @@ class IrmKmiApiClient:
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params) r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
return await r.read() 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( async def _api_wrapper(
self, self,
params: dict, params: dict,

View file

@ -14,7 +14,7 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN: Final = 'irm_kmi' 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 CONFIG_FLOW_VERSION = 3
OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)", OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",

View file

@ -23,6 +23,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 .const import OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP
from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast, from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast,
ProcessedCoordinatorData, RadarAnimationData, WarningData) ProcessedCoordinatorData, RadarAnimationData, WarningData)
from .pollen import PollenParser
from .rain_graph import RainGraph from .rain_graph import RainGraph
from .utils import disable_from_config, get_config_value from .utils import disable_from_config, get_config_value
@ -65,7 +66,8 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
'long': zone.attributes[ATTR_LONGITUDE]} 'long': zone.attributes[ATTR_LONGITUDE]}
) )
_LOGGER.debug(f"Observation for {api_data.get('cityName', '')}: {api_data.get('obs', '{}')}") _LOGGER.debug(f"Observation for {api_data.get('cityName', '')}: {api_data.get('obs', '{}')}")
_LOGGER.debug(f"Full data: {api_data}") # TODO re-enable logging here
# _LOGGER.debug(f"Full data: {api_data}")
except IrmKmiApiError as err: except IrmKmiApiError as err:
raise UpdateFailed(f"Error communicating with API: {err}") raise UpdateFailed(f"Error communicating with API: {err}")
@ -124,6 +126,28 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
radar_animation['svg_still'] = rain_graph.get_svg_string(still_image=True) radar_animation['svg_still'] = rain_graph.get_svg_string(still_image=True)
return radar_animation 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: async def process_api_data(self, api_data: dict) -> ProcessedCoordinatorData:
"""From the API data, create the object that will be used in the entities""" """From the API data, create the object that will be used in the entities"""
return ProcessedCoordinatorData( return ProcessedCoordinatorData(
@ -131,7 +155,8 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
daily_forecast=self.daily_list_to_forecast(api_data.get('for', {}).get('daily')), 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')), hourly_forecast=IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
animation=await self._async_animation_data(api_data=api_data), 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, async def download_images_from_api(self,

View file

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

View file

@ -31,23 +31,31 @@ class PollenParser:
return True return True
@staticmethod @staticmethod
def extract_elements(root) -> List[ET.Element]: def get_default_data() -> dict:
return {k.lower(): 'none' for k in POLLEN_NAMES}
@staticmethod
def get_option_values() -> List[str]:
return ['active', 'green', 'yellow', 'orange', 'red', 'purple', 'none']
@staticmethod
def _extract_elements(root) -> List[ET.Element]:
elements = [] elements = []
for child in root: for child in root:
elements.append(child) elements.append(child)
elements.extend(PollenParser.extract_elements(child)) elements.extend(PollenParser._extract_elements(child))
return elements return elements
@staticmethod @staticmethod
def dot_to_color_value(dot: ET.Element) -> str | None: def _dot_to_color_value(dot: ET.Element) -> str:
try: try:
cx = float(dot.attrib.get('cx')) cx = float(dot.attrib.get('cx'))
except ValueError: except ValueError:
return None return 'none'
if cx > 155: if cx > 155:
return None return 'none'
elif cx > 140: elif cx > 140:
return 'purple' return 'purple'
elif cx > 125: elif cx > 125:
@ -59,20 +67,21 @@ class PollenParser:
elif cx > 80: elif cx > 80:
return 'green' return 'green'
else: else:
return None return 'none'
def get_pollen_data(self): def get_pollen_data(self) -> dict:
pollen_data = self.get_default_data()
try: try:
root = ET.fromstring("self._xml") root = ET.fromstring(self._xml)
except ET.ParseError: except ET.ParseError:
# TODO Handle with default case _LOGGER.warning("Could not parse SVG pollen XML")
return None return pollen_data
elements: List[ET.Element] = self.extract_elements(root) elements: List[ET.Element] = self._extract_elements(root)
if not self._validate_svg(elements): if not self._validate_svg(elements):
# TODO return default value _LOGGER.warning("Could not validate SVG pollen data")
return None return pollen_data
pollens = [e for e in elements if 'tspan' in e.tag and e.text in POLLEN_NAMES] 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'] active = [e for e in elements if 'tspan' in e.tag and e.text == 'active']
@ -80,23 +89,19 @@ class PollenParser:
and 'fill:#ffffff' in e.attrib.get('style', '') and 'fill:#ffffff' in e.attrib.get('style', '')
and 3 == float(e.attrib.get('rx', '0'))] and 3 == float(e.attrib.get('rx', '0'))]
pollen_data = {k: None for k in POLLEN_NAMES}
for pollen in pollens: for pollen in pollens:
try: try:
y = float(pollen.attrib.get('y')) y = float(pollen.attrib.get('y'))
if y in [float(e.attrib.get('y')) for e in active]: if y in [float(e.attrib.get('y')) for e in active]:
pollen_data[pollen.text] = 'active' pollen_data[pollen.text.lower()] = 'active'
else: else:
dot = [d for d in dots if y - 3 <= float(d.attrib.get('cy', '0')) <= y + 3] dot = [d for d in dots if y - 3 <= float(d.attrib.get('cy', '0')) <= y + 3]
if len(dot) == 1: if len(dot) == 1:
dot = dot[0] dot = dot[0]
pollen_data[pollen.text] = self.dot_to_color_value(dot) pollen_data[pollen.text.lower()] = self._dot_to_color_value(dot)
except ValueError | NameError: except ValueError | NameError:
pass _LOGGER.warning("Skipped some data in the pollen SVG")
_LOGGER.debug(f"Pollen data: {pollen_data}")
return pollen_data
print(pollen_data)
for e in pollens + active:
print(e.text)
for d in dots:
print(d.attrib)

View file

@ -0,0 +1,56 @@
"""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.device_registry import DeviceEntryType, DeviceInfo
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
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_name = f"Pollen {pollen}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="IRM KMI",
name=f"Pollen {pollen}"
)
self._pollen = pollen
# TODO add translation for name
# self._attr_translation_key = f"pollen_{pollen}"
# _LOGGER.debug(f"translation key: {self._attr_translation_key}")
@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

@ -1325,18 +1325,6 @@
] ]
}, },
"module": [ "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", "type": "uv",
"data": { "data": {

View file

@ -1,6 +1,5 @@
from custom_components.irm_kmi.pollen import PollenParser from custom_components.irm_kmi.pollen import PollenParser
def test_svg_pollen_parsing(): def test_svg_pollen_parsing():
# TODO make it an actual test # TODO make it an actual test
with open("tests/fixtures/pollen.svg", "r") as file: with open("tests/fixtures/pollen.svg", "r") as file: