Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
josiasmontag committed Aug 19, 2024
1 parent abb5064 commit 070860e
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 16 deletions.
120 changes: 120 additions & 0 deletions custom_components/dwd_rain_radar/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""Binary Sensor entities for the DWD Rain Radar integration."""

from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta

from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription
)

from .const import DOMAIN, ATTRIBUTION, FORECAST_MINUTES
from homeassistant.const import (
ATTR_ATTRIBUTION
)
from .coordinator import DwdRainRadarUpdateCoordinator, PrecipitationForecast
from .entity import DwdCoordinatorEntity

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, kw_only=True)
class BinarySensorEntityDescription(BinarySensorEntityDescription):
"""Provide a description for a precipitation sensor."""

is_on_fn: Callable[[PrecipitationForecast]]
extra_state_attributes_fn: Callable[[PrecipitationForecast], dict] = lambda _: {}
exists_fn: Callable[[dict], bool] = lambda _: True


PRECIPTITATION_SENSORS = [
BinarySensorEntityDescription(
key="raining",
name="Raining",
device_class=BinarySensorDeviceClass.MOISTURE,
is_on_fn=lambda forecasts: next(
(forecast.precipitation > 0 for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() - timedelta(minutes=5)),
None
),
extra_state_attributes_fn=lambda forecasts: {
'prediction_time': next(
(forecast.prediction_time for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() - timedelta(minutes=5)),
None
)
},
),
*(BinarySensorEntityDescription(
key=f"raining_in_{forecast_in}_minutes",
name=f"Raining In {forecast_in} Minutes",
entity_registry_enabled_default=False,
device_class=BinarySensorDeviceClass.MOISTURE,
is_on_fn=lambda forecasts, forecast_in=forecast_in: next(
(forecast.precipitation > 0 for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() + timedelta(minutes=forecast_in - 5)),
None
),
extra_state_attributes_fn=lambda forecasts, forecast_in=forecast_in: {
'prediction_time': next(
(forecast.prediction_time for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() + timedelta(minutes=forecast_in - 5)),
None
)
},
) for forecast_in in FORECAST_MINUTES),
]


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
RainingSensorEntity(coordinator, description)
for description in PRECIPTITATION_SENSORS
if description.exists_fn(entry)
)


class RainingSensorEntity(DwdCoordinatorEntity, BinarySensorEntity):
"""Implementation of a precipitation sensor."""

def __init__(
self,
coordinator: DwdRainRadarUpdateCoordinator,
description: BinarySensorEntityDescription,
) -> None:
"""Initialize the sensor entity."""
super().__init__(coordinator, description)

self._attr_unique_id = (
f"{self.coordinator.config_entry.entry_id}"
+ f"_{self.entity_description.key}"
)

@property
def is_on(self):
"""Return the state of the sensor."""
return self.entity_description.is_on_fn(self.coordinator.data)

@property
def extra_state_attributes(self):
"""Return the state attributes of the device."""
attributes = self.entity_description.extra_state_attributes_fn(self.coordinator.data)

attributes['latest_update'] = self.coordinator.latest_update
attributes[ATTR_ATTRIBUTION] = ATTRIBUTION

return attributes
4 changes: 2 additions & 2 deletions custom_components/dwd_rain_radar/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

from homeassistant.const import Platform


DOMAIN = "dwd_rain_radar"

ATTRIBUTION = "Data provided by Deutscher Wetterdienst (DWD)"

PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]

CONF_COORDINATES = "coordinates"

DWD_OPENDATA_URL = "https://opendata.dwd.de"

DWD_RADAR_COMPOSITE_RV_URL = f"{DWD_OPENDATA_URL}/weather/radar/composite/rv/DE1200_RV_LATEST.tar.bz2"

FORECAST_MINUTES = [5, 10, 15, 30, 60, 90, 120]
6 changes: 3 additions & 3 deletions custom_components/dwd_rain_radar/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

_LOGGER = logging.getLogger(__name__)

UPDATE_INTERVAL = timedelta(seconds=300)
UPDATE_INTERVAL = timedelta(seconds=60)


@dataclass(slots=True)
Expand All @@ -33,8 +33,8 @@ class PrecipitationForecast:
def from_radolan_data(cls, data) -> PrecipitationForecast:
"""Return instance of Precipitation."""
return cls(
prediction_time=pd.to_datetime(data.prediction_time.values[0]).to_pydatetime(),
precipitation=data.RV.values.item()
prediction_time=pd.to_datetime(data.prediction_time.values[0], utc=True).to_pydatetime().astimezone(),
precipitation=round(data.RV.values.item(), 2)
)


Expand Down
1 change: 0 additions & 1 deletion custom_components/dwd_rain_radar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/josiasmontag/ha-dwd-rain-radar",
"integration_type": "hub",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/josiasmontag/ha-dwd-rain-radar/issues",
"requirements": [
Expand Down
13 changes: 11 additions & 2 deletions custom_components/dwd_rain_radar/radolan.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import asyncio
import tarfile
import logging
from io import BytesIO

import wradlib as wrl
Expand All @@ -12,6 +13,8 @@

from .const import DWD_RADAR_COMPOSITE_RV_URL

_LOGGER = logging.getLogger(__name__)


class Radolan:
"""Radolan class."""
Expand Down Expand Up @@ -41,13 +44,19 @@ async def update(self):

resp = await self._async_client.get(url, headers=headers)

_LOGGER.debug(f"Response {resp.status_code} (Headers: {resp.headers}) from {url}")

if resp.status_code == 304:
return self.curr_value

if resp.status_code != httpx.codes.OK:
resp.raise_for_status()

self.curr_value = self._parse(resp.read())
loop = asyncio.get_running_loop()

self.curr_value = await loop.run_in_executor(None, self._parse, resp.read())

self._last_etag = resp.headers["ETag"]

return self.curr_value

Expand Down
59 changes: 54 additions & 5 deletions custom_components/dwd_rain_radar/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from operator import attrgetter

from homeassistant.core import HomeAssistant
Expand All @@ -18,7 +19,7 @@
SensorStateClass,
)

from .const import DOMAIN, ATTRIBUTION
from .const import DOMAIN, ATTRIBUTION, FORECAST_MINUTES
from homeassistant.const import (
ATTR_ATTRIBUTION
)
Expand All @@ -44,22 +45,70 @@ class PrecipitationSensorEntityDescription(SensorEntityDescription):
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda forecasts: forecasts[0].precipitation,
value_fn=lambda forecasts: next(
(forecast.precipitation for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() - timedelta(minutes=5)),
None
),
extra_state_attributes_fn=lambda forecasts: {
'prediction_time': forecasts[0].prediction_time
'prediction_time': next(
(forecast.prediction_time for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() - timedelta(minutes=5)),
None
)
},
),
*(PrecipitationSensorEntityDescription(
key=f"precipitation_in_{forecast_in}_minutes",
name=f"Precipitation In {forecast_in} Minutes",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda forecasts, forecast_in=forecast_in: next(
(forecast.precipitation for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() + timedelta(minutes=forecast_in - 5)),
None
),
extra_state_attributes_fn=lambda forecasts, forecast_in=forecast_in: {
'prediction_time': next(
(forecast.prediction_time for forecast in forecasts if
forecast.prediction_time > datetime.now().astimezone() + timedelta(minutes=forecast_in - 5)),
None
)
},
) for forecast_in in FORECAST_MINUTES),
PrecipitationSensorEntityDescription(
key="rain_expected_at",
name="Rain Expected At",
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.DATE,
value_fn=lambda forecasts: next(
(forecast.prediction_time for forecast in forecasts if forecast.precipitation > 0),
(forecast.prediction_time for forecast in forecasts if
forecast.precipitation > 0 and forecast.prediction_time > datetime.now().astimezone()),
None
),
extra_state_attributes_fn=lambda forecasts: {
'precipitation': next(
(forecast.precipitation for forecast in forecasts if
forecast.precipitation > 0 and forecast.prediction_time > datetime.now().astimezone()),
None
)
},
),
PrecipitationSensorEntityDescription(
key="rain_expected_in_minutes",
name="Rain Expected In Minutes",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda forecasts: next(
(int((forecast.prediction_time - datetime.now().astimezone()).total_seconds() // 60) for forecast in
forecasts if forecast.precipitation > 0 and forecast.prediction_time > datetime.now().astimezone()),
None
),
extra_state_attributes_fn=lambda forecasts: {
'precipitation': next(
(forecast.precipitation for forecast in forecasts if forecast.precipitation > 0),
(forecast.precipitation for forecast in forecasts if
forecast.precipitation > 0 and forecast.prediction_time > datetime.now().astimezone()),
None
)
},
Expand Down
61 changes: 61 additions & 0 deletions tests/test_binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Test binary sensor for DWD rain radar integration."""
import os

import pytest
from unittest.mock import AsyncMock, patch, MagicMock

from freezegun import freeze_time
from pytest_homeassistant_custom_component.common import MockConfigEntry
from typing_extensions import Generator

from custom_components.dwd_rain_radar.const import DOMAIN

# Example binary data to return
with open(os.path.dirname(__file__) + '/DE1200_RV_LATEST.tar.bz2', 'rb') as f:
binary_data = f.read()


@pytest.fixture
def entity_registry_enabled_by_default() -> Generator[None]:
"""Test fixture that ensures all entities are enabled in the registry."""
with patch(
"homeassistant.helpers.entity.Entity.entity_registry_enabled_default",
return_value=True,
):
yield

@pytest.mark.asyncio
@patch('httpx.AsyncClient.get', new_callable=AsyncMock)
@freeze_time("2024-08-08T15:47:00", tz_offset=2)
async def test_binary_sensor(mock_get, hass, enable_custom_integrations, entity_registry_enabled_by_default):
"""Test binary sensor."""

# Create a mock response object
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.read = MagicMock(return_value=binary_data) # Mock the read() method

# Assign the mock response to the get request
mock_get.return_value = mock_response

entry = MockConfigEntry(domain=DOMAIN, data={
"name": "test dwd",
"coordinates": {
"latitude": 48.07530,
"longitude": 11.32589
}
})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

raining = hass.states.get("binary_sensor.mock_title_raining")

assert raining
assert raining.state == 'on'

Check failure on line 55 in tests/test_binary_sensor.py

View workflow job for this annotation

GitHub Actions / tests (3.12)

test_binary_sensor AssertionError: assert 'off' == 'on' - on + off
assert raining.attributes['prediction_time'].isoformat() == '2024-08-08T17:50:00+02:00'

raining_in_120_minutes = hass.states.get("binary_sensor.mock_title_raining_in_120_minutes")
assert raining_in_120_minutes
assert raining_in_120_minutes.state == 'off'
assert raining_in_120_minutes.attributes['prediction_time'].isoformat() == '2024-08-08T19:45:00+02:00'
Loading

0 comments on commit 070860e

Please sign in to comment.