481 lines
15 KiB
Diff
481 lines
15 KiB
Diff
From 92f85569b2ec8beabfb7ea6aea462fba5e5af39d Mon Sep 17 00:00:00 2001
|
|
From: Martin Weinelt <hexa@darmstadt.ccc.de>
|
|
Date: Sat, 20 Mar 2021 17:33:14 +0100
|
|
Subject: [PATCH] warnwetter: init
|
|
|
|
---
|
|
CODEOWNERS | 1 +
|
|
.../components/warnwetter/__init__.py | 1 +
|
|
.../components/warnwetter/manifest.json | 7 +
|
|
homeassistant/components/warnwetter/sensor.py | 302 ++++++++++++++++++
|
|
.../components/warnwetter/weather.py | 115 +++++++
|
|
5 files changed, 426 insertions(+)
|
|
create mode 100644 homeassistant/components/warnwetter/__init__.py
|
|
create mode 100644 homeassistant/components/warnwetter/manifest.json
|
|
create mode 100644 homeassistant/components/warnwetter/sensor.py
|
|
create mode 100644 homeassistant/components/warnwetter/weather.py
|
|
|
|
diff --git a/CODEOWNERS b/CODEOWNERS
|
|
index 0e069f94e7..596beebec8 100644
|
|
--- a/CODEOWNERS
|
|
+++ b/CODEOWNERS
|
|
@@ -525,6 +525,7 @@ homeassistant/components/vlc_telnet/* @rodripf @dmcc
|
|
homeassistant/components/volkszaehler/* @fabaff
|
|
homeassistant/components/volumio/* @OnFreund
|
|
homeassistant/components/waqi/* @andrey-git
|
|
+homeassistant/components/warnwetter/* @mtdcr
|
|
homeassistant/components/watson_tts/* @rutkai
|
|
homeassistant/components/weather/* @fabaff
|
|
homeassistant/components/webostv/* @bendavid
|
|
diff --git a/homeassistant/components/warnwetter/__init__.py b/homeassistant/components/warnwetter/__init__.py
|
|
new file mode 100644
|
|
index 0000000000..538fc5a2dc
|
|
--- /dev/null
|
|
+++ b/homeassistant/components/warnwetter/__init__.py
|
|
@@ -0,0 +1 @@
|
|
+"""The warnwetter component."""
|
|
diff --git a/homeassistant/components/warnwetter/manifest.json b/homeassistant/components/warnwetter/manifest.json
|
|
new file mode 100644
|
|
index 0000000000..cf37e4824e
|
|
--- /dev/null
|
|
+++ b/homeassistant/components/warnwetter/manifest.json
|
|
@@ -0,0 +1,7 @@
|
|
+{
|
|
+ "domain": "warnwetter",
|
|
+ "name": "DWD WarnWetter API",
|
|
+ "documentation": "https://www.home-assistant.io/integrations/warnwetter",
|
|
+ "dependencies": ["weather"],
|
|
+ "codeowners": ["@mtdcr"]
|
|
+}
|
|
diff --git a/homeassistant/components/warnwetter/sensor.py b/homeassistant/components/warnwetter/sensor.py
|
|
new file mode 100644
|
|
index 0000000000..65bbe2bdce
|
|
--- /dev/null
|
|
+++ b/homeassistant/components/warnwetter/sensor.py
|
|
@@ -0,0 +1,302 @@
|
|
+"""Sensor platform for data from WarnWetter."""
|
|
+from datetime import datetime, timedelta
|
|
+import json
|
|
+import logging
|
|
+from typing import Any, Dict, Optional, Union
|
|
+
|
|
+from aiohttp.hdrs import USER_AGENT
|
|
+import pytz
|
|
+import requests
|
|
+import voluptuous as vol
|
|
+
|
|
+from homeassistant.components.weather import (
|
|
+ ATTR_FORECAST_CONDITION,
|
|
+ ATTR_FORECAST_PRECIPITATION,
|
|
+ ATTR_FORECAST_TEMP,
|
|
+ ATTR_FORECAST_TEMP_LOW,
|
|
+ ATTR_FORECAST_TIME,
|
|
+ ATTR_FORECAST_WIND_BEARING,
|
|
+ ATTR_FORECAST_WIND_SPEED,
|
|
+)
|
|
+from homeassistant.const import (
|
|
+ ATTR_ATTRIBUTION,
|
|
+ ATTR_ICON,
|
|
+ ATTR_TEMPERATURE,
|
|
+ ATTR_TIME,
|
|
+ CONF_MONITORED_CONDITIONS,
|
|
+ CONF_NAME,
|
|
+ DEGREE,
|
|
+ DEVICE_CLASS_HUMIDITY,
|
|
+ DEVICE_CLASS_PRESSURE,
|
|
+ DEVICE_CLASS_TEMPERATURE,
|
|
+ DEVICE_CLASS_TIMESTAMP,
|
|
+ PERCENTAGE,
|
|
+ PRESSURE_HPA,
|
|
+ SPEED_KILOMETERS_PER_HOUR,
|
|
+ TEMP_CELSIUS,
|
|
+ TIME_HOURS,
|
|
+ TIME_MINUTES,
|
|
+ __version__,
|
|
+)
|
|
+import homeassistant.helpers.config_validation as cv
|
|
+from homeassistant.helpers.entity import Entity
|
|
+from homeassistant.util import Throttle
|
|
+
|
|
+_LOGGER = logging.getLogger(__name__)
|
|
+
|
|
+ATTR_STATION = "station"
|
|
+ATTR_UPDATED = "updated"
|
|
+ATTRIBUTION = "Data provided by WarnWetter"
|
|
+
|
|
+CONF_STATION_ID = "station_id"
|
|
+
|
|
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
|
|
+MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30)
|
|
+
|
|
+ATTR_DEWPOINT = "dewpoint"
|
|
+ATTR_HUMIDITY = "humidity"
|
|
+ATTR_MAXWIND = "maxwind"
|
|
+ATTR_MEANWIND = "meanwind"
|
|
+ATTR_PRECIPITATION = "precipitation"
|
|
+ATTR_PRECIPITATION3H = "precipitation3h"
|
|
+ATTR_PRESSURE = "pressure"
|
|
+ATTR_SUNSHINE = "sunshine"
|
|
+ATTR_TOTALSNOW = "totalsnow"
|
|
+ATTR_WINDDIRECTION = "winddirection"
|
|
+
|
|
+SENSOR_TYPES = {
|
|
+ ATTR_DEWPOINT: ("Dew Point", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, 10),
|
|
+ ATTR_HUMIDITY: ("Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE, 10),
|
|
+ ATTR_ICON: ("Icon", None, None, 1),
|
|
+ ATTR_MAXWIND: ("Top Wind Speed", None, SPEED_KILOMETERS_PER_HOUR, 10),
|
|
+ ATTR_MEANWIND: ("Wind Speed", None, SPEED_KILOMETERS_PER_HOUR, 10),
|
|
+ ATTR_PRECIPITATION: ("Precipitation", None, f"mm/{TIME_HOURS}", 10),
|
|
+ ATTR_PRECIPITATION3H: (
|
|
+ f"Precipitation 3{TIME_HOURS}",
|
|
+ None,
|
|
+ f"mm/3{TIME_HOURS}",
|
|
+ 10,
|
|
+ ),
|
|
+ ATTR_PRESSURE: ("Pressure", DEVICE_CLASS_PRESSURE, PRESSURE_HPA, 10),
|
|
+ ATTR_SUNSHINE: ("Sun Last Hour", None, TIME_MINUTES, 10),
|
|
+ ATTR_TEMPERATURE: ("Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, 10),
|
|
+ ATTR_TIME: ("Update Time", DEVICE_CLASS_TIMESTAMP, None, 1000),
|
|
+ ATTR_TOTALSNOW: ("Snow", None, "cm", 10),
|
|
+ ATTR_WINDDIRECTION: ("Wind Bearing", None, DEGREE, 10),
|
|
+}
|
|
+
|
|
+CONDITION_CLASSES = {
|
|
+ "cloudy": {4},
|
|
+ "fog": {5, 6},
|
|
+ "hail": {17, 24, 25},
|
|
+ "lightning": {26},
|
|
+ "lightning-rainy": {27, 28, 29, 30},
|
|
+ "partlycloudy": {2, 3},
|
|
+ "pouring": {18, 19},
|
|
+ "rainy": {7, 8, 9, 10, 11},
|
|
+ "snowy": {14, 15, 16, 22, 23},
|
|
+ "snowy-rainy": {12, 13, 20, 21},
|
|
+ "sunny": {1},
|
|
+ "windy": {31},
|
|
+}
|
|
+
|
|
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
|
|
+ {
|
|
+ vol.Required(CONF_MONITORED_CONDITIONS, default=["temperature"]): vol.All(
|
|
+ cv.ensure_list, [vol.In(SENSOR_TYPES)]
|
|
+ ),
|
|
+ vol.Required(CONF_STATION_ID): cv.string,
|
|
+ vol.Required(CONF_NAME): cv.string,
|
|
+ }
|
|
+)
|
|
+
|
|
+
|
|
+def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
+ """Set up the WarnWetter sensor platform."""
|
|
+ name = config[CONF_NAME]
|
|
+ probe = WarnWetterMeasurements(config[CONF_STATION_ID])
|
|
+
|
|
+ add_entities(
|
|
+ [
|
|
+ WarnWetterSensor(probe, variable, name)
|
|
+ for variable in config[CONF_MONITORED_CONDITIONS]
|
|
+ ],
|
|
+ True,
|
|
+ )
|
|
+
|
|
+
|
|
+class WarnWetterSensor(Entity):
|
|
+ """Implementation of a WarnWetter sensor."""
|
|
+
|
|
+ def __init__(self, probe, variable, name):
|
|
+ """Initialize the sensor."""
|
|
+ self.probe = probe
|
|
+ self.client_name = name
|
|
+ self.variable = variable
|
|
+
|
|
+ @property
|
|
+ def unique_id(self) -> Optional[str]:
|
|
+ """Return a unique ID."""
|
|
+ return f"{self.probe.station_id}:{self.variable}"
|
|
+
|
|
+ @property
|
|
+ def name(self) -> Optional[str]:
|
|
+ """Return the name of the entity."""
|
|
+ return f"{self.client_name} {SENSOR_TYPES[self.variable][0]}"
|
|
+
|
|
+ @property
|
|
+ def state(self) -> Union[None, str, int, float]:
|
|
+ """Return the state of the entity."""
|
|
+ return self.probe.data.get(self.variable)
|
|
+
|
|
+ @property
|
|
+ def device_state_attributes(self) -> Optional[Dict[str, Any]]:
|
|
+ """Return device specific state attributes."""
|
|
+ return {
|
|
+ ATTR_ATTRIBUTION: ATTRIBUTION,
|
|
+ ATTR_STATION: self.probe.station_id,
|
|
+ ATTR_UPDATED: self.probe.last_update.isoformat(),
|
|
+ }
|
|
+
|
|
+ @property
|
|
+ def device_class(self) -> Optional[str]:
|
|
+ """Return the class of this device, from component DEVICE_CLASSES."""
|
|
+ return SENSOR_TYPES[self.variable][1]
|
|
+
|
|
+ @property
|
|
+ def unit_of_measurement(self) -> Optional[str]:
|
|
+ """Return the unit of measurement of this entity, if any."""
|
|
+ return SENSOR_TYPES[self.variable][2]
|
|
+
|
|
+ def update(self):
|
|
+ """Delegate update to probe."""
|
|
+ self.probe.update()
|
|
+
|
|
+
|
|
+class WarnWetterClient:
|
|
+ """Base class for static json data."""
|
|
+
|
|
+ API_URL = "https://s3.eu-central-1.amazonaws.com/app-prod-static.warnwetter.de/v16/%s_%s.json"
|
|
+ API_HEADERS = {USER_AGENT: f"home-assistant.warnwetter/ {__version__}"}
|
|
+
|
|
+ def __init__(self, resource, station_id, interval_h):
|
|
+ """Initialize WarnWetterClient."""
|
|
+ self._station_id = station_id
|
|
+ self._api_url = self.API_URL % (resource, station_id)
|
|
+ self._interval_h = interval_h
|
|
+ self.data = {}
|
|
+
|
|
+ @property
|
|
+ def station_id(self):
|
|
+ """Return station ID."""
|
|
+ return self._station_id
|
|
+
|
|
+ @property
|
|
+ def last_update(self):
|
|
+ """Return the timestamp of the most recent data."""
|
|
+ time = self.data.get("time")
|
|
+ if time is not None:
|
|
+ return datetime.fromtimestamp(int(time)).replace(
|
|
+ tzinfo=pytz.timezone("Europe/Berlin")
|
|
+ )
|
|
+
|
|
+ def _current_observations(self):
|
|
+ """Fetch the latest JSON data."""
|
|
+ try:
|
|
+ response = requests.get(self._api_url, headers=self.API_HEADERS, timeout=15)
|
|
+ response.raise_for_status()
|
|
+ return json.loads(response.content)
|
|
+ except requests.exceptions.HTTPError:
|
|
+ _LOGGER.error("While fetching data")
|
|
+
|
|
+ def _fetch_json(self):
|
|
+ """Get the latest data from WarnWetter."""
|
|
+ if self.last_update and (
|
|
+ self.last_update + timedelta(hours=self._interval_h)
|
|
+ > datetime.utcnow().replace(tzinfo=pytz.utc)
|
|
+ ):
|
|
+ return None
|
|
+
|
|
+ return self._current_observations()
|
|
+
|
|
+ @classmethod
|
|
+ def _icons_to_condition(cls, icons):
|
|
+ """Convert icon numbers to conditions (first match)."""
|
|
+ for icon in icons:
|
|
+ for key, val in CONDITION_CLASSES.items():
|
|
+ if icon in val:
|
|
+ return key
|
|
+
|
|
+
|
|
+class WarnWetterForecast(WarnWetterClient):
|
|
+ """A class consuming forecast data."""
|
|
+
|
|
+ def __init__(self, station_id):
|
|
+ """Initialize WarnWetterForecast."""
|
|
+ super().__init__("forecast_mosmix", station_id, 4)
|
|
+
|
|
+ @Throttle(MIN_TIME_BETWEEN_FORECAST_UPDATES)
|
|
+ def update(self):
|
|
+ """Update state."""
|
|
+ src = self._fetch_json()
|
|
+ if not src:
|
|
+ return
|
|
+
|
|
+ data = {
|
|
+ "forecast": [],
|
|
+ }
|
|
+ for key, value in src.items():
|
|
+ if key == "days" and isinstance(value, list):
|
|
+ for day in value:
|
|
+ data["forecast"].append(
|
|
+ {
|
|
+ ATTR_FORECAST_TIME: day.get("dayDate"),
|
|
+ ATTR_FORECAST_TEMP_LOW: day.get("temperatureMin") / 10.0,
|
|
+ ATTR_FORECAST_TEMP: day.get("temperatureMax") / 10.0,
|
|
+ ATTR_FORECAST_CONDITION: self._condition(day),
|
|
+ ATTR_FORECAST_PRECIPITATION: day.get("precipitation")
|
|
+ / 10.0,
|
|
+ ATTR_FORECAST_WIND_SPEED: day.get("windSpeed") / 10.0,
|
|
+ ATTR_FORECAST_WIND_BEARING: day.get("windDirection") / 10.0,
|
|
+ # ATTR_FORECAST_WIND_GUST: day.get('windGust') / 10.0,
|
|
+ # ATTR_FORECAST_SUNSHINE: day.get('sunshine') / 10.0,
|
|
+ }
|
|
+ )
|
|
+
|
|
+ elif key == "forecast" and isinstance(value, dict):
|
|
+ data["time"] = value.get("start", 0) // 1000
|
|
+
|
|
+ self.data = data
|
|
+
|
|
+ @classmethod
|
|
+ def _condition(cls, day):
|
|
+ keys = ("icon", "icon1", "icon2")
|
|
+ icons = filter(None, (day.get(icon) for icon in keys))
|
|
+ return cls._icons_to_condition(icons)
|
|
+
|
|
+
|
|
+class WarnWetterMeasurements(WarnWetterClient):
|
|
+ """A class consuming measured data."""
|
|
+
|
|
+ def __init__(self, station_id):
|
|
+ """Initialize WarnWetterMeasurements."""
|
|
+ super().__init__("current_measurement", station_id, 1)
|
|
+
|
|
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
|
|
+ def update(self):
|
|
+ """Update state."""
|
|
+ src = self._fetch_json()
|
|
+ if not src:
|
|
+ return
|
|
+
|
|
+ data = {}
|
|
+ for key, value in src.items():
|
|
+ spec = SENSOR_TYPES.get(key)
|
|
+ if spec:
|
|
+ if isinstance(value, int) and value != 32767:
|
|
+ if key == "icon":
|
|
+ data[key] = self._icons_to_condition([value])
|
|
+ else:
|
|
+ data[key] = value / spec[3]
|
|
+
|
|
+ self.data = data
|
|
diff --git a/homeassistant/components/warnwetter/weather.py b/homeassistant/components/warnwetter/weather.py
|
|
new file mode 100644
|
|
index 0000000000..f00155c2bf
|
|
--- /dev/null
|
|
+++ b/homeassistant/components/warnwetter/weather.py
|
|
@@ -0,0 +1,115 @@
|
|
+"""Weather platform for data from WarnWetter."""
|
|
+import logging
|
|
+from typing import Optional
|
|
+
|
|
+import voluptuous as vol
|
|
+
|
|
+from homeassistant.components.weather import (
|
|
+ ATTR_FORECAST_CONDITION,
|
|
+ PLATFORM_SCHEMA,
|
|
+ WeatherEntity,
|
|
+)
|
|
+from homeassistant.const import ATTR_ICON, ATTR_TEMPERATURE, CONF_NAME, TEMP_CELSIUS
|
|
+from homeassistant.helpers import config_validation as cv
|
|
+
|
|
+# Reuse data and API logic from the sensor implementation
|
|
+from .sensor import (
|
|
+ ATTR_HUMIDITY,
|
|
+ ATTR_MEANWIND,
|
|
+ ATTR_PRESSURE,
|
|
+ ATTR_WINDDIRECTION,
|
|
+ ATTRIBUTION,
|
|
+ CONF_STATION_ID,
|
|
+ WarnWetterForecast,
|
|
+ WarnWetterMeasurements,
|
|
+)
|
|
+
|
|
+_LOGGER = logging.getLogger(__name__)
|
|
+
|
|
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
+ {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_STATION_ID): cv.string}
|
|
+)
|
|
+
|
|
+
|
|
+def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
+ """Set up the WarnWetter weather platform."""
|
|
+ mprobe = WarnWetterMeasurements(config[CONF_STATION_ID])
|
|
+ fprobe = WarnWetterForecast(config[CONF_STATION_ID])
|
|
+ entity = WarnWetterWeather(mprobe, fprobe, config[CONF_NAME])
|
|
+ add_entities([entity], True)
|
|
+
|
|
+
|
|
+class WarnWetterWeather(WeatherEntity):
|
|
+ """Representation of a weather condition."""
|
|
+
|
|
+ def __init__(self, mprobe, fprobe, stationname):
|
|
+ """Initialize WarnWetterWeather."""
|
|
+ self.mprobe = mprobe
|
|
+ self.fprobe = fprobe
|
|
+ self.stationname = stationname
|
|
+
|
|
+ @property
|
|
+ def unique_id(self) -> Optional[str]:
|
|
+ """Return a unique ID."""
|
|
+ return self.mprobe.station_id
|
|
+
|
|
+ @property
|
|
+ def name(self) -> Optional[str]:
|
|
+ """Return the name of the entity."""
|
|
+ return self.stationname
|
|
+
|
|
+ @property
|
|
+ def temperature(self):
|
|
+ """Return the platform temperature."""
|
|
+ return self.mprobe.data.get(ATTR_TEMPERATURE)
|
|
+
|
|
+ @property
|
|
+ def temperature_unit(self):
|
|
+ """Return the unit of measurement."""
|
|
+ return TEMP_CELSIUS
|
|
+
|
|
+ @property
|
|
+ def pressure(self):
|
|
+ """Return the pressure."""
|
|
+ return self.mprobe.data.get(ATTR_PRESSURE)
|
|
+
|
|
+ @property
|
|
+ def humidity(self):
|
|
+ """Return the humidity."""
|
|
+ return self.mprobe.data.get(ATTR_HUMIDITY)
|
|
+
|
|
+ @property
|
|
+ def wind_speed(self):
|
|
+ """Return the wind speed."""
|
|
+ return self.mprobe.data.get(ATTR_MEANWIND)
|
|
+
|
|
+ @property
|
|
+ def wind_bearing(self):
|
|
+ """Return the wind bearing."""
|
|
+ return self.mprobe.data.get(ATTR_WINDDIRECTION)
|
|
+
|
|
+ @property
|
|
+ def attribution(self):
|
|
+ """Return the attribution."""
|
|
+ return ATTRIBUTION
|
|
+
|
|
+ @property
|
|
+ def forecast(self):
|
|
+ """Return the forecast."""
|
|
+ return self.fprobe.data.get("forecast")
|
|
+
|
|
+ @property
|
|
+ def condition(self):
|
|
+ """Return the current condition."""
|
|
+ icon = self.mprobe.data.get(ATTR_ICON)
|
|
+ if icon:
|
|
+ return icon
|
|
+
|
|
+ forecast = self.forecast
|
|
+ if forecast:
|
|
+ return forecast[0].get(ATTR_FORECAST_CONDITION)
|
|
+
|
|
+ def update(self):
|
|
+ """Update state."""
|
|
+ self.mprobe.update()
|
|
+ self.fprobe.update()
|
|
--
|
|
2.30.1
|
|
|