Merge pull request #4 from jdejaegh/warnings

Add binary sensor for weather warnings
This commit is contained in:
Jules 2024-01-13 23:08:54 +01:00 committed by GitHub
commit b6e445f9fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1893 additions and 19 deletions

View file

@ -5,8 +5,6 @@ The data is collected via their non-public mobile application API.
Although the provider is Belgian, the data is available for Belgium 🇧🇪, Luxembourg 🇱🇺, and The Netherlands 🇳🇱 Although the provider is Belgian, the data is available for Belgium 🇧🇪, Luxembourg 🇱🇺, and The Netherlands 🇳🇱
**Note: this is still under development, new versions may not be backward compatible.**
## Installing via HACS ## Installing via HACS
1. Go to HACS > Integrations 1. Go to HACS > Integrations
@ -23,6 +21,7 @@ This integration provides the following things:
- A weather entity with current weather conditions - A weather entity with current weather conditions
- Weather forecasts (hourly, daily and twice-daily) [using the service `weather.get_forecasts`](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts) - 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 camera entity for rain radar and short-term rain previsions
- A binary sensor for weather warnings
The following options are available: The following options are available:
@ -33,6 +32,8 @@ The following options are available:
<details> <details>
<summary>Show screenshots</summary> <summary>Show screenshots</summary>
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/sensors.png"/> <br>
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/forecast.png"/> <br>
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_light.png"/> <br> <img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_light.png"/> <br>
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_dark.png"/> <br> <img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_dark.png"/> <br>
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_sat.png"/> <img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_sat.png"/>
@ -45,8 +46,7 @@ weather condition is taken into account in this integration.
<br><img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/monday.png" height="150" alt="Example of two weather conditions"> <br><img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/monday.png" height="150" alt="Example of two weather conditions">
2. The trends for 14 days are not shown 2. The trends for 14 days are not shown
3. The warnings shown in the app are not shown by the integration 3. The provider only has data for Belgium, Luxembourg and The Netherlands
4. The provider only has data for Belgium, Luxembourg and The Netherlands
## Mapping between IRM KMI and Home Assistant weather conditions ## Mapping between IRM KMI and Home Assistant weather conditions
@ -71,6 +71,39 @@ Mapping was established based on my own interpretation of the icons and conditio
| windy-variant | Wind and clouds | | | | windy-variant | Wind and clouds | | |
## Warning details
The warning binary sensor is on if a warning is currently relevant (i.e. warning start time < current time < warning end time).
Warnings may be issued by the IRM KMI ahead of time but the binary sensor is only on when at least one of the issued warnings is relevant.
The binary sensor has an additional attribute called `warnings`, with a list of warnings for the current location.
Warnings in the list may be warning issued ahead of time.
Each element in the list has the following attributes:
* `slug: str`: warning slug type, can be used for automation and does not change with language setting. Example: `ice_or_snow`
* `id: int`: internal id for the warning type used by the IRM KMI api.
* `level: int`: warning severity, from 1 (lower risk) to 3 (higher risk)
* `friendly_name: str`: language specific name for the warning type. Examples: `Ice or snow`, `Chute de neige ou verglas`, `Sneeuw of ijzel`, `Glätte`
* `text: str`: language specific additional information about the warning
* `starts_at: datetime`: time at which the warning starts being relevant
* `ends_at: datetime`: time at which the warning stops being relevant
The following table summarizes the different known warning types. Other warning types may be returned and will have `unknown` as slug. Feel free to open an issue with the id and the English friendly name to have it added to this integration.
| Warning slug | Warning id | Friendly name (en, fr, nl, de) |
|-----------------------------|------------|------------------------------------------------------------------------------------------|
| wind | 0 | Wind, Vent, Wind, Wind |
| rain | 1 | Rain, Pluie, Regen, Regen |
| ice_or_snow | 2 | Ice or snow, Chute de neige ou verglas, Sneeuw of ijzel, Glätte |
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
| fog | 7 | Fog, Brouillard, Mist, Nebel |
| cold | 9 | Cold, Froid, Koude, Kalt |
| 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 |
| storm_surge | 15 | Storm surge, Marée forte, Stormtij, Sturmflut |
| coldspell | 17 | Coldspell, Vague de froid, Koude, Koude |
## Disclaimer ## Disclaimer
This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [The Royal Meteorological This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [The Royal Meteorological

View file

@ -0,0 +1,62 @@
"""Sensor to signal weather warning from the IRM KMI"""
import datetime
import logging
import pytz
from homeassistant.components import binary_sensor
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
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
"""Set up the binary platform"""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([IrmKmiWarning(coordinator, entry)])
class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
"""Representation of a weather warning binary sensor"""
def __init__(self,
coordinator: IrmKmiCoordinator,
entry: ConfigEntry
) -> None:
super().__init__(coordinator)
BinarySensorEntity.__init__(self)
self._attr_device_class = BinarySensorDeviceClass.SAFETY
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}"
)
@property
def is_on(self) -> bool | None:
if self.coordinator.data.get('warnings') is None:
return False
now = datetime.datetime.now(tz=pytz.timezone(self.hass.config.time_zone))
for item in self.coordinator.data.get('warnings'):
if item.get('starts_at') < now < item.get('ends_at'):
return True
return False
@property
def extra_state_attributes(self) -> dict:
"""Return the camera state attributes."""
attrs = {"warnings": self.coordinator.data.get('warnings', [])}
return attrs

View file

@ -15,7 +15,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] PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_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)",
@ -121,3 +121,16 @@ IRM_KMI_TO_HA_CONDITION_MAP: Final = {
(27, 'd'): ATTR_CONDITION_EXCEPTIONAL, (27, 'd'): ATTR_CONDITION_EXCEPTIONAL,
(27, 'n'): ATTR_CONDITION_EXCEPTIONAL (27, 'n'): ATTR_CONDITION_EXCEPTIONAL
} }
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'}

View file

@ -18,10 +18,11 @@ from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator,
from .api import IrmKmiApiClient, IrmKmiApiError from .api import IrmKmiApiClient, IrmKmiApiError
from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
from .const import (LANGS, OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, from .const import LANGS
STYLE_TO_PARAM_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
from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast, from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast,
ProcessedCoordinatorData, RadarAnimationData) ProcessedCoordinatorData, RadarAnimationData, WarningData)
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
@ -131,7 +132,8 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
current_weather=IrmKmiCoordinator.current_weather_from_data(api_data), current_weather=IrmKmiCoordinator.current_weather_from_data(api_data),
daily_forecast=IrmKmiCoordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily')), daily_forecast=IrmKmiCoordinator.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'))
) )
async def download_images_from_api(self, async def download_images_from_api(self,
@ -344,3 +346,37 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
return RainGraph(radar_animation, image_path, bg_size, return RainGraph(radar_animation, image_path, bg_size,
dark_mode=self._dark_mode, dark_mode=self._dark_mode,
tz=self.hass.config.time_zone) tz=self.hass.config.time_zone)
def warnings_from_data(self, warning_data: list | None) -> List[WarningData] | None:
"""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 None
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(self.hass.config.language),
text=data.get('text', {}).get(self.hass.config.language),
starts_at=start,
ends_at=end
)
)
return result if len(result) > 0 else None

View file

@ -9,6 +9,7 @@ class IrmKmiForecast(Forecast):
"""Forecast class with additional attributes for IRM KMI""" """Forecast class with additional attributes for IRM KMI"""
# TODO: add condition_2 as well and evolution to match data from the API? # TODO: add condition_2 as well and evolution to match data from the API?
# TODO: remove the _fr and _nl to have only one 'text' attribute
text_fr: str | None text_fr: str | None
text_nl: str | None text_nl: str | None
@ -45,9 +46,21 @@ class RadarAnimationData(TypedDict, total=False):
svg_animated: 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
class ProcessedCoordinatorData(TypedDict, total=False): class ProcessedCoordinatorData(TypedDict, total=False):
"""Data class that will be exposed to the entities consuming data from an IrmKmiCoordinator""" """Data class that will be exposed to the entities consuming data from an IrmKmiCoordinator"""
current_weather: CurrentWeatherData current_weather: CurrentWeatherData
hourly_forecast: List[Forecast] | None hourly_forecast: List[Forecast] | None
daily_forecast: List[IrmKmiForecast] | None daily_forecast: List[IrmKmiForecast] | None
animation: RadarAnimationData animation: RadarAnimationData
warnings: List[WarningData] | None

BIN
img/forecast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
img/sensors.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,6 +1,6 @@
homeassistant==2024.1.2 homeassistant==2024.1.3
pytest pytest
pytest_homeassistant_custom_component @ git+https://github.com/MatthewFlamm/pytest-homeassistant-custom-component pytest_homeassistant_custom_component==0.13.89
freezegun freezegun
Pillow==10.1.0 Pillow==10.1.0
isort isort

View file

@ -17,6 +17,10 @@ from custom_components.irm_kmi.const import (
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD) OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD)
def get_api_data(fixture: str) -> dict:
return json.loads(load_fixture(fixture))
async def patched(url: str, params: dict | None = None) -> bytes: async def patched(url: str, params: dict | None = None) -> bytes:
if "cdn.knmi.nl" in url: if "cdn.knmi.nl" in url:
file_name = "tests/fixtures/clouds_nl.png" file_name = "tests/fixtures/clouds_nl.png"

1668
tests/fixtures/be_forecast_warning.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
from datetime import datetime
from freezegun import freeze_time
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.binary_sensor import IrmKmiWarning
from tests.conftest import get_api_data
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01: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'))
coordinator.data = {'warnings': result}
warning = IrmKmiWarning(coordinator, mock_config_entry)
warning.hass = hass
assert warning.is_on
assert len(warning.extra_state_attributes['warnings']) == 2

View file

@ -35,7 +35,6 @@ async def test_full_user_flow(
CONF_STYLE: OPTION_STYLE_STD, CONF_STYLE: OPTION_STYLE_STD,
CONF_DARK_MODE: False}, CONF_DARK_MODE: False},
) )
print(result2)
assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2.get("type") == FlowResultType.CREATE_ENTRY
assert result2.get("title") == "test home" assert result2.get("title") == "test home"
assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME, assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME,

View file

@ -1,4 +1,3 @@
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from freezegun import freeze_time from freezegun import freeze_time
@ -6,15 +5,11 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_RAINY, Forecast) ATTR_CONDITION_RAINY, Forecast)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import (MockConfigEntry, from pytest_homeassistant_custom_component.common import MockConfigEntry
load_fixture)
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
from custom_components.irm_kmi.data import CurrentWeatherData, IrmKmiForecast from custom_components.irm_kmi.data import CurrentWeatherData, IrmKmiForecast
from tests.conftest import get_api_data
def get_api_data(fixture: str) -> dict:
return json.loads(load_fixture(fixture))
async def test_jules_forgot_to_revert_update_interval_before_pushing( async def test_jules_forgot_to_revert_update_interval_before_pushing(
@ -26,6 +21,30 @@ async def test_jules_forgot_to_revert_update_interval_before_pushing(
assert timedelta(minutes=5) <= coordinator.update_interval assert timedelta(minutes=5) <= coordinator.update_interval
@freeze_time(datetime.fromisoformat('2024-01-12T07:10: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-26T18:30:00')) @freeze_time(datetime.fromisoformat('2023-12-26T18:30:00'))
def test_current_weather_be() -> None: def test_current_weather_be() -> None:
api_data = get_api_data("forecast.json") api_data = get_api_data("forecast.json")