From eae28e29a39b7bf36dabfcd65bac0807c6744fcb Mon Sep 17 00:00:00 2001 From: Malte Brandy Date: Fri, 4 Jun 2021 03:32:57 +0200 Subject: [PATCH] Install home-assistant --- nixos/machines/hera/configuration.nix | 1 + nixos/roles/home-assistant/default.nix | 44 ++ nixos/roles/home-assistant/warnwetter.patch | 481 ++++++++++++++++++++ 3 files changed, 526 insertions(+) create mode 100644 nixos/roles/home-assistant/default.nix create mode 100644 nixos/roles/home-assistant/warnwetter.patch diff --git a/nixos/machines/hera/configuration.nix b/nixos/machines/hera/configuration.nix index 21a10905..34cf2ae4 100644 --- a/nixos/machines/hera/configuration.nix +++ b/nixos/machines/hera/configuration.nix @@ -25,6 +25,7 @@ in ../../roles/laminar ../../roles/kassandra-server.nix ../../roles/foundryvtt.nix + ../../roles/home-assistant ./web.nix ./mail.nix ./boot.nix diff --git a/nixos/roles/home-assistant/default.nix b/nixos/roles/home-assistant/default.nix new file mode 100644 index 00000000..7c95fca8 --- /dev/null +++ b/nixos/roles/home-assistant/default.nix @@ -0,0 +1,44 @@ +{ pkgs, ... }: +{ + services.home-assistant = { + enable = true; + package = pkgs.home-assistant.overrideAttrs (oldAttrs: { + doInstallCheck = false; + patches = (oldAttrs.patches or [ ]) ++ [ ./warnwetter.patch ]; + }); + config = { + default_config = { }; + weather = [{ + platform = "warnwetter"; + name = "DWD Darmstadt"; + station_id = "L886"; + }]; + http = { + use_x_forwarded_for = true; + trusted_proxies = [ "::1" ]; + }; + }; + lovelaceConfig = { + views = [{ + cards = [ + { + type = "weather-forecast"; + entity = "weather.dwd_darmstadt"; + } + ]; + }]; + }; + }; + + services.nginx = { + virtualHosts."home.maralorn.de" = { + enableACME = true; + forceSSL = true; + locations."/" = { + proxyPass = "http://[::1]:8123"; + proxyWebsockets = true; + }; + }; + }; + +} diff --git a/nixos/roles/home-assistant/warnwetter.patch b/nixos/roles/home-assistant/warnwetter.patch new file mode 100644 index 00000000..b8387863 --- /dev/null +++ b/nixos/roles/home-assistant/warnwetter.patch @@ -0,0 +1,481 @@ +From 92f85569b2ec8beabfb7ea6aea462fba5e5af39d Mon Sep 17 00:00:00 2001 +From: Martin Weinelt +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 +