mirror of
https://github.com/jdejaegh/irm-kmi-ha.git
synced 2025-06-27 03:35:56 +02:00
Initial support for rain radar
This commit is contained in:
parent
e3d464e28f
commit
1f97db64ff
12 changed files with 196 additions and 19 deletions
|
@ -9,6 +9,7 @@ from datetime import datetime
|
|||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from aiohttp import ClientResponse
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -50,38 +51,36 @@ class IrmKmiApiClient:
|
|||
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
||||
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
||||
|
||||
return await self._api_wrapper(
|
||||
params={"s": "getForecasts"} | coord
|
||||
)
|
||||
response = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
||||
return await response.json()
|
||||
|
||||
async def get_image(self, url, params: dict | None = None) -> bytes:
|
||||
# TODO support etag and head request before requesting content
|
||||
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
||||
return await r.read()
|
||||
|
||||
async def _api_wrapper(
|
||||
self,
|
||||
params: dict,
|
||||
base_url: str | None = None,
|
||||
path: str = "",
|
||||
method: str = "get",
|
||||
data: dict | None = None,
|
||||
headers: dict | None = None
|
||||
headers: dict | None = None,
|
||||
) -> any:
|
||||
"""Get information from the API."""
|
||||
|
||||
if 's' not in params:
|
||||
raise IrmKmiApiParametersError("No query provided as 's' argument for API")
|
||||
else:
|
||||
params['k'] = _api_key(params['s'])
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
_LOGGER.debug(f"Calling for {params}")
|
||||
response = await self._session.request(
|
||||
method=method,
|
||||
url=f"{self._base_url}{path}",
|
||||
url=f"{self._base_url if base_url is None else base_url}{path}",
|
||||
headers=headers,
|
||||
json=data,
|
||||
params=params
|
||||
)
|
||||
_LOGGER.debug(f"API status code {response.status}")
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
return response
|
||||
|
||||
except asyncio.TimeoutError as exception:
|
||||
raise IrmKmiApiCommunicationError(
|
||||
|
|
104
custom_components/irm_kmi/camera.py
Normal file
104
custom_components/irm_kmi/camera.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
"""Create a radar view for IRM KMI weather"""
|
||||
# File inspired by https://github.com/jodur/imagesdirectory-camera/blob/main/custom_components/imagedirectory/camera.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
from . import IrmKmiCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
"""Set up the camera entry."""
|
||||
|
||||
_LOGGER.debug(f'async_setup_entry entry is: {entry}')
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
# await coordinator.async_config_entry_first_refresh()
|
||||
async_add_entities(
|
||||
[IrmKmiRadar(coordinator, entry)]
|
||||
)
|
||||
|
||||
|
||||
class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||
"""Representation of a local file camera."""
|
||||
|
||||
def __init__(self,
|
||||
coordinator: IrmKmiCoordinator,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize Local File Camera component."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
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._image_index = 0
|
||||
|
||||
@property # Baseclass Camera property override
|
||||
def frame_interval(self) -> float:
|
||||
"""Return the interval between frames of the mjpeg stream"""
|
||||
return 0.3
|
||||
|
||||
def camera_image(self,
|
||||
width: int | None = None,
|
||||
height: int | None = None) -> bytes | None:
|
||||
images = self.coordinator.data.get('animation', {}).get('images')
|
||||
if isinstance(images, list) and len(images) > 0:
|
||||
return images[0]
|
||||
return None
|
||||
|
||||
async def async_camera_image(
|
||||
self,
|
||||
width: int | None = None,
|
||||
height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return bytes of camera image."""
|
||||
return self.camera_image()
|
||||
|
||||
async def handle_async_still_stream(self, request, interval):
|
||||
"""Generate an HTTP MJPEG stream from camera images."""
|
||||
_LOGGER.info("handle_async_still_stream")
|
||||
self._image_index = 0
|
||||
return await async_get_still_stream(
|
||||
request, self.iterate, self.content_type, interval
|
||||
)
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Serve an HTTP MJPEG stream from the camera."""
|
||||
_LOGGER.info("handle_async_mjpeg_stream")
|
||||
return await self.handle_async_still_stream(request, self.frame_interval)
|
||||
|
||||
async def iterate(self) -> bytes | None:
|
||||
images = self.coordinator.data.get('animation', {}).get('images')
|
||||
if isinstance(images, list) and len(images) > 0:
|
||||
r = images[self._image_index]
|
||||
self._image_index = (self._image_index + 1) % len(images)
|
||||
return r
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the camera state attributes."""
|
||||
attrs = {"hint": self.coordinator.data.get('animation', {}).get('hint')}
|
||||
return attrs
|
||||
|
|
@ -14,7 +14,7 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
|||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = 'irm_kmi'
|
||||
PLATFORMS: list[Platform] = [Platform.WEATHER]
|
||||
PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.CAMERA]
|
||||
OUT_OF_BENELUX = ["außerhalb der Benelux (Brussels)",
|
||||
"Hors de Belgique (Bxl)",
|
||||
"Outside the Benelux (Brussels)",
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
|
||||
import async_timeout
|
||||
|
@ -10,6 +11,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
|||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator,
|
||||
UpdateFailed)
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .api import IrmKmiApiClient, IrmKmiApiError
|
||||
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
|
||||
|
@ -60,7 +62,64 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
|
|||
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
||||
raise UpdateFailed(f"Zone '{self._zone}' is out of Benelux and forecast is only available in the Benelux")
|
||||
|
||||
return self.process_api_data(api_data)
|
||||
result = self.process_api_data(api_data)
|
||||
|
||||
# TODO make such that the most up to date image is specified to entity for static display
|
||||
return result | await self._async_animation_data(api_data)
|
||||
|
||||
async def _async_animation_data(self, api_data: dict) -> dict:
|
||||
|
||||
default = {'animation': None}
|
||||
animation_data = api_data.get('animation', {}).get('sequence')
|
||||
localisation_layer = api_data.get('animation', {}).get('localisationLayer')
|
||||
country = api_data.get('country', None)
|
||||
|
||||
if animation_data is None or localisation_layer is None or not isinstance(animation_data, list):
|
||||
return default
|
||||
|
||||
coroutines = list()
|
||||
coroutines.append(self._api_client.get_image(f"{localisation_layer}&th={'d' if country == 'NL' else 'n'}"))
|
||||
for frame in animation_data:
|
||||
if frame.get('uri', None) is not None:
|
||||
coroutines.append(self._api_client.get_image(frame.get('uri')))
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(20):
|
||||
r = await asyncio.gather(*coroutines, return_exceptions=True)
|
||||
except IrmKmiApiError:
|
||||
_LOGGER.warning(f"Could not get images for weather radar")
|
||||
return default
|
||||
_LOGGER.debug(f"Just downloaded {len(r)} images")
|
||||
|
||||
if country == 'NL':
|
||||
background = Image.open("custom_components/irm_kmi/resources/nl.png").convert('RGBA')
|
||||
else:
|
||||
background = Image.open("custom_components/irm_kmi/resources/be_bw.png").convert('RGBA')
|
||||
localisation = Image.open(BytesIO(r[0])).convert('RGBA')
|
||||
merged_frames = list()
|
||||
for frame in r[1:]:
|
||||
layer = Image.open(BytesIO(frame)).convert('RGBA')
|
||||
temp = Image.alpha_composite(background, layer)
|
||||
temp = Image.alpha_composite(temp, localisation)
|
||||
|
||||
draw = ImageDraw.Draw(temp)
|
||||
font = ImageFont.truetype("custom_components/irm_kmi/resources/roboto_medium.ttf", 16)
|
||||
# TODO write actual date time
|
||||
if country == 'NL':
|
||||
draw.text((4, 4), "Sample Text", (0, 0, 0), font=font)
|
||||
else:
|
||||
draw.text((4, 4), "Sample Text", (255, 255, 255), font=font)
|
||||
|
||||
bytes_img = BytesIO()
|
||||
temp.save(bytes_img, 'png')
|
||||
merged_frames.append(bytes_img.getvalue())
|
||||
|
||||
return {'animation': {
|
||||
'images': merged_frames,
|
||||
# TODO support translation for hint
|
||||
'hint': api_data.get('animation', {}).get('sequenceHint', {}).get('en')
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def process_api_data(api_data):
|
||||
|
@ -82,6 +141,7 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
|
|||
if module.get('type', None) == 'uv':
|
||||
uv_index = module.get('data', {}).get('levelValue')
|
||||
# Put everything together
|
||||
# TODO NL cities have a better 'obs' section, use that for current weather
|
||||
processed_data = {
|
||||
'current_weather': {
|
||||
'condition': CDT_MAP.get(
|
||||
|
|
BIN
custom_components/irm_kmi/resources/be_bw.png
Normal file
BIN
custom_components/irm_kmi/resources/be_bw.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
custom_components/irm_kmi/resources/be_satellite.png
Normal file
BIN
custom_components/irm_kmi/resources/be_satellite.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 667 KiB |
BIN
custom_components/irm_kmi/resources/nl.png
Normal file
BIN
custom_components/irm_kmi/resources/nl.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
BIN
custom_components/irm_kmi/resources/roboto_medium.ttf
Normal file
BIN
custom_components/irm_kmi/resources/roboto_medium.ttf
Normal file
Binary file not shown.
|
@ -1,4 +1,4 @@
|
|||
""""Support for IRM KMI weather."""
|
||||
"""Support for IRM KMI weather."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
|||
|
||||
_LOGGER.debug(f'async_setup_entry entry is: {entry}')
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
# await coordinator.async_config_entry_first_refresh()
|
||||
async_add_entities(
|
||||
[IrmKmiWeather(coordinator, entry)]
|
||||
)
|
||||
|
@ -37,6 +37,7 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
|||
entry: ConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
WeatherEntity.__init__(self)
|
||||
self._name = entry.title
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
|
|
|
@ -2,3 +2,4 @@ aiohttp==3.9.1
|
|||
async_timeout==4.0.3
|
||||
homeassistant==2023.12.3
|
||||
voluptuous==0.13.1
|
||||
Pillow==10.1.0
|
|
@ -78,3 +78,14 @@ def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None
|
|||
irm_kmi = irm_kmi_api_mock.return_value
|
||||
irm_kmi.get_forecasts_coord.side_effect = IrmKmiApiParametersError
|
||||
yield irm_kmi
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_coordinator(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||
"""Return a mocked IrmKmi api client."""
|
||||
with patch(
|
||||
"custom_components.irm_kmi.IrmKmiCoordinator", autospec=True
|
||||
) as coordinator_mock:
|
||||
coord = coordinator_mock.return_value
|
||||
coord._async_animation_data.return_value = {'animation': None}
|
||||
yield coord
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for the IRM KMI integration."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
|
@ -15,6 +15,7 @@ async def test_load_unload_config_entry(
|
|||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_irm_kmi_api: AsyncMock,
|
||||
mock_coordinator: AsyncMock
|
||||
) -> None:
|
||||
"""Test the IRM KMI configuration entry loading/unloading."""
|
||||
hass.states.async_set(
|
||||
|
|
Loading…
Add table
Reference in a new issue