mirror of
https://github.com/jdejaegh/irm-kmi-ha.git
synced 2025-06-27 11:39:26 +02:00
Implement binary sensor for weather warning
This commit is contained in:
parent
5b02c8e29a
commit
bd26a99b0c
9 changed files with 1811 additions and 19 deletions
|
@ -1,16 +1,18 @@
|
||||||
"""Sensor to signal weather warning from the IRM KMI"""
|
"""Sensor to signal weather warning from the IRM KMI"""
|
||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import pytz
|
||||||
from homeassistant.components import binary_sensor
|
from homeassistant.components import binary_sensor
|
||||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
|
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from custom_components.irm_kmi import IrmKmiCoordinator, DOMAIN
|
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -28,7 +30,6 @@ class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
|
||||||
coordinator: IrmKmiCoordinator,
|
coordinator: IrmKmiCoordinator,
|
||||||
entry: ConfigEntry
|
entry: ConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
_LOGGER.info(f"{entry.entry_id}, {entry.title}")
|
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
BinarySensorEntity.__init__(self)
|
BinarySensorEntity.__init__(self)
|
||||||
self._attr_device_class = BinarySensorDeviceClass.SAFETY
|
self._attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||||
|
@ -44,5 +45,18 @@ class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
# TODO return a real value but first, change implementation of coordinator to expose the data
|
if self.coordinator.data.get('warnings') is None:
|
||||||
return True
|
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
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -45,9 +45,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
|
||||||
|
|
|
@ -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
1668
tests/fixtures/be_forecast_warning.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
27
tests/test_binary_sensor.py
Normal file
27
tests/test_binary_sensor.py
Normal 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
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Add table
Reference in a new issue