├── .github └── workflows │ ├── hacs.yml │ └── hassfest.yml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── dwd │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── manifest.json │ ├── stations.json │ ├── strings.json │ ├── translations │ ├── de.json │ └── en.json │ └── weather.py ├── hacs.json ├── images ├── screenshot_bramkragten-weather-card.png ├── screenshot_entities.png ├── screenshot_entity.png ├── screenshot_hacs_add-repository.png ├── screenshot_installation-folder.png ├── screenshot_mlamberts78-weather-chart-card.png ├── screenshot_search-integration.png ├── screenshot_service.png ├── screenshot_weather-forecast-card-configuration.png └── screenshot_weather-forecast.png ├── migration.md ├── questions_and_answers.md ├── setup.md ├── stations.md └── tools └── generate_stations └── generate_stations.py /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /custom_components/dwd/__pycache__ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Release](https://img.shields.io/github/v/release/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/releases) [![Hassfest Workflow Status](https://img.shields.io/github/actions/workflow/status/hg1337/homeassistant-dwd/hassfest.yml?label=Hassfest&style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/actions/workflows/hassfest.yml) [![License](https://img.shields.io/github/license/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/blob/main/LICENSE) [![Donation](https://img.shields.io/badge/Donation-Buy%20me%20a%20coffee-ffd557?style=for-the-badge)](https://www.buymeacoffee.com/hg1337) 2 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 3 | 4 | # Deutscher Wetterdienst (DWD) Integration for Home Assistant 5 | 6 | ![Screenshot Weather Forecast](./images/screenshot_weather-forecast.png) 7 | 8 | - [Introduction](#introduction) 9 | - [Main Features](#main-features) 10 | - [Quick Setup](#quick-setup) 11 | - [Questions & Answers](#questions--answers) 12 | - [References](#references) 13 | 14 | ## Introduction 15 | This custom component for [Home Assistant](https://www.home-assistant.io/) integrates weather data (measurements and forecasts) from the [Deutscher Wetterdienst Open Data](https://www.dwd.de/DE/leistungen/opendata/opendata.html) server into Home Assistant via weather entities. 16 | 17 | ### Legal Information 18 | 19 | **Deutscher Wetterdienst (DWD) is not affiliated in any way with this project.** 20 | 21 | The conditions from Deutscher Wetterdienst (DWD) for using their data and accessing their servers apply. 22 | - https://www.dwd.de/EN/service/copyright/copyright_artikel.html 23 | - https://opendata.dwd.de/README.txt 24 | 25 | [stations.md](stations.md) and [custom_components/dwd/stations.json](custom_components/dwd/stations.json) are generated from data from Deutscher Wetterdienst (DWD) with the Python script at [tools/generate_stations/generate_stations.py](tools/generate_stations/generate_stations.py). 26 | 27 | ## Main Features 28 | 29 | - Current measurement data from the weather stations from https://opendata.dwd.de/weather/weather_reports/poi/ as state attributes of a weather entity. 30 | - condition 31 | - temperature 32 | - dew_point 33 | - cloud_coverage 34 | - humidity 35 | - pressure 36 | - visibility 37 | - wind_gust_speed 38 | - wind_speed 39 | - wind_bearing 40 | - Hourly forecast data from the weather stations from https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/ in the forecast list of a weather entity. 41 | - datetime 42 | - condition 43 | - temperature 44 | - dew_point 45 | - cloud_coverage 46 | - precipitation 47 | - precipitation_probability 48 | - pressure 49 | - wind_gust_speed 50 | - wind_speed 51 | - wind_bearing 52 | - Daily forecast data calculated by the component from the hourly forecast data. This is the most tricky part. I have compared the result of this with what the official Warnwetter app displays and the results seems to be very close. 53 | - datetime 54 | - condition 55 | - temperature (the maximum temperature for the day) 56 | - templow (the minimum temperature for the day) 57 | - cloud_coverage (arithmetic average over the day) 58 | - precipitation (sum over the day) 59 | - pressure (arithmetic average over the day) 60 | - wind_gust_speed (maximum over the day) 61 | - wind_speed (arithmetic average over the day) 62 | - Uses the [HTTP ETag](https://en.wikipedia.org/wiki/HTTP_ETag) mechanism to only download new data if the data has changed. This allows more frequent polling (currently about every 10 minutes) while still keeping the load low. 63 | - Configuration via UI 64 | 65 | ![Screenshot Entity](./images/screenshot_entity.png) 66 | 67 | ## Quick Setup 68 | 69 | This quick setup guide is based on [My Home Assistant](https://my.home-assistant.io) links and the [Home Assistant Community Store (HACS)](https://hacs.xyz). For more details and other setup methods, see [setup.md](setup.md). 70 | 71 | As this integration is currently not part of Home Assistant Core, you have to download it first into your Home Assistant installation. To download it via HACS, click the following button to open the download page for this integration in HACS. 72 | 73 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) 74 | 75 | After a restart of Home Assistant, this integration is configurable by via "Add Integration" at "Devices & Services" like any core integration. Select "Deutscher Wetterdienst" and follow the instructions. 76 | 77 | ![Screenshot Search Integration](./images/screenshot_search-integration.png) 78 | 79 | To get there in one click, use this button: 80 | 81 | [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 82 | 83 | This adds one device and three entities for the selected station. By default, only the entity that provides all forecasts in one entity is enabled. If you still need the deprecated weather entities with daily and houry forecasts separately or via the old mechanism, you can still enable them for now, but you should really switch to the new entity now. To add more stations, just repeat the "Add Integration" step. 84 | 85 | ## Questions & Answers 86 | 87 | If you have questions, they might already be answered at [questions_and_answers.md](./questions_and_answers.md). 88 | 89 | ## References 90 | Unfortunately, most of the following documentation is only available in German. 91 | ### General 92 | - [Deutscher Wetterdienst Open Data.](https://www.dwd.de/DE/leistungen/opendata/opendata.html) 93 | - [List of documents related to Deutscher Wetterdienst Open Data](https://www.dwd.de/DE/leistungen/opendata/hilfe.html?nn=16102&lsbId=625220), e.g. documents that describe the various file formats. The most relevant ones used during development of this component are listed below. 94 | ### Measurements 95 | - [Description of the codes in the present_weather column of the weather reports.](https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/csv/poi_present_weather_zuordnung_pdf.pdf) 96 | ### Forecasts 97 | - [Explanation of the elements used in the MOSMIX forecast KML files.](https://opendata.dwd.de/weather/lib/MetElementDefinition.xml) 98 | - [Explanation of the weather codes (ww, ww3, WPc11, WPc31, WPc61, WPcd1, WPch1 and W1W2) used in the MOSMIX forecast KML files.](https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/kml/mosmix_element_weather_xls.xlsx) 99 | - [Binary Codes (BUFR).](https://www.dwd.de/DE/leistungen/pbfb_verlag_vub/pdf_einzelbaende/vub_2_binaer_barrierefrei.pdf) Actually, this should not be so much relevant, because everything should be covered by the previous documents, but there is some interesting overlap with the table "Aktuelles Wetter" on page 229 of this document. 100 | - [General explanation of forecast symbols.](https://www.dwd.de/DE/fachnutzer/landwirtschaft/dokumentationen/agrowetter/VHS_Elemente_Wettersymbole.pdf) This doesn't explain the data format, but it is still quite interesting, because it shows the relations of the symbols to other data like cloud coverage and precipitation. 101 | ### Station Lists 102 | For more information about stations see also [stations.md](stations.md). 103 | - [General station list](https://rcc.dwd.de/DE/leistungen/klimadatendeutschland/stationsliste.html) 104 | - [Station list with stations providing MOSMIX forecasts.](https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg) 105 | -------------------------------------------------------------------------------- /custom_components/dwd/__init__.py: -------------------------------------------------------------------------------- 1 | """The DWD component.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import Platform 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers import config_validation as cv 11 | 12 | from .const import DOMAIN 13 | from .coordinator import DwdDataUpdateCoordinator 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 18 | PLATFORMS = [Platform.WEATHER] 19 | 20 | 21 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 22 | """Set up DWD as config entry.""" 23 | 24 | config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) 25 | 26 | coordinator = DwdDataUpdateCoordinator(hass, config_entry) 27 | await coordinator.async_config_entry_first_refresh() 28 | 29 | config_entry.runtime_data = coordinator 30 | 31 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 32 | return True 33 | 34 | 35 | async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 36 | """Handle options update.""" 37 | 38 | await hass.config_entries.async_reload(config_entry.entry_id) 39 | 40 | 41 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 42 | """Unload a config entry.""" 43 | return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 44 | -------------------------------------------------------------------------------- /custom_components/dwd/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure DWD component.""" 2 | 3 | from __future__ import annotations 4 | 5 | from itertools import chain, islice 6 | import json 7 | import os 8 | from typing import Any 9 | 10 | from aiohttp import ClientSession 11 | import voluptuous as vol 12 | 13 | from homeassistant import config_entries 14 | from homeassistant.const import CONF_NAME, UnitOfLength 15 | from homeassistant.core import callback 16 | from homeassistant.data_entry_flow import FlowResult 17 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 18 | from homeassistant.helpers.selector import selector 19 | from homeassistant.util.unit_conversion import DistanceConverter 20 | 21 | from .const import ( 22 | CONF_CURRENT_WEATHER, 23 | CONF_CURRENT_WEATHER_DEFAULT, 24 | CONF_CURRENT_WEATHER_FORECAST, 25 | CONF_CURRENT_WEATHER_HYBRID, 26 | CONF_CURRENT_WEATHER_MEASUREMENT, 27 | CONF_FORECAST, 28 | CONF_FORECAST_DEFAULT, 29 | CONF_STATION_ID, 30 | DOMAIN, 31 | DWD_FORECAST, 32 | DWD_MEASUREMENT, 33 | SOURCE_STATIONSLEXIKON, 34 | URL_FORECAST, 35 | URL_MEASUREMENT, 36 | ) 37 | 38 | # Translation workaround until there is someting better offered by Home Assistant 39 | STRING_NO_MEASUREMENT = {"en": "[no measurement data]", "de": "[keine Messdaten]"} 40 | STRING_NO_FORECAST = {"en": "[no forecast data]", "de": "[keine Vorhersagedaten]"} 41 | 42 | 43 | class DwdFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 44 | """Config flow for DWD component.""" 45 | 46 | VERSION = 1 47 | 48 | def __init__(self) -> None: 49 | """Initialize the DWD flow.""" 50 | self._name = None 51 | self._station_id = None 52 | self._available_data = None 53 | self._current_weather = None 54 | self._forecast = None 55 | self._show_all = False 56 | 57 | def _get_translation(self, translations: dict[str, str]) -> str: 58 | return translations.get(self.hass.config.language, translations["en"]) 59 | 60 | async def async_step_user( 61 | self, user_input: dict[str, Any] | None = None 62 | ) -> FlowResult: 63 | """Handle a flow initialized by the user.""" 64 | 65 | errors = {} 66 | 67 | self._station_id = None 68 | 69 | if user_input is not None: 70 | self._station_id = user_input[CONF_STATION_ID] 71 | 72 | if self._station_id == "nostation_custom": 73 | return await self.async_step_manual() 74 | 75 | elif self._station_id == "nostation_load_all": 76 | self._show_all = True 77 | return await self.async_step_user() 78 | 79 | else: 80 | await self.async_set_unique_id(self._station_id) 81 | self._abort_if_unique_id_configured() 82 | 83 | if not errors: 84 | self._available_data = await _get_available_data( 85 | self._station_id, async_get_clientsession(self.hass) 86 | ) 87 | if len(self._available_data) == 0: 88 | errors[CONF_STATION_ID] = "no_data" 89 | 90 | if not errors: 91 | self._name = await self.hass.async_add_executor_job( 92 | DwdFlowHandler._get_station_name, self._station_id 93 | ) 94 | if self._name is None: 95 | errors[CONF_STATION_ID] = "no_station_name" 96 | 97 | if not errors: 98 | return await self.async_step_name() 99 | 100 | stations = list( 101 | await self.hass.async_add_executor_job(self._get_nearest_stations) 102 | ) 103 | 104 | station_options = chain( 105 | [ 106 | { 107 | "label": "", 108 | "value": "nostation_custom", 109 | } 110 | ], 111 | ( 112 | { 113 | # Elevation is always in m in Home Assistant 114 | "label": f'{x["name"]} ({"" if x["source"] == SOURCE_STATIONSLEXIKON else "~ "}{x["distance"]:.0f} {self.hass.config.units.length_unit}, {x["altitude_delta"]:+.0f} m) {self._get_translation(STRING_NO_MEASUREMENT) if not x["measurement"] else self._get_translation(STRING_NO_FORECAST) if not x["forecast"] else ""}', 115 | "value": x["id"], 116 | } 117 | for x in stations 118 | ), 119 | ) 120 | 121 | if not self._show_all: 122 | station_options = chain( 123 | station_options, 124 | [ 125 | { 126 | "label": "", 127 | "value": "nostation_load_all", 128 | } 129 | ], 130 | ) 131 | 132 | suggested_station = next( 133 | ( 134 | x 135 | for x in stations 136 | if x["measurement"] and x["forecast"] and abs(x["altitude_delta"]) < 500 137 | ), 138 | None, 139 | ) 140 | if ( 141 | suggested_station is None 142 | or DistanceConverter.convert( 143 | suggested_station["distance"], 144 | self.hass.config.units.length_unit, 145 | UnitOfLength.KILOMETERS, 146 | ) 147 | > 20 148 | ): 149 | suggested_station = stations[0] 150 | 151 | schema = vol.Schema( 152 | { 153 | vol.Required( 154 | CONF_STATION_ID, 155 | description={"suggested_value": suggested_station["id"]}, 156 | ): selector( 157 | { 158 | "select": { 159 | "options": list(station_options), 160 | "custom_value": False, 161 | "mode": "dropdown", 162 | "translation_key": "station", 163 | } 164 | } 165 | ) 166 | } 167 | ) 168 | 169 | return self.async_show_form( 170 | step_id="user", data_schema=schema, errors=errors, last_step=False 171 | ) 172 | 173 | async def async_step_name( 174 | self, user_input: dict[str, Any] | None = None 175 | ) -> FlowResult: 176 | """Handle the step to rename the station after selecting one from the list.""" 177 | 178 | if user_input is not None: 179 | self._name = user_input[CONF_NAME] 180 | return await self.async_step_options() 181 | 182 | schema = vol.Schema( 183 | { 184 | vol.Required(CONF_NAME, default=self._name): str, 185 | } 186 | ) 187 | 188 | return self.async_show_form(step_id="name", data_schema=schema, last_step=False) 189 | 190 | async def async_step_manual( 191 | self, user_input: dict[str, Any] | None = None 192 | ) -> FlowResult: 193 | """Handle the step for manual station selection.""" 194 | 195 | errors = {} 196 | 197 | self._name = None 198 | self._station_id = None 199 | 200 | if user_input is not None: 201 | self._name = user_input[CONF_NAME] 202 | self._station_id = user_input[CONF_STATION_ID] 203 | 204 | await self.async_set_unique_id(self._station_id) 205 | self._abort_if_unique_id_configured() 206 | 207 | if not errors: 208 | self._available_data = await _get_available_data( 209 | self._station_id, async_get_clientsession(self.hass) 210 | ) 211 | if len(self._available_data) == 0: 212 | errors[CONF_STATION_ID] = "no_data" 213 | 214 | if not errors: 215 | return await self.async_step_options() 216 | 217 | schema = vol.Schema( 218 | { 219 | vol.Required( 220 | CONF_NAME, description={"suggested_value": self._name} 221 | ): str, 222 | vol.Required( 223 | CONF_STATION_ID, description={"suggested_value": self._station_id} 224 | ): str, 225 | } 226 | ) 227 | 228 | return self.async_show_form( 229 | step_id="manual", data_schema=schema, errors=errors, last_step=False 230 | ) 231 | 232 | async def async_step_options( 233 | self, user_input: dict[str, Any] | None = None 234 | ) -> FlowResult: 235 | """Handle the step for manual station selection.""" 236 | return await self._async_step_options(user_input) 237 | 238 | async def async_step_options_no_measurement( 239 | self, user_input: dict[str, Any] | None = None 240 | ) -> FlowResult: 241 | """Handle the step for manual station selection.""" 242 | return await self._async_step_options(user_input) 243 | 244 | async def async_step_options_no_forecast( 245 | self, user_input: dict[str, Any] | None = None 246 | ) -> FlowResult: 247 | """Handle the step for manual station selection.""" 248 | return await self._async_step_options(user_input) 249 | 250 | async def _async_step_options( 251 | self, user_input: dict[str, Any] | None 252 | ) -> FlowResult: 253 | """Handle the step for manual station selection.""" 254 | 255 | self._current_weather = None 256 | self._forecast = None 257 | 258 | if user_input is not None: 259 | # CONF_CURRENT_WEATHER is always set from the UI. 260 | self._current_weather = user_input[CONF_CURRENT_WEATHER] 261 | # CONF_FORECAST is not configurable in the UI if no forecast is 262 | # available and has to default to False in this case. 263 | self._forecast = user_input.get(CONF_FORECAST, False) 264 | 265 | return self.async_create_entry( 266 | title=self._name, 267 | data={CONF_STATION_ID: self._station_id}, 268 | options={ 269 | CONF_CURRENT_WEATHER: self._current_weather, 270 | CONF_FORECAST: self._forecast, 271 | }, 272 | ) 273 | else: 274 | self._current_weather = CONF_CURRENT_WEATHER_DEFAULT 275 | self._forecast = CONF_FORECAST_DEFAULT 276 | 277 | schema = _create_schema( 278 | self._available_data, 279 | self._current_weather, 280 | self._forecast, 281 | self.hass.config.language, 282 | ) 283 | 284 | if ( 285 | DWD_MEASUREMENT not in self._available_data 286 | and DWD_FORECAST in self._available_data 287 | ): 288 | return self.async_show_form( 289 | step_id="options_no_measurement", data_schema=schema, last_step=True 290 | ) 291 | elif ( 292 | DWD_MEASUREMENT in self._available_data 293 | and DWD_FORECAST not in self._available_data 294 | ): 295 | return self.async_show_form( 296 | step_id="options_no_forecast", data_schema=schema, last_step=True 297 | ) 298 | else: 299 | return self.async_show_form( 300 | step_id="options", data_schema=schema, last_step=True 301 | ) 302 | 303 | @staticmethod 304 | @callback 305 | def async_get_options_flow( 306 | config_entry: config_entries.ConfigEntry, 307 | ) -> config_entries.OptionsFlow: 308 | """Create the options flow.""" 309 | return DwdOptionsFlowHandler(config_entry) 310 | 311 | def _get_nearest_stations(self): 312 | with open( 313 | os.path.join(os.path.dirname(os.path.realpath(__file__)), "stations.json"), 314 | encoding="utf-8", 315 | ) as file: 316 | stations = json.load(file) 317 | 318 | for station in stations: 319 | station["distance"] = self.hass.config.distance( 320 | station["latitude"], station["longitude"] 321 | ) 322 | # The elevation is always in m in Home Assistant same as the station altitude! 323 | station["altitude_delta"] = ( 324 | station["altitude"] - self.hass.config.elevation 325 | ) 326 | 327 | sorted_startions = sorted(stations, key=lambda x: x["distance"]) 328 | 329 | if self._show_all: 330 | return sorted_startions 331 | else: 332 | return islice(sorted_startions, 100) 333 | 334 | @staticmethod 335 | def _get_station_name(station_id: str): 336 | with open( 337 | os.path.join(os.path.dirname(os.path.realpath(__file__)), "stations.json"), 338 | encoding="utf-8", 339 | ) as file: 340 | stations = json.load(file) 341 | 342 | for station in stations: 343 | if station["id"] == station_id: 344 | return station["name"] 345 | 346 | return None 347 | 348 | 349 | class DwdOptionsFlowHandler(config_entries.OptionsFlow): 350 | """Options flow for DWD component.""" 351 | 352 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 353 | """Initialize options flow.""" 354 | self.config_entry = config_entry 355 | self._available_data = None 356 | 357 | async def async_step_init( 358 | self, user_input: dict[str, Any] | None = None 359 | ) -> FlowResult: 360 | """Manage the options.""" 361 | return await self._async_step_init(user_input) 362 | 363 | async def async_step_init_no_measurement( 364 | self, user_input: dict[str, Any] | None = None 365 | ) -> FlowResult: 366 | """Manage the options.""" 367 | return await self._async_step_init(user_input) 368 | 369 | async def async_step_init_no_forecast( 370 | self, user_input: dict[str, Any] | None = None 371 | ) -> FlowResult: 372 | """Manage the options.""" 373 | return await self._async_step_init(user_input) 374 | 375 | async def _async_step_init(self, user_input: dict[str, Any] | None) -> FlowResult: 376 | if user_input is not None: 377 | return self.async_create_entry( 378 | data={ 379 | # CONF_CURRENT_WEATHER is always set from the UI. 380 | CONF_CURRENT_WEATHER: user_input[CONF_CURRENT_WEATHER], 381 | # CONF_FORECAST is not configurable in the UI if no forecast is 382 | # available and has to default to False in this case. 383 | CONF_FORECAST: user_input.get(CONF_FORECAST, False), 384 | } 385 | ) 386 | 387 | available_data = await _get_available_data( 388 | self.config_entry.data[CONF_STATION_ID], async_get_clientsession(self.hass) 389 | ) 390 | 391 | schema = _create_schema( 392 | available_data, 393 | self.config_entry.options.get( 394 | CONF_CURRENT_WEATHER, CONF_CURRENT_WEATHER_DEFAULT 395 | ), 396 | self.config_entry.options.get(CONF_FORECAST, CONF_FORECAST_DEFAULT), 397 | self.hass.config.language, 398 | ) 399 | 400 | if DWD_MEASUREMENT not in available_data and DWD_FORECAST in available_data: 401 | return self.async_show_form( 402 | step_id="init_no_measurement", data_schema=schema 403 | ) 404 | elif DWD_MEASUREMENT in available_data and DWD_FORECAST not in available_data: 405 | return self.async_show_form(step_id="init_no_forecast", data_schema=schema) 406 | else: 407 | return self.async_show_form(step_id="init", data_schema=schema) 408 | 409 | 410 | def _create_schema( 411 | available_data: list, 412 | suggested_current_weather: str, 413 | suggested_forecast: bool, 414 | language: str, 415 | ) -> vol.Schema: 416 | selector_dict = { 417 | "select": { 418 | "options": [], 419 | "custom_value": False, 420 | "mode": "list", 421 | "translation_key": "current_weather", 422 | } 423 | } 424 | 425 | if DWD_MEASUREMENT in available_data: 426 | selector_dict["select"]["options"].append( 427 | { 428 | "label": "", 429 | "value": CONF_CURRENT_WEATHER_MEASUREMENT, 430 | } 431 | ) 432 | 433 | if DWD_MEASUREMENT in available_data and DWD_FORECAST in available_data: 434 | selector_dict["select"]["options"].append( 435 | { 436 | "label": "", 437 | "value": CONF_CURRENT_WEATHER_HYBRID, 438 | }, 439 | ) 440 | 441 | if DWD_FORECAST in available_data: 442 | selector_dict["select"]["options"].append( 443 | { 444 | "label": "", 445 | "value": CONF_CURRENT_WEATHER_FORECAST, 446 | }, 447 | ) 448 | 449 | # Overwrite suggested values if some data is not available 450 | if DWD_MEASUREMENT in available_data and DWD_FORECAST not in available_data: 451 | suggested_current_weather = CONF_CURRENT_WEATHER_MEASUREMENT 452 | suggested_forecast = None 453 | elif DWD_MEASUREMENT not in available_data and DWD_FORECAST in available_data: 454 | suggested_current_weather = CONF_CURRENT_WEATHER_FORECAST 455 | elif DWD_MEASUREMENT not in available_data and DWD_FORECAST not in available_data: 456 | suggested_current_weather = None 457 | suggested_forecast = None 458 | 459 | schema_dict = {} 460 | if DWD_MEASUREMENT in available_data or DWD_FORECAST in available_data: 461 | schema_dict[ 462 | vol.Required( 463 | CONF_CURRENT_WEATHER, 464 | description={"suggested_value": suggested_current_weather}, 465 | ) 466 | ] = selector(selector_dict) 467 | if DWD_FORECAST in available_data: 468 | schema_dict[ 469 | vol.Required( 470 | CONF_FORECAST, 471 | description={"suggested_value": suggested_forecast}, 472 | ) 473 | ] = bool 474 | 475 | return vol.Schema(schema_dict) 476 | 477 | 478 | async def _get_available_data( 479 | station_id: str, clientsession: ClientSession 480 | ) -> list[str]: 481 | result = [] 482 | 483 | response = await clientsession.head(URL_MEASUREMENT.format(station_id=station_id)) 484 | if response.status >= 200 and response.status <= 299: 485 | result.append(DWD_MEASUREMENT) 486 | 487 | response = await clientsession.head(URL_FORECAST.format(station_id=station_id)) 488 | if response.status >= 200 and response.status <= 299: 489 | result.append(DWD_FORECAST) 490 | 491 | return result 492 | -------------------------------------------------------------------------------- /custom_components/dwd/const.py: -------------------------------------------------------------------------------- 1 | """Constants for DWD component.""" 2 | 3 | from datetime import timedelta 4 | import logging 5 | 6 | from homeassistant.components.weather import ( 7 | ATTR_CONDITION_CLOUDY, 8 | ATTR_CONDITION_FOG, 9 | ATTR_CONDITION_HAIL, 10 | ATTR_CONDITION_LIGHTNING, 11 | ATTR_CONDITION_LIGHTNING_RAINY, 12 | ATTR_CONDITION_PARTLYCLOUDY, 13 | ATTR_CONDITION_POURING, 14 | ATTR_CONDITION_RAINY, 15 | ATTR_CONDITION_SNOWY, 16 | ATTR_CONDITION_SNOWY_RAINY, 17 | ATTR_CONDITION_SUNNY, 18 | ATTR_CONDITION_WINDY, 19 | ) 20 | 21 | DOMAIN = "dwd" 22 | 23 | ATTRIBUTION = "Quelle: Deutscher Wetterdienst" 24 | 25 | CONF_STATION_ID = "station_id" 26 | CONF_CURRENT_WEATHER = "current_weather" 27 | CONF_CURRENT_WEATHER_MEASUREMENT = "measurement" 28 | CONF_CURRENT_WEATHER_FORECAST = "forecast" 29 | CONF_CURRENT_WEATHER_HYBRID = "hybrid" 30 | CONF_CURRENT_WEATHER_DEFAULT = CONF_CURRENT_WEATHER_MEASUREMENT 31 | CONF_FORECAST = "forecast" 32 | CONF_FORECAST_DEFAULT = True 33 | 34 | URL_MEASUREMENT = ( 35 | "https://opendata.dwd.de/weather/weather_reports/poi/{station_id:_<5}-BEOB.csv" 36 | ) 37 | URL_FORECAST = "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{station_id}/kml/MOSMIX_L_LATEST_{station_id}.kmz" 38 | 39 | UPDATE_INTERVAL = timedelta(seconds=610) 40 | 41 | CONDITION_PARTLYCLOUDY_THRESHOLD = 25 42 | CONDITION_CLOUDY_THRESHOLD = 75 43 | 44 | MEASUREMENTS_MAX_AGE = 3 45 | 46 | DWD_MEASUREMENT = 0 47 | DWD_FORECAST = 1 48 | 49 | DWD_MEASUREMENT_DATETIME = "datetime" 50 | 51 | # Mapping see https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/csv/poi_present_weather_zuordnung_pdf.pdf (German) 52 | CONDITIONS_MAP = { 53 | 1: ATTR_CONDITION_SUNNY, 54 | 2: ATTR_CONDITION_PARTLYCLOUDY, 55 | 3: ATTR_CONDITION_PARTLYCLOUDY, 56 | 4: ATTR_CONDITION_CLOUDY, 57 | 5: ATTR_CONDITION_FOG, 58 | 6: ATTR_CONDITION_FOG, 59 | 7: ATTR_CONDITION_RAINY, 60 | 8: ATTR_CONDITION_RAINY, 61 | 9: ATTR_CONDITION_POURING, 62 | 10: ATTR_CONDITION_SNOWY_RAINY, 63 | 11: ATTR_CONDITION_SNOWY_RAINY, 64 | 12: ATTR_CONDITION_SNOWY_RAINY, 65 | 13: ATTR_CONDITION_SNOWY_RAINY, 66 | 14: ATTR_CONDITION_SNOWY, 67 | 15: ATTR_CONDITION_SNOWY, 68 | 16: ATTR_CONDITION_SNOWY, 69 | 17: ATTR_CONDITION_HAIL, 70 | 18: ATTR_CONDITION_RAINY, 71 | 19: ATTR_CONDITION_POURING, 72 | 20: ATTR_CONDITION_SNOWY_RAINY, 73 | 21: ATTR_CONDITION_SNOWY_RAINY, 74 | 22: ATTR_CONDITION_SNOWY, 75 | 23: ATTR_CONDITION_SNOWY, 76 | 24: ATTR_CONDITION_SNOWY, 77 | 25: ATTR_CONDITION_SNOWY, 78 | 26: ATTR_CONDITION_LIGHTNING, 79 | 27: ATTR_CONDITION_LIGHTNING_RAINY, 80 | 28: ATTR_CONDITION_LIGHTNING_RAINY, 81 | 29: ATTR_CONDITION_LIGHTNING_RAINY, 82 | 30: ATTR_CONDITION_LIGHTNING_RAINY, 83 | 31: ATTR_CONDITION_WINDY, 84 | } 85 | 86 | DWD_MEASUREMENT_PRESENT_WEATHER = "present_weather" 87 | DWD_MEASUREMENT_TEMPERATURE = "dry_bulb_temperature_at_2_meter_above_ground" 88 | DWD_MEASUREMENT_PRESSURE = "pressure_reduced_to_mean_sea_level" 89 | DWD_MEASUREMENT_HUMIDITY = "relative_humidity" 90 | DWD_MEASUREMENT_VISIBILITY = "horizontal_visibility" 91 | DWD_MEASUREMENT_MAXIMUM_WIND_SPEED = "maximum_wind_speed_last_hour" 92 | DWD_MEASUREMENT_MEANWIND_SPEED = ( 93 | "mean_wind_speed_during last_10_min_at_10_meters_above_ground" 94 | ) 95 | DWD_MEASUREMENT_MEANWIND_DIRECTION = ( 96 | "mean_wind_direction_during_last_10 min_at_10_meters_above_ground" 97 | ) 98 | DWD_MEASUREMENT_CLOUD_COVER_TOTAL = "cloud_cover_total" 99 | DWD_MEASUREMENT_DEW_POINT = "dew_point_temperature_at_2_meter_above_ground" 100 | 101 | DWD_FORECAST_TIMESTAMP = "timestamp" 102 | 103 | SOURCE_STATIONSLEXIKON = 0 104 | SOURCE_MOSMIX_STATIONSKATALOG = 1 105 | 106 | _LOGGER = logging.getLogger(".") 107 | -------------------------------------------------------------------------------- /custom_components/dwd/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for DWD integration.""" 2 | 3 | import codecs 4 | from datetime import UTC, datetime 5 | from io import BytesIO 6 | import logging 7 | import zipfile 8 | 9 | from aiohttp import ClientSession 10 | from defusedxml import ElementTree 11 | 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import ( 18 | CONF_CURRENT_WEATHER, 19 | CONF_CURRENT_WEATHER_DEFAULT, 20 | CONF_CURRENT_WEATHER_FORECAST, 21 | CONF_CURRENT_WEATHER_HYBRID, 22 | CONF_CURRENT_WEATHER_MEASUREMENT, 23 | CONF_FORECAST, 24 | CONF_FORECAST_DEFAULT, 25 | CONF_STATION_ID, 26 | DOMAIN, 27 | DWD_FORECAST, 28 | DWD_FORECAST_TIMESTAMP, 29 | DWD_MEASUREMENT, 30 | DWD_MEASUREMENT_DATETIME, 31 | MEASUREMENTS_MAX_AGE, 32 | UPDATE_INTERVAL, 33 | URL_FORECAST, 34 | URL_MEASUREMENT, 35 | ) 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | class DwdDataUpdateCoordinator(DataUpdateCoordinator): 41 | """Class to manage fetching DWD data.""" 42 | 43 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 44 | """Initialize global DWD data updater.""" 45 | 46 | self._config_entry: ConfigEntry = config_entry 47 | self._clientsession: ClientSession = async_get_clientsession(hass) 48 | 49 | self._last_measurement: dict | None = None 50 | self._last_forecast: dict | None = None 51 | self._last_measurement_etag: str | None = None 52 | self._last_forecast_etag: str | None = None 53 | 54 | _LOGGER.debug( 55 | "Checking for new data for %s (%s) every %s", 56 | self._config_entry.title, 57 | self._config_entry.data.get(CONF_STATION_ID, None), 58 | UPDATE_INTERVAL, 59 | ) 60 | 61 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) 62 | 63 | async def _async_update_data(self) -> dict: 64 | """Fetch data from DWD.""" 65 | 66 | try: 67 | conf_current_weather = self._config_entry.options.get( 68 | CONF_CURRENT_WEATHER, CONF_CURRENT_WEATHER_DEFAULT 69 | ) 70 | conf_forecast = self._config_entry.options.get( 71 | CONF_FORECAST, CONF_FORECAST_DEFAULT 72 | ) 73 | 74 | if conf_current_weather in ( 75 | CONF_CURRENT_WEATHER_MEASUREMENT, 76 | CONF_CURRENT_WEATHER_HYBRID, 77 | ): 78 | # Fetch measurement, if new data is available (using ETag header). 79 | 80 | url = URL_MEASUREMENT.format( 81 | station_id=self._config_entry.data[CONF_STATION_ID] 82 | ) 83 | headers = {} 84 | if self._last_measurement_etag is not None: 85 | headers["If-None-Match"] = self._last_measurement_etag 86 | response = await self._clientsession.get(url, headers=headers) 87 | 88 | if response.status == 304: 89 | _LOGGER.debug("No new data from %s", url) 90 | 91 | elif 200 <= response.status <= 299: 92 | measurement = {} 93 | measurement_etag = response.headers.get("ETag", None) 94 | 95 | data = response.content 96 | 97 | # Read column names: 98 | line = codecs.decode(await data.readline()).strip() 99 | column_names = line.split(";") 100 | # Skip 2 additional descriptive header rows 101 | await data.readline() 102 | await data.readline() 103 | # Read actual measurement values into target dictionary 104 | # Some stations set some values only every few hours, so we go a few rows 105 | # down (up to MEASUREMENTS_MAX_AGE) to collect all values. 106 | raw_line = await data.readline() 107 | age = 0 108 | while age < MEASUREMENTS_MAX_AGE and raw_line: 109 | line = codecs.decode(raw_line).strip() 110 | fields = line.split(";") 111 | measurement.setdefault( 112 | DWD_MEASUREMENT_DATETIME, 113 | datetime.strptime( 114 | f"{fields[0]} {fields[1]}", r"%d.%m.%y %H:%M" 115 | ).replace(tzinfo=UTC), 116 | ) 117 | for i in range(2, min(len(column_names), len(fields))): 118 | if fields[i] and fields[i] != "---": 119 | measurement.setdefault(column_names[i], fields[i]) 120 | raw_line = await data.readline() 121 | age += 1 122 | 123 | self._last_measurement = measurement 124 | self._last_measurement_etag = measurement_etag 125 | _LOGGER.debug( 126 | "Measurement successfully fetched from %s. ETag: %s", 127 | url, 128 | self._last_measurement_etag, 129 | ) 130 | 131 | else: 132 | raise UpdateFailed( 133 | f"Unexpected status code {response.status} from {url}." 134 | ) 135 | 136 | else: 137 | _LOGGER.debug( 138 | "Not fetching measurement data because current_weather is %s", 139 | conf_current_weather, 140 | ) 141 | 142 | if ( 143 | conf_current_weather 144 | in (CONF_CURRENT_WEATHER_HYBRID, CONF_CURRENT_WEATHER_FORECAST) 145 | or conf_forecast 146 | ): 147 | # Fetch forecast, if new data is available (using ETag header). 148 | 149 | url = URL_FORECAST.format( 150 | station_id=self._config_entry.data[CONF_STATION_ID] 151 | ) 152 | headers = {} 153 | if self._last_forecast_etag is not None: 154 | headers["If-None-Match"] = self._last_forecast_etag 155 | response = await self._clientsession.get(url, headers=headers) 156 | 157 | if response.status == 304: 158 | _LOGGER.debug("No new data from %s", url) 159 | 160 | elif 200 <= response.status <= 299: 161 | forecast = {} 162 | forecast_etag = response.headers.get("ETag", None) 163 | 164 | data = await response.read() 165 | 166 | with zipfile.ZipFile(BytesIO(data)) as dwd_zip_file: 167 | for kml_file_name in dwd_zip_file.namelist(): 168 | if kml_file_name.endswith(".kml"): 169 | with dwd_zip_file.open(kml_file_name) as kml_file: 170 | # For a description of all elements see https://opendata.dwd.de/weather/lib/MetElementDefinition.xml 171 | elementTree = ElementTree.parse(kml_file) 172 | timestamps = [ 173 | datetime.strptime( 174 | x.text, "%Y-%m-%dT%H:%M:%S.%f%z" 175 | ) 176 | for x in elementTree.findall( 177 | "./kml:Document/kml:ExtendedData/dwd:ProductDefinition/dwd:ForecastTimeSteps/dwd:TimeStep", 178 | { 179 | "kml": "http://www.opengis.net/kml/2.2", 180 | "dwd": "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd", 181 | }, 182 | ) 183 | ] 184 | forecast[DWD_FORECAST_TIMESTAMP] = timestamps 185 | forecastElements = elementTree.findall( 186 | "./kml:Document/kml:Placemark/kml:ExtendedData/dwd:Forecast", 187 | { 188 | "kml": "http://www.opengis.net/kml/2.2", 189 | "dwd": "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd", 190 | }, 191 | ) 192 | for forecastElement in forecastElements: 193 | name = forecastElement.attrib[ 194 | r"{https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd}elementName" 195 | ] 196 | values = forecastElement.find( 197 | "dwd:value", 198 | { 199 | "kml": "http://www.opengis.net/kml/2.2", 200 | "dwd": "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd", 201 | }, 202 | ).text.split() 203 | forecast[name] = values 204 | 205 | # There should only be on KML file in the KMZ archive so we don't handle multiple. 206 | # Don't even know what this would mean. ;) Anyway, would complicate things a bit. 207 | break 208 | 209 | self._last_forecast = forecast 210 | self._last_forecast_etag = forecast_etag 211 | _LOGGER.debug( 212 | "Forecast successfully fetched from %s. ETag: %s", 213 | url, 214 | self._last_forecast_etag, 215 | ) 216 | 217 | else: 218 | raise UpdateFailed( 219 | f"Unexpected status code {response.status} from {url}." 220 | ) 221 | else: 222 | _LOGGER.debug( 223 | "Not fetching forecast data because current_weather is %s and forecast is %s", 224 | conf_current_weather, 225 | conf_forecast, 226 | ) 227 | 228 | return { 229 | DWD_MEASUREMENT: self._last_measurement, 230 | DWD_FORECAST: self._last_forecast, 231 | } 232 | 233 | except Exception as err: 234 | raise UpdateFailed(err) from err 235 | -------------------------------------------------------------------------------- /custom_components/dwd/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "dwd", 3 | "name": "Deutscher Wetterdienst", 4 | "codeowners": [ 5 | "@hg1337" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/hg1337/homeassistant-dwd#readme", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/hg1337/homeassistant-dwd/issues", 11 | "requirements": [ 12 | "defusedxml==0.7.1" 13 | ], 14 | "version": "2025.5.0" 15 | } -------------------------------------------------------------------------------- /custom_components/dwd/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Station is already configured." 5 | }, 6 | "error": { 7 | "no_data": "Could get neither measurement nor forecast data for the selected station. Either you selected an invalid station or the station does not provide data.", 8 | "no_station_name": "Name of the selected station not found." 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "station_id": "Station" 14 | }, 15 | "description": "This integration fetches weather data from the Open Data server of Deutscher Wetterdienst. By using this integration, you agree to the terms at https://opendata.dwd.de/README.txt.\n\nBased on your Home Assistant configuration, a station was suggested below. Feel free to try out different ones until you find the best one for you. The distance and difference in elevation to your home location is displayed for each station.\n\nIf you want to configuare a station manually, select \"Custom...\"." 16 | }, 17 | "name": { 18 | "data": { 19 | "name": "Name" 20 | }, 21 | "description": "You can keep the default name of the station or change it here." 22 | }, 23 | "manual": { 24 | "data": { 25 | "station_id": "Station ID", 26 | "name": "Name" 27 | }, 28 | "description": "This integration fetches weather data from the Open Data server of Deutscher Wetterdienst. By using this integration, you agree to the terms at https://opendata.dwd.de/README.txt.\n\nFor a list of stations see https://github.com/hg1337/homeassistant-dwd/blob/main/stations.md." 29 | }, 30 | "options": { 31 | "data": { 32 | "current_weather": "Which data shall be used as the current weather?", 33 | "forecast": "Do you want to have forecast data (recommended)?" 34 | }, 35 | "description": "You can change this later in the configuration dialog." 36 | }, 37 | "options_no_measurement": { 38 | "data": { 39 | "current_weather": "Which data shall be used as the current weather?", 40 | "forecast": "Do you want to have forecast data (recommended)?" 41 | }, 42 | "description": "You can change this later in the configuration dialog.\n\nThis station does not provide measurement data, therefore your options are limited. You may still use this station, but real measurement data is usually better." 43 | }, 44 | "options_no_forecast": { 45 | "data": { 46 | "current_weather": "Which data shall be used as the current weather?", 47 | "forecast": "Do you want to have forecast data (recommended)?" 48 | }, 49 | "description": "You can change this later in the configuration dialog.\n\nThis station does not provide forecast data, therefore your options are limited." 50 | } 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "data": { 57 | "current_weather": "Which data shall be used as the current weather?", 58 | "forecast": "Do you want to have forecast data (recommended)?" 59 | } 60 | }, 61 | "init_no_measurement": { 62 | "data": { 63 | "current_weather": "Which data shall be used as the current weather?", 64 | "forecast": "Do you want to have forecast data (recommended)?" 65 | }, 66 | "description": "This station does not provide measurement data, therefore your options are limited." 67 | }, 68 | "init_no_forecast": { 69 | "data": { 70 | "current_weather": "Which data shall be used as the current weather?", 71 | "forecast": "Do you want to have forecast data (recommended)?" 72 | }, 73 | "description": "This station does not provide forecast data, therefore your options are limited." 74 | } 75 | } 76 | }, 77 | "selector": { 78 | "station": { 79 | "options": { 80 | "nostation_custom": "Custom...", 81 | "nostation_load_all": "Load all (might be slow)..." 82 | } 83 | }, 84 | "current_weather": { 85 | "options": { 86 | "measurement": "Measurement data only (recommended)", 87 | "hybrid": "Measurement data with forecast data for current hour as fallback for attributes where no measurement data is available", 88 | "forecast": "Forecast data only (recommended only for stations that do not provide measurement data at all)" 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /custom_components/dwd/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Wetterstation ist bereits konfiguriert." 5 | }, 6 | "error": { 7 | "no_data": "Weder Vorhersagen noch Messdaten konnten für die gewählte Wetterstation abgerufen werden. Entweder wurde eine ungültige Wetterstation ausgewählt oder die gewählte Wetterstation liefert keine Daten.", 8 | "no_station_name": "Name der gewählten Wetterstation nicht gefunden." 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "station_id": "Station" 14 | }, 15 | "description": "Diese Integration bezieht Wetterdaten vom Open Data Server des Deutschen Wetterdienstes. Wenn du diese Integration verwendest, stimmst du den Bedingungen unter https://opendata.dwd.de/README.txt zu.\n\nBasierend auf der aktuellen Home Assistant Konfiguration wurde unten eine Wetterstation vorgeschlagen. Probiere gerne verschiedene Wetterstationen aus, um die beste für dich zu finden. Entferung und Höhenunterscheid zu deinem Heimatort werden zu jeder Station angezeigt.\n\nFalls du eine Wetterstation manuell konfigurieren möchtest, wähle \"Benutzerdefiniert...\" aus." 16 | }, 17 | "name": { 18 | "data": { 19 | "name": "Name" 20 | }, 21 | "description": "Du kannst den Standardnamen der Wetterstation verwenden oder ihn hier ändern." 22 | }, 23 | "manual": { 24 | "data": { 25 | "station_id": "Stations-ID", 26 | "name": "Name" 27 | }, 28 | "description": "Diese Integration bezieht Wetterdaten vom Open Data Server des Deutschen Wetterdienstes. Wenn du diese Integration verwendest, stimmst du den Bedingungen unter https://opendata.dwd.de/README.txt zu.\n\nFür eine Liste der Wetterstationen siehe https://github.com/hg1337/homeassistant-dwd/blob/main/stations.md." 29 | }, 30 | "options": { 31 | "data": { 32 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 33 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 34 | }, 35 | "description": "Du kannst das später im Konfigurationsdialog ändern." 36 | }, 37 | "options_no_measurement": { 38 | "data": { 39 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 40 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 41 | }, 42 | "description": "Du kannst das später im Konfigurationsdialog ändern.\n\nDiese Station liefert keine Messdaten, daher sind deine Optionen eingeschränkt. Du kannst diese Station trotzdem verwenden, aber echte Messdaten sind gewöhnlich besser." 43 | }, 44 | "options_no_forecast": { 45 | "data": { 46 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 47 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 48 | }, 49 | "description": "Du kannst das später im Konfigurationsdialog ändern.\n\nDiese Station liefert keine Vorhersagen, daher sind deine Optionen eingeschränkt." 50 | } 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "data": { 57 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 58 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 59 | } 60 | }, 61 | "init_no_measurement": { 62 | "data": { 63 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 64 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 65 | }, 66 | "description": "Diese Station liefert keine Messdaten, daher sind deine Optionen eingeschränkt." 67 | }, 68 | "init_no_forecast": { 69 | "data": { 70 | "current_weather": "Welche Daten sollen als aktuelles Wetter verwendet werden?", 71 | "forecast": "Möchtest du Vorhersagen (empfohlen)?" 72 | }, 73 | "description": "Diese Station liefert keine Vorhersagen, daher sind deine Optionen eingeschränkt." 74 | } 75 | } 76 | }, 77 | "selector": { 78 | "station": { 79 | "options": { 80 | "nostation_custom": "Benutzerdefiniert...", 81 | "nostation_load_all": "Alle laden (könnte langsam sein)..." 82 | } 83 | }, 84 | "current_weather": { 85 | "options": { 86 | "measurement": "Nur Messdaten (empfohlen)", 87 | "hybrid": "Messdaten mit Vorhersagedaten für die aktuelle Stunde für Attribute für die keine Messdaten verfügbar sind", 88 | "forecast": "Nur Vorhersagedaten (nur für Stationen empfohlen, die überhaupt keine Messdaten liefern)" 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /custom_components/dwd/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Station is already configured." 5 | }, 6 | "error": { 7 | "no_data": "Could get neither measurement nor forecast data for the selected station. Either you selected an invalid station or the station does not provide data.", 8 | "no_station_name": "Name of the selected station not found." 9 | }, 10 | "step": { 11 | "user": { 12 | "data": { 13 | "station_id": "Station" 14 | }, 15 | "description": "This integration fetches weather data from the Open Data server of Deutscher Wetterdienst. By using this integration, you agree to the terms at https://opendata.dwd.de/README.txt.\n\nBased on your Home Assistant configuration, a station was suggested below. Feel free to try out different ones until you find the best one for you. The distance and difference in elevation to your home location is displayed for each station.\n\nIf you want to configuare a station manually, select \"Custom...\"." 16 | }, 17 | "name": { 18 | "data": { 19 | "name": "Name" 20 | }, 21 | "description": "You can keep the default name of the station or change it here." 22 | }, 23 | "manual": { 24 | "data": { 25 | "station_id": "Station ID", 26 | "name": "Name" 27 | }, 28 | "description": "This integration fetches weather data from the Open Data server of Deutscher Wetterdienst. By using this integration, you agree to the terms at https://opendata.dwd.de/README.txt.\n\nFor a list of stations see https://github.com/hg1337/homeassistant-dwd/blob/main/stations.md." 29 | }, 30 | "options": { 31 | "data": { 32 | "current_weather": "Which data shall be used as the current weather?", 33 | "forecast": "Do you want to have forecast data (recommended)?" 34 | }, 35 | "description": "You can change this later in the configuration dialog." 36 | }, 37 | "options_no_measurement": { 38 | "data": { 39 | "current_weather": "Which data shall be used as the current weather?", 40 | "forecast": "Do you want to have forecast data (recommended)?" 41 | }, 42 | "description": "You can change this later in the configuration dialog.\n\nThis station does not provide measurement data, therefore your options are limited. You may still use this station, but real measurement data is usually better." 43 | }, 44 | "options_no_forecast": { 45 | "data": { 46 | "current_weather": "Which data shall be used as the current weather?", 47 | "forecast": "Do you want to have forecast data (recommended)?" 48 | }, 49 | "description": "You can change this later in the configuration dialog.\n\nThis station does not provide forecast data, therefore your options are limited." 50 | } 51 | } 52 | }, 53 | "options": { 54 | "step": { 55 | "init": { 56 | "data": { 57 | "current_weather": "Which data shall be used as the current weather?", 58 | "forecast": "Do you want to have forecast data (recommended)?" 59 | } 60 | }, 61 | "init_no_measurement": { 62 | "data": { 63 | "current_weather": "Which data shall be used as the current weather?", 64 | "forecast": "Do you want to have forecast data (recommended)?" 65 | }, 66 | "description": "This station does not provide measurement data, therefore your options are limited." 67 | }, 68 | "init_no_forecast": { 69 | "data": { 70 | "current_weather": "Which data shall be used as the current weather?", 71 | "forecast": "Do you want to have forecast data (recommended)?" 72 | }, 73 | "description": "This station does not provide forecast data, therefore your options are limited." 74 | } 75 | } 76 | }, 77 | "selector": { 78 | "station": { 79 | "options": { 80 | "nostation_custom": "Custom...", 81 | "nostation_load_all": "Load all (might be slow)..." 82 | } 83 | }, 84 | "current_weather": { 85 | "options": { 86 | "measurement": "Measurement data only (recommended)", 87 | "hybrid": "Measurement data with forecast data for current hour as fallback for attributes where no measurement data is available", 88 | "forecast": "Forecast data only (recommended only for stations that do not provide measurement data at all)" 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /custom_components/dwd/weather.py: -------------------------------------------------------------------------------- 1 | """Support for DWD weather service.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import UTC, date, datetime, time, timedelta 6 | from enum import Enum 7 | import logging 8 | from typing import Any 9 | 10 | from homeassistant.components.weather import ( 11 | ATTR_CONDITION_CLEAR_NIGHT, 12 | ATTR_CONDITION_CLOUDY, 13 | ATTR_CONDITION_FOG, 14 | ATTR_CONDITION_HAIL, 15 | ATTR_CONDITION_LIGHTNING, 16 | ATTR_CONDITION_LIGHTNING_RAINY, 17 | ATTR_CONDITION_PARTLYCLOUDY, 18 | ATTR_CONDITION_POURING, 19 | ATTR_CONDITION_RAINY, 20 | ATTR_CONDITION_SNOWY, 21 | ATTR_CONDITION_SNOWY_RAINY, 22 | ATTR_CONDITION_SUNNY, 23 | ATTR_CONDITION_WINDY, 24 | ATTR_CONDITION_WINDY_VARIANT, 25 | ATTR_FORECAST_CLOUD_COVERAGE, 26 | ATTR_FORECAST_CONDITION, 27 | ATTR_FORECAST_DEW_POINT, 28 | ATTR_FORECAST_NATIVE_PRECIPITATION, 29 | ATTR_FORECAST_NATIVE_PRESSURE, 30 | ATTR_FORECAST_NATIVE_TEMP, 31 | ATTR_FORECAST_NATIVE_TEMP_LOW, 32 | ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, 33 | ATTR_FORECAST_NATIVE_WIND_SPEED, 34 | ATTR_FORECAST_PRECIPITATION_PROBABILITY, 35 | ATTR_FORECAST_TIME, 36 | ATTR_FORECAST_WIND_BEARING, 37 | DOMAIN as WEATHER_DOMAIN, 38 | SingleCoordinatorWeatherEntity, 39 | WeatherEntityFeature, 40 | ) 41 | from homeassistant.config_entries import ConfigEntry 42 | from homeassistant.const import ( 43 | UnitOfLength, 44 | UnitOfPressure, 45 | UnitOfSpeed, 46 | UnitOfTemperature, 47 | ) 48 | from homeassistant.core import HomeAssistant, callback 49 | from homeassistant.helpers import entity_registry as er, sun 50 | from homeassistant.helpers.device_registry import DeviceEntryType 51 | from homeassistant.helpers.entity import DeviceInfo 52 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 53 | from homeassistant.util import dt as dt_util 54 | 55 | from .const import ( 56 | ATTRIBUTION, 57 | CONDITION_CLOUDY_THRESHOLD, 58 | CONDITION_PARTLYCLOUDY_THRESHOLD, 59 | CONDITIONS_MAP, 60 | CONF_CURRENT_WEATHER, 61 | CONF_CURRENT_WEATHER_DEFAULT, 62 | CONF_CURRENT_WEATHER_FORECAST, 63 | CONF_CURRENT_WEATHER_HYBRID, 64 | CONF_CURRENT_WEATHER_MEASUREMENT, 65 | CONF_FORECAST, 66 | CONF_FORECAST_DEFAULT, 67 | DOMAIN, 68 | DWD_FORECAST, 69 | DWD_FORECAST_TIMESTAMP, 70 | DWD_MEASUREMENT, 71 | DWD_MEASUREMENT_CLOUD_COVER_TOTAL, 72 | DWD_MEASUREMENT_DEW_POINT, 73 | DWD_MEASUREMENT_HUMIDITY, 74 | DWD_MEASUREMENT_MAXIMUM_WIND_SPEED, 75 | DWD_MEASUREMENT_MEANWIND_DIRECTION, 76 | DWD_MEASUREMENT_MEANWIND_SPEED, 77 | DWD_MEASUREMENT_PRESENT_WEATHER, 78 | DWD_MEASUREMENT_PRESSURE, 79 | DWD_MEASUREMENT_TEMPERATURE, 80 | DWD_MEASUREMENT_VISIBILITY, 81 | ) 82 | from .coordinator import DwdDataUpdateCoordinator 83 | 84 | _LOGGER = logging.getLogger(__name__) 85 | 86 | 87 | class ForecastMode(Enum): 88 | """The forecast mode of a Weather entity.""" 89 | 90 | DAILY = 1 91 | HOURLY = 2 92 | 93 | 94 | async def async_setup_entry( 95 | hass: HomeAssistant, 96 | config_entry: ConfigEntry, 97 | async_add_entities: AddEntitiesCallback, 98 | ): 99 | """Add a weather entity from a config_entry.""" 100 | coordinator: DwdDataUpdateCoordinator = config_entry.runtime_data 101 | entity_registry = er.async_get(hass) 102 | 103 | device = { 104 | "identifiers": {(DOMAIN, config_entry.unique_id)}, 105 | "name": config_entry.title, 106 | "manufacturer": "Deutscher Wetterdienst", 107 | "model": f"Station {config_entry.unique_id}", 108 | "entry_type": DeviceEntryType.SERVICE, 109 | } 110 | 111 | # Remove hourly entity from legacy config entries 112 | if hourly_entity_id := entity_registry.async_get_entity_id( 113 | WEATHER_DOMAIN, 114 | DOMAIN, 115 | f"{config_entry.unique_id}-hourly", 116 | ): 117 | entity_registry.async_remove(hourly_entity_id) 118 | 119 | # Remove daily entity from legacy config entries 120 | if daily_entity_id := entity_registry.async_get_entity_id( 121 | WEATHER_DOMAIN, 122 | DOMAIN, 123 | f"{config_entry.unique_id}-daily", 124 | ): 125 | entity_registry.async_remove(daily_entity_id) 126 | 127 | async_add_entities( 128 | [ 129 | DwdWeather( 130 | hass, 131 | coordinator, 132 | config_entry.unique_id, 133 | config_entry, 134 | device, 135 | ), 136 | ] 137 | ) 138 | 139 | 140 | class DwdWeather(SingleCoordinatorWeatherEntity[DwdDataUpdateCoordinator]): 141 | """Implementation of a DWD weather condition.""" 142 | 143 | def __init__( 144 | self, 145 | hass: HomeAssistant, 146 | coordinator: DwdDataUpdateCoordinator, 147 | unique_id: str, 148 | config: ConfigEntry, 149 | device: DeviceInfo, 150 | ) -> None: 151 | """Initialize.""" 152 | super().__init__(coordinator) 153 | self._hass: HomeAssistant = hass 154 | self._attr_unique_id = unique_id 155 | self._config: ConfigEntry = config 156 | self._attr_device_info = device 157 | self._conf_current_weather: str = self._config.options.get( 158 | CONF_CURRENT_WEATHER, CONF_CURRENT_WEATHER_DEFAULT 159 | ) 160 | 161 | name = self._config.title 162 | 163 | if name is None: 164 | name = self.hass.config.location_name 165 | 166 | if name is None: 167 | name = "DWD" 168 | 169 | self._attr_name = name 170 | 171 | self._attr_entity_registry_enabled_default = True 172 | 173 | self._attr_supported_features = 0 174 | if self._config.options.get(CONF_FORECAST, CONF_FORECAST_DEFAULT): 175 | self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY 176 | self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY 177 | 178 | self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS 179 | self._attr_native_pressure_unit = UnitOfPressure.HPA 180 | self._attr_native_visibility_unit = UnitOfLength.KILOMETERS 181 | self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR 182 | self._attr_native_precipitation_unit = UnitOfLength.MILLIMETERS 183 | 184 | self._attr_attribution = ATTRIBUTION 185 | 186 | @property 187 | def available(self) -> bool: 188 | """Return True if entity is available.""" 189 | return self.coordinator.last_update_success 190 | 191 | @property 192 | def condition(self) -> str | None: 193 | """Return the current condition.""" 194 | if self._conf_current_weather in ( 195 | CONF_CURRENT_WEATHER_MEASUREMENT, 196 | CONF_CURRENT_WEATHER_HYBRID, 197 | ): 198 | str_value = self.coordinator.data[DWD_MEASUREMENT].get( 199 | DWD_MEASUREMENT_PRESENT_WEATHER, None 200 | ) 201 | if str_value is None or str_value == "---": 202 | if self._conf_current_weather == CONF_CURRENT_WEATHER_MEASUREMENT: 203 | return None 204 | else: 205 | forecast = self._get_forecast(ForecastMode.HOURLY, 1) 206 | if forecast is None or len(forecast) < 1: 207 | return None 208 | else: 209 | return forecast[0].get(ATTR_FORECAST_CONDITION) 210 | else: 211 | condition = CONDITIONS_MAP.get(int(str_value), "") 212 | if condition == ATTR_CONDITION_SUNNY and not sun.is_up(self._hass): 213 | condition = ATTR_CONDITION_CLEAR_NIGHT 214 | return condition 215 | elif self._conf_current_weather == CONF_CURRENT_WEATHER_FORECAST: 216 | forecast = self._get_forecast(ForecastMode.HOURLY, 1) 217 | if forecast is None or len(forecast) < 1: 218 | return None 219 | else: 220 | return forecast[0].get(ATTR_FORECAST_CONDITION) 221 | else: 222 | return None 223 | 224 | @property 225 | def native_temperature(self) -> float | None: 226 | """Return the temperature in native units.""" 227 | return self._get_float_measurement_with_fallback( 228 | DWD_MEASUREMENT_TEMPERATURE, ATTR_FORECAST_NATIVE_TEMP 229 | ) 230 | 231 | @property 232 | def native_dew_point(self) -> float | None: 233 | """Return the dew point temperature in native units.""" 234 | return self._get_float_measurement_with_fallback( 235 | DWD_MEASUREMENT_DEW_POINT, ATTR_FORECAST_DEW_POINT 236 | ) 237 | 238 | @property 239 | def native_pressure(self) -> float | None: 240 | """Return the pressure in native units.""" 241 | return self._get_float_measurement_with_fallback( 242 | DWD_MEASUREMENT_PRESSURE, ATTR_FORECAST_NATIVE_PRESSURE 243 | ) 244 | 245 | @property 246 | def humidity(self) -> float | None: 247 | """Return the humidity in native units.""" 248 | return self._get_float_measurement_without_fallback(DWD_MEASUREMENT_HUMIDITY) 249 | 250 | @property 251 | def cloud_coverage(self) -> float | None: 252 | """Return the Cloud coverage in %.""" 253 | return self._get_float_measurement_with_fallback( 254 | DWD_MEASUREMENT_CLOUD_COVER_TOTAL, ATTR_FORECAST_CLOUD_COVERAGE 255 | ) 256 | 257 | @property 258 | def native_visibility(self) -> float | None: 259 | """Return the visibility in native units.""" 260 | return self._get_float_measurement_without_fallback(DWD_MEASUREMENT_VISIBILITY) 261 | 262 | @property 263 | def native_wind_gust_speed(self) -> float | None: 264 | """Return the wind gust speed in native units.""" 265 | return self._get_float_measurement_with_fallback( 266 | DWD_MEASUREMENT_MAXIMUM_WIND_SPEED, ATTR_FORECAST_NATIVE_WIND_GUST_SPEED 267 | ) 268 | 269 | @property 270 | def native_wind_speed(self) -> float | None: 271 | """Return the wind speed in native units.""" 272 | return self._get_float_measurement_with_fallback( 273 | DWD_MEASUREMENT_MEANWIND_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED 274 | ) 275 | 276 | @property 277 | def wind_bearing(self) -> float | None: 278 | """Return the wind bearing.""" 279 | return self._get_float_measurement_with_fallback( 280 | DWD_MEASUREMENT_MEANWIND_DIRECTION, ATTR_FORECAST_WIND_BEARING 281 | ) 282 | 283 | def _get_float_measurement_with_fallback( 284 | self, dwd_measurement: str, attr_forecast: str 285 | ) -> float | None: 286 | if self._conf_current_weather in ( 287 | CONF_CURRENT_WEATHER_MEASUREMENT, 288 | CONF_CURRENT_WEATHER_HYBRID, 289 | ): 290 | str_value = self.coordinator.data[DWD_MEASUREMENT].get( 291 | dwd_measurement, None 292 | ) 293 | if str_value is None or str_value == "---": 294 | if self._conf_current_weather == CONF_CURRENT_WEATHER_MEASUREMENT: 295 | return None 296 | else: 297 | forecast = self._get_forecast(ForecastMode.HOURLY, 1) 298 | if forecast is None or len(forecast) < 1: 299 | return None 300 | else: 301 | return forecast[0].get(attr_forecast) 302 | else: 303 | return DwdWeather._str_to_float(str_value) 304 | elif self._conf_current_weather == CONF_CURRENT_WEATHER_FORECAST: 305 | forecast = self._get_forecast(ForecastMode.HOURLY, 1) 306 | if forecast is None or len(forecast) < 1: 307 | return None 308 | else: 309 | return forecast[0].get(attr_forecast) 310 | else: 311 | return None 312 | 313 | def _get_float_measurement_without_fallback( 314 | self, dwd_measurement: str 315 | ) -> float | None: 316 | if self._conf_current_weather in ( 317 | CONF_CURRENT_WEATHER_MEASUREMENT, 318 | CONF_CURRENT_WEATHER_HYBRID, 319 | ): 320 | str_value = self.coordinator.data[DWD_MEASUREMENT].get( 321 | dwd_measurement, None 322 | ) 323 | if str_value is None or str_value == "---": 324 | return None 325 | else: 326 | return DwdWeather._str_to_float(str_value) 327 | else: 328 | return None 329 | 330 | @callback 331 | def _async_forecast_daily(self): 332 | """Return the daily forecast in native units.""" 333 | 334 | if not self._config.options.get(CONF_FORECAST, CONF_FORECAST_DEFAULT): 335 | return None 336 | 337 | return self._get_forecast(ForecastMode.DAILY) 338 | 339 | @callback 340 | def _async_forecast_hourly(self): 341 | """Return the hourly forecast in native units.""" 342 | 343 | if not self._config.options.get(CONF_FORECAST, CONF_FORECAST_DEFAULT): 344 | return None 345 | 346 | return self._get_forecast(ForecastMode.HOURLY) 347 | 348 | def _get_forecast(self, forecast_mode: ForecastMode, max_hours: int = 0): 349 | # We build both lists in parallel and just return the needed one. Although it's a small 350 | # overhead, it still makes thinks easier, because there is still much in common, because to 351 | # calculate the days most of the hourly stuff has to be done again. 352 | hourly_list = [] 353 | daily_list = [] 354 | 355 | # For a description of all values see https://opendata.dwd.de/weather/lib/MetElementDefinition.xml 356 | # Unfortunately, "ww" is not documented there, but the assumption is that it's the same as for 357 | # "ww3", but hourly. However, "ww" is at least mentioned at 358 | # https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/kml/mosmix_element_weather_xls.xlsx 359 | 360 | dwd_forecast = self.coordinator.data[DWD_FORECAST] 361 | 362 | if dwd_forecast is None: 363 | return None 364 | 365 | dwd_forecast_timestamp = dwd_forecast.get(DWD_FORECAST_TIMESTAMP, []) 366 | dwd_forecast_TTT = dwd_forecast.get("TTT", []) 367 | dwd_forecast_ww = dwd_forecast.get("ww", []) 368 | dwd_forecast_Td = dwd_forecast.get("Td", []) 369 | dwd_forecast_Neff = dwd_forecast.get("Neff", []) 370 | dwd_forecast_RR1c = dwd_forecast.get("RR1c", []) 371 | dwd_forecast_wwP = dwd_forecast.get("wwP", []) 372 | dwd_forecast_PPPP = dwd_forecast.get("PPPP", []) 373 | dwd_forecast_DD = dwd_forecast.get("DD", []) 374 | dwd_forecast_FF = dwd_forecast.get("FF", []) 375 | dwd_forecast_FX1 = dwd_forecast.get("FX1", []) 376 | 377 | current_day: DwdWeatherDay = None 378 | 379 | # Timestamp and temperature are mandatory attributes of the forcast entity, 380 | # see https://developers.home-assistant.io/docs/core/entity/weather/ 381 | for i in range(min(len(dwd_forecast_timestamp), len(dwd_forecast_TTT))): 382 | # We have to subsctract one hour, because all forecast values are for the last hour 383 | # and we are interested in the timestamp at the beginning of the hour, not at the end. 384 | timestamp = dwd_forecast_timestamp[i] - timedelta(hours=1) 385 | 386 | # The forcast contains data from a few hour back. However, the earlist we want to return 387 | # is from the current hour (i.e. at most one hour back), because that's what other 388 | # Home Assistant components like UI elements expect. They use just everything we give them. 389 | if timestamp > datetime.now(UTC) - timedelta(hours=1): 390 | hourly_item = {} 391 | 392 | hourly_item[ATTR_FORECAST_TIME] = timestamp.isoformat() 393 | 394 | timestamp_local = dt_util.as_local(timestamp) 395 | day = timestamp_local.date() 396 | if current_day is None or current_day.day != day: 397 | current_day = DwdWeatherDay(day, dwd_forecast) 398 | daily_list.append(current_day) 399 | current_day.add_hour(hourly_item, i) 400 | 401 | # TTT is in K 402 | raw_temperature_value = dwd_forecast_TTT[i] 403 | if raw_temperature_value != "-": 404 | temperature_celcius = float(raw_temperature_value) - 273.15 405 | hourly_item[ATTR_FORECAST_NATIVE_TEMP] = temperature_celcius 406 | 407 | # If there is no temperature, we skip this entry, because it's a mandatory attribute! 408 | 409 | # There are actually two sources for the mapping of the "ww" field. The primary description seems to be 410 | # https://www.dwd.de/DE/leistungen/opendata/help/schluessel_datenformate/kml/mosmix_element_weather_xls.xlsx 411 | # However, at first I found 412 | # https://www.dwd.de/DE/leistungen/pbfb_verlag_vub/pdf_einzelbaende/vub_2_binaer_barrierefrei.pdf 413 | # ("Aktuelles Wetter" on page 229) and started the implementation based on that. The first link basically 414 | # seems to be a subset of the second link. I still have some doubts regarding the values 0-3. There seems 415 | # to be a slight difference between the two documentations, and the value does no behave exactly as descibed. 416 | # For exmaple, the documentation says that 3 is for effective cloud coverage of at least 7/8 and 2 for 417 | # effective cloud coverage 4.6/8 to 6/8, but I could observe 3 even for 78% which is much below 6/8. 418 | # Still using it for now, the behavior at least seems to be the same as in the WarnWetter app so far. 419 | if i < len(dwd_forecast_ww): 420 | raw_weather_value = dwd_forecast_ww[i] 421 | if raw_weather_value != "-": 422 | weather_value = int(round(float(raw_weather_value), 0)) 423 | if weather_value == 0: 424 | if sun.is_up(self._hass, timestamp): 425 | hourly_item[ATTR_FORECAST_CONDITION] = ( 426 | ATTR_CONDITION_SUNNY 427 | ) 428 | else: 429 | hourly_item[ATTR_FORECAST_CONDITION] = ( 430 | ATTR_CONDITION_CLEAR_NIGHT 431 | ) 432 | elif 1 <= weather_value <= 2: 433 | hourly_item[ATTR_FORECAST_CONDITION] = ( 434 | ATTR_CONDITION_PARTLYCLOUDY 435 | ) 436 | elif weather_value == 3: 437 | hourly_item[ATTR_FORECAST_CONDITION] = ( 438 | ATTR_CONDITION_CLOUDY 439 | ) 440 | elif 4 <= weather_value <= 12: 441 | hourly_item[ATTR_FORECAST_CONDITION] = ( 442 | ATTR_CONDITION_FOG 443 | ) 444 | elif weather_value == 13: 445 | hourly_item[ATTR_FORECAST_CONDITION] = ( 446 | ATTR_CONDITION_LIGHTNING 447 | ) 448 | elif 14 <= weather_value <= 16: 449 | hourly_item[ATTR_FORECAST_CONDITION] = ( 450 | ATTR_CONDITION_RAINY 451 | ) 452 | elif weather_value == 17: 453 | hourly_item[ATTR_FORECAST_CONDITION] = ( 454 | ATTR_CONDITION_LIGHTNING 455 | ) 456 | elif weather_value == 18: 457 | hourly_item[ATTR_FORECAST_CONDITION] = ( 458 | ATTR_CONDITION_WINDY 459 | ) 460 | elif weather_value == 19: 461 | hourly_item[ATTR_FORECAST_CONDITION] = ( 462 | ATTR_CONDITION_WINDY_VARIANT 463 | ) 464 | elif 20 <= weather_value <= 21: 465 | hourly_item[ATTR_FORECAST_CONDITION] = ( 466 | ATTR_CONDITION_RAINY 467 | ) 468 | elif weather_value == 22: 469 | hourly_item[ATTR_FORECAST_CONDITION] = ( 470 | ATTR_CONDITION_SNOWY 471 | ) 472 | elif weather_value == 23: 473 | hourly_item[ATTR_FORECAST_CONDITION] = ( 474 | ATTR_CONDITION_SNOWY_RAINY 475 | ) 476 | elif 24 <= weather_value <= 25: 477 | hourly_item[ATTR_FORECAST_CONDITION] = ( 478 | ATTR_CONDITION_RAINY 479 | ) 480 | elif weather_value == 26: 481 | hourly_item[ATTR_FORECAST_CONDITION] = ( 482 | ATTR_CONDITION_SNOWY 483 | ) 484 | elif weather_value == 27: 485 | hourly_item[ATTR_FORECAST_CONDITION] = ( 486 | ATTR_CONDITION_HAIL 487 | ) 488 | elif weather_value == 28: 489 | hourly_item[ATTR_FORECAST_CONDITION] = ( 490 | ATTR_CONDITION_FOG 491 | ) 492 | elif weather_value == 29: 493 | hourly_item[ATTR_FORECAST_CONDITION] = ( 494 | ATTR_CONDITION_LIGHTNING_RAINY 495 | ) 496 | elif 30 <= weather_value <= 39: 497 | hourly_item[ATTR_FORECAST_CONDITION] = ( 498 | ATTR_CONDITION_WINDY 499 | ) 500 | elif 40 <= weather_value <= 49: 501 | hourly_item[ATTR_FORECAST_CONDITION] = ( 502 | ATTR_CONDITION_FOG 503 | ) 504 | elif 50 <= weather_value <= 63: 505 | hourly_item[ATTR_FORECAST_CONDITION] = ( 506 | ATTR_CONDITION_RAINY 507 | ) 508 | elif 64 <= weather_value <= 65: 509 | hourly_item[ATTR_FORECAST_CONDITION] = ( 510 | ATTR_CONDITION_POURING 511 | ) 512 | elif 66 <= weather_value <= 67: 513 | hourly_item[ATTR_FORECAST_CONDITION] = ( 514 | ATTR_CONDITION_RAINY 515 | ) 516 | elif 68 <= weather_value <= 69: 517 | hourly_item[ATTR_FORECAST_CONDITION] = ( 518 | ATTR_CONDITION_SNOWY_RAINY 519 | ) 520 | elif 70 <= weather_value <= 79: 521 | hourly_item[ATTR_FORECAST_CONDITION] = ( 522 | ATTR_CONDITION_SNOWY 523 | ) 524 | elif 80 <= weather_value <= 81: 525 | hourly_item[ATTR_FORECAST_CONDITION] = ( 526 | ATTR_CONDITION_RAINY 527 | ) 528 | elif weather_value == 82: 529 | hourly_item[ATTR_FORECAST_CONDITION] = ( 530 | ATTR_CONDITION_POURING 531 | ) 532 | elif 83 <= weather_value <= 84: 533 | hourly_item[ATTR_FORECAST_CONDITION] = ( 534 | ATTR_CONDITION_SNOWY_RAINY 535 | ) 536 | elif 85 <= weather_value <= 88: 537 | hourly_item[ATTR_FORECAST_CONDITION] = ( 538 | ATTR_CONDITION_SNOWY 539 | ) 540 | elif 89 <= weather_value <= 90: 541 | hourly_item[ATTR_FORECAST_CONDITION] = ( 542 | ATTR_CONDITION_HAIL 543 | ) 544 | elif 91 <= weather_value <= 99: 545 | hourly_item[ATTR_FORECAST_CONDITION] = ( 546 | ATTR_CONDITION_LIGHTNING_RAINY 547 | ) 548 | elif weather_value == 100: 549 | if sun.is_up(self._hass, timestamp): 550 | hourly_item[ATTR_FORECAST_CONDITION] = ( 551 | ATTR_CONDITION_SUNNY 552 | ) 553 | else: 554 | hourly_item[ATTR_FORECAST_CONDITION] = ( 555 | ATTR_CONDITION_CLEAR_NIGHT 556 | ) 557 | elif 101 <= weather_value <= 102: 558 | hourly_item[ATTR_FORECAST_CONDITION] = ( 559 | ATTR_CONDITION_PARTLYCLOUDY 560 | ) 561 | elif weather_value == 103: 562 | hourly_item[ATTR_FORECAST_CONDITION] = ( 563 | ATTR_CONDITION_CLOUDY 564 | ) 565 | elif 104 <= weather_value <= 105: 566 | hourly_item[ATTR_FORECAST_CONDITION] = ( 567 | ATTR_CONDITION_FOG 568 | ) 569 | elif weather_value == 110: 570 | hourly_item[ATTR_FORECAST_CONDITION] = ( 571 | ATTR_CONDITION_FOG 572 | ) 573 | elif weather_value == 111: 574 | hourly_item[ATTR_FORECAST_CONDITION] = ( 575 | ATTR_CONDITION_SNOWY 576 | ) 577 | elif weather_value == 112: 578 | hourly_item[ATTR_FORECAST_CONDITION] = ( 579 | ATTR_CONDITION_LIGHTNING 580 | ) 581 | elif weather_value == 118: 582 | hourly_item[ATTR_FORECAST_CONDITION] = ( 583 | ATTR_CONDITION_WINDY 584 | ) 585 | elif weather_value == 120: 586 | hourly_item[ATTR_FORECAST_CONDITION] = ( 587 | ATTR_CONDITION_FOG 588 | ) 589 | elif 121 <= weather_value <= 123: 590 | hourly_item[ATTR_FORECAST_CONDITION] = ( 591 | ATTR_CONDITION_RAINY 592 | ) 593 | elif weather_value == 124: 594 | hourly_item[ATTR_FORECAST_CONDITION] = ( 595 | ATTR_CONDITION_SNOWY 596 | ) 597 | elif weather_value == 125: 598 | hourly_item[ATTR_FORECAST_CONDITION] = ( 599 | ATTR_CONDITION_RAINY 600 | ) 601 | elif weather_value == 126: 602 | hourly_item[ATTR_FORECAST_CONDITION] = ( 603 | ATTR_CONDITION_LIGHTNING_RAINY 604 | ) 605 | elif 127 <= weather_value <= 129: 606 | hourly_item[ATTR_FORECAST_CONDITION] = ( 607 | ATTR_CONDITION_WINDY 608 | ) 609 | elif 130 <= weather_value <= 135: 610 | hourly_item[ATTR_FORECAST_CONDITION] = ( 611 | ATTR_CONDITION_FOG 612 | ) 613 | elif 140 <= weather_value <= 141: 614 | hourly_item[ATTR_FORECAST_CONDITION] = ( 615 | ATTR_CONDITION_RAINY 616 | ) 617 | elif weather_value == 142: 618 | hourly_item[ATTR_FORECAST_CONDITION] = ( 619 | ATTR_CONDITION_POURING 620 | ) 621 | elif weather_value == 143: 622 | hourly_item[ATTR_FORECAST_CONDITION] = ( 623 | ATTR_CONDITION_RAINY 624 | ) 625 | elif weather_value == 144: 626 | hourly_item[ATTR_FORECAST_CONDITION] = ( 627 | ATTR_CONDITION_POURING 628 | ) 629 | elif 145 <= weather_value <= 146: 630 | hourly_item[ATTR_FORECAST_CONDITION] = ( 631 | ATTR_CONDITION_HAIL 632 | ) 633 | elif 147 <= weather_value <= 148: 634 | hourly_item[ATTR_FORECAST_CONDITION] = ( 635 | ATTR_CONDITION_RAINY 636 | ) 637 | elif 150 <= weather_value <= 158: 638 | hourly_item[ATTR_FORECAST_CONDITION] = ( 639 | ATTR_CONDITION_RAINY 640 | ) 641 | elif 160 <= weather_value <= 162: 642 | hourly_item[ATTR_FORECAST_CONDITION] = ( 643 | ATTR_CONDITION_RAINY 644 | ) 645 | elif weather_value == 163: 646 | hourly_item[ATTR_FORECAST_CONDITION] = ( 647 | ATTR_CONDITION_POURING 648 | ) 649 | elif 164 <= weather_value <= 165: 650 | hourly_item[ATTR_FORECAST_CONDITION] = ( 651 | ATTR_CONDITION_RAINY 652 | ) 653 | elif weather_value == 166: 654 | hourly_item[ATTR_FORECAST_CONDITION] = ( 655 | ATTR_CONDITION_POURING 656 | ) 657 | elif 167 <= weather_value <= 168: 658 | hourly_item[ATTR_FORECAST_CONDITION] = ( 659 | ATTR_CONDITION_SNOWY_RAINY 660 | ) 661 | elif 170 <= weather_value <= 178: 662 | hourly_item[ATTR_FORECAST_CONDITION] = ( 663 | ATTR_CONDITION_SNOWY 664 | ) 665 | elif 180 <= weather_value <= 182: 666 | hourly_item[ATTR_FORECAST_CONDITION] = ( 667 | ATTR_CONDITION_RAINY 668 | ) 669 | elif 183 <= weather_value <= 184: 670 | hourly_item[ATTR_FORECAST_CONDITION] = ( 671 | ATTR_CONDITION_POURING 672 | ) 673 | elif 185 <= weather_value <= 187: 674 | hourly_item[ATTR_FORECAST_CONDITION] = ( 675 | ATTR_CONDITION_SNOWY 676 | ) 677 | elif weather_value == 189: 678 | hourly_item[ATTR_FORECAST_CONDITION] = ( 679 | ATTR_CONDITION_HAIL 680 | ) 681 | elif 190 <= weather_value <= 191: 682 | hourly_item[ATTR_FORECAST_CONDITION] = ( 683 | ATTR_CONDITION_LIGHTNING 684 | ) 685 | elif 192 <= weather_value <= 193: 686 | hourly_item[ATTR_FORECAST_CONDITION] = ( 687 | ATTR_CONDITION_LIGHTNING_RAINY 688 | ) 689 | elif weather_value == 194: 690 | hourly_item[ATTR_FORECAST_CONDITION] = ( 691 | ATTR_CONDITION_LIGHTNING 692 | ) 693 | elif 195 <= weather_value <= 196: 694 | hourly_item[ATTR_FORECAST_CONDITION] = ( 695 | ATTR_CONDITION_LIGHTNING_RAINY 696 | ) 697 | elif weather_value == 199: 698 | hourly_item[ATTR_FORECAST_CONDITION] = ( 699 | ATTR_CONDITION_WINDY_VARIANT 700 | ) 701 | 702 | # Td is in K 703 | if i < len(dwd_forecast_Td): 704 | raw_dew_point_value = dwd_forecast_Td[i] 705 | if raw_dew_point_value != "-": 706 | dew_point_celcius = float(raw_dew_point_value) - 273.15 707 | hourly_item[ATTR_FORECAST_DEW_POINT] = round( 708 | dew_point_celcius, 1 709 | ) 710 | 711 | # Neff is in % 712 | if i < len(dwd_forecast_Neff): 713 | raw_cloud_coverage_value = dwd_forecast_Neff[i] 714 | if raw_cloud_coverage_value != "-": 715 | cloud_coverage_value = float(raw_cloud_coverage_value) 716 | hourly_item[ATTR_FORECAST_CLOUD_COVERAGE] = ( 717 | cloud_coverage_value 718 | ) 719 | 720 | # RR1c is in kg/m2 which is equal to mm 721 | if i < len(dwd_forecast_RR1c): 722 | raw_value = dwd_forecast_RR1c[i] 723 | if raw_value != "-": 724 | precipitation_mm = float(raw_value) 725 | hourly_item[ATTR_FORECAST_NATIVE_PRECIPITATION] = ( 726 | precipitation_mm 727 | ) 728 | 729 | # wwP is in % 730 | if i < len(dwd_forecast_wwP): 731 | raw_value = dwd_forecast_wwP[i] 732 | if raw_value != "-": 733 | hourly_item[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = int( 734 | round(float(raw_value), 0) 735 | ) 736 | 737 | # PPPP is in Pa 738 | if i < len(dwd_forecast_PPPP): 739 | raw_value = dwd_forecast_PPPP[i] 740 | if raw_value != "-": 741 | hourly_item[ATTR_FORECAST_NATIVE_PRESSURE] = ( 742 | float(raw_value) * 0.01 743 | ) 744 | 745 | # DD is in ° 746 | if i < len(dwd_forecast_DD): 747 | raw_value = dwd_forecast_DD[i] 748 | if raw_value != "-": 749 | hourly_item[ATTR_FORECAST_WIND_BEARING] = float(raw_value) 750 | 751 | # FF is in m/s 752 | if i < len(dwd_forecast_FF): 753 | raw_value = dwd_forecast_FF[i] 754 | if raw_value != "-": 755 | wind_speed_kmh = float(raw_value) * 3.6 756 | hourly_item[ATTR_FORECAST_NATIVE_WIND_SPEED] = int( 757 | round(wind_speed_kmh, 0) 758 | ) 759 | 760 | # FX1 is in m/s 761 | if i < len(dwd_forecast_FX1): 762 | raw_value = dwd_forecast_FX1[i] 763 | if raw_value != "-": 764 | wind_gust_speed_kmh = float(raw_value) * 3.6 765 | hourly_item[ATTR_FORECAST_NATIVE_WIND_GUST_SPEED] = int( 766 | round(wind_gust_speed_kmh, 0) 767 | ) 768 | 769 | hourly_list.append(hourly_item) 770 | 771 | if max_hours > 0 and len(hourly_list) >= max_hours: 772 | break 773 | 774 | if forecast_mode == ForecastMode.DAILY: 775 | result = [] 776 | if len(daily_list) > 0: 777 | # Always add current day: 778 | result.append(daily_list[0].values) 779 | # Only add other days of they are complete 780 | for i in range(1, len(daily_list)): 781 | if daily_list[i].has_enough_hours: 782 | result.append(daily_list[i].values) 783 | return result 784 | if forecast_mode == ForecastMode.HOURLY: 785 | return hourly_list 786 | 787 | @staticmethod 788 | def _str_to_float(value: str) -> float | None: 789 | if value == "---": 790 | return None 791 | else: 792 | return float(value.replace(",", ".")) 793 | 794 | 795 | class DwdWeatherDay: 796 | """Manages the weather data of a single day.""" 797 | 798 | @property 799 | def day(self) -> date: 800 | """Returns the date of the day.""" 801 | return self._day 802 | 803 | @property 804 | def has_enough_hours(self) -> bool: 805 | """Return True, if the day has data of enough hours, otherwise returns False.""" 806 | # We do not insist on 24 hours, 807 | # 1. because the day might have 23 or 25 hours on DST changes. 808 | # 2. to be a bit robust in case data is missing for very few hours (although we didn't observe this yet). 809 | return len(self._hours) > 20 810 | 811 | @property 812 | def values(self) -> dict[str, Any]: 813 | """Returns the value of the day as a dict.""" 814 | 815 | result = {} 816 | 817 | result[ATTR_FORECAST_TIME] = datetime.combine( 818 | self._day, time(0, 0, 0) 819 | ).isoformat() 820 | 821 | temperature_values = self._get_hourly_values(ATTR_FORECAST_NATIVE_TEMP) 822 | if len(temperature_values) > 0: 823 | result[ATTR_FORECAST_NATIVE_TEMP] = max(temperature_values) 824 | result[ATTR_FORECAST_NATIVE_TEMP_LOW] = min(temperature_values) 825 | 826 | # Danger: The following has a slight ruonding error. You can easily see that because if you 827 | # sum up RR1c ("Total precipitation during the last hour consistent with significant weather"), 828 | # you get a different number that the sum of RRdc ("Total precipitation during the last 24 hours 829 | # consistent with significant weather"). Usually this seems not to be too big, e.g. a sum of 830 | # 1.7 mm instead of 1.6 mm. Unfortunately, we can't use RRdc either, because it's not aligned 831 | # to days. 832 | precipitation_values = self._get_hourly_values( 833 | ATTR_FORECAST_NATIVE_PRECIPITATION 834 | ) 835 | if len(precipitation_values) > 0: 836 | precipitation = sum(precipitation_values) 837 | result[ATTR_FORECAST_NATIVE_PRECIPITATION] = round(precipitation, 2) 838 | 839 | pressure_values = self._get_hourly_values(ATTR_FORECAST_NATIVE_PRESSURE) 840 | if len(pressure_values) > 0: 841 | pressure = sum(pressure_values) / len(pressure_values) 842 | result[ATTR_FORECAST_NATIVE_PRESSURE] = round(pressure, 1) 843 | 844 | wind_gust_speed_values = self._get_hourly_values( 845 | ATTR_FORECAST_NATIVE_WIND_GUST_SPEED 846 | ) 847 | if len(wind_gust_speed_values) > 0: 848 | wind_gust_speed = max(wind_gust_speed_values) 849 | result[ATTR_FORECAST_NATIVE_WIND_GUST_SPEED] = round(wind_gust_speed, 0) 850 | 851 | wind_speed_values = self._get_hourly_values(ATTR_FORECAST_NATIVE_WIND_SPEED) 852 | if len(wind_speed_values) > 0: 853 | wind_speed = sum(wind_speed_values) / len(wind_speed_values) 854 | result[ATTR_FORECAST_NATIVE_WIND_SPEED] = round(wind_speed, 0) 855 | 856 | cloud_coverage_sum = 0 857 | cloud_coverage_items = 0 858 | cloud_coverage_avg = 0 859 | for hour in self._hours: 860 | cloud_coverage = hour.get(ATTR_FORECAST_CLOUD_COVERAGE, None) 861 | if cloud_coverage is not None: 862 | cloud_coverage_sum += cloud_coverage 863 | cloud_coverage_items += 1 864 | 865 | if cloud_coverage_items > 0: 866 | cloud_coverage_avg = cloud_coverage_sum / cloud_coverage_items 867 | result[ATTR_FORECAST_CLOUD_COVERAGE] = round(cloud_coverage_avg, 0) 868 | 869 | condition_stats = {} 870 | for hour in self._hours: 871 | condition = hour.get(ATTR_FORECAST_CONDITION, None) 872 | if condition is not None: 873 | condition_stats[condition] = condition_stats.get(condition, 0) + 1 874 | if len(condition_stats) == 1: 875 | for condition in condition_stats: 876 | result[ATTR_FORECAST_CONDITION] = condition 877 | elif condition_stats.get(ATTR_CONDITION_LIGHTNING_RAINY, 0) > 0: 878 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_LIGHTNING_RAINY 879 | elif condition_stats.get(ATTR_CONDITION_LIGHTNING, 0) > 0: 880 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_LIGHTNING 881 | elif condition_stats.get(ATTR_CONDITION_HAIL, 0) > 0: 882 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_HAIL 883 | elif condition_stats.get(ATTR_CONDITION_SNOWY, 0) > 0: 884 | if ( 885 | condition_stats.get(ATTR_CONDITION_SNOWY_RAINY, 0) == 0 886 | and condition_stats.get(ATTR_CONDITION_RAINY, 0) == 0 887 | ): 888 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_SNOWY 889 | else: 890 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_SNOWY_RAINY 891 | elif condition_stats.get(ATTR_CONDITION_SNOWY_RAINY, 0) > 0: 892 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_SNOWY_RAINY 893 | elif condition_stats.get(ATTR_CONDITION_POURING, 0) > 1: 894 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_POURING 895 | elif ( 896 | condition_stats.get(ATTR_CONDITION_POURING, 0) 897 | + condition_stats.get(ATTR_CONDITION_RAINY, 0) 898 | > 0 899 | ): 900 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_RAINY 901 | elif cloud_coverage_avg >= CONDITION_CLOUDY_THRESHOLD: 902 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_CLOUDY 903 | elif cloud_coverage_avg >= CONDITION_PARTLYCLOUDY_THRESHOLD: 904 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_PARTLYCLOUDY 905 | elif condition_stats.get(ATTR_CONDITION_WINDY_VARIANT, 0) > 0: 906 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_WINDY_VARIANT 907 | elif condition_stats.get(ATTR_CONDITION_WINDY, 0) > 0: 908 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_WINDY 909 | elif ( 910 | condition_stats.get(ATTR_CONDITION_SUNNY, 0) 911 | + condition_stats.get(ATTR_CONDITION_CLEAR_NIGHT, 0) 912 | > 0 913 | ): 914 | result[ATTR_FORECAST_CONDITION] = ATTR_CONDITION_SUNNY 915 | 916 | return result 917 | 918 | def _get_hourly_values(self, key: str) -> list: 919 | return list( 920 | filter( 921 | lambda x: x is not None, 922 | (x.get(key, None) for x in self._hours), 923 | ) 924 | ) 925 | 926 | def __init__(self, day: date, dwd_forecast: dict[str, Any]) -> None: 927 | """Initialize.""" 928 | self._day: date = day 929 | self._dwd_forecast: dict[str, Any] = dwd_forecast 930 | self._hours: list[dict[str, Any]] = [] 931 | self._hour_indices: list[int] = [] 932 | 933 | def add_hour(self, hour_item: dict[str, Any], index: int) -> None: 934 | """Add hour information to this day.""" 935 | self._hours.append(hour_item) 936 | self._hour_indices.append(index) 937 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Deutscher Wetterdienst (by hg1337)", 3 | "homeassistant": "2024.11.0", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /images/screenshot_bramkragten-weather-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_bramkragten-weather-card.png -------------------------------------------------------------------------------- /images/screenshot_entities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_entities.png -------------------------------------------------------------------------------- /images/screenshot_entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_entity.png -------------------------------------------------------------------------------- /images/screenshot_hacs_add-repository.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_hacs_add-repository.png -------------------------------------------------------------------------------- /images/screenshot_installation-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_installation-folder.png -------------------------------------------------------------------------------- /images/screenshot_mlamberts78-weather-chart-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_mlamberts78-weather-chart-card.png -------------------------------------------------------------------------------- /images/screenshot_search-integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_search-integration.png -------------------------------------------------------------------------------- /images/screenshot_service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_service.png -------------------------------------------------------------------------------- /images/screenshot_weather-forecast-card-configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_weather-forecast-card-configuration.png -------------------------------------------------------------------------------- /images/screenshot_weather-forecast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hg1337/homeassistant-dwd/7a7ef6648b14196792bc2715d80e9679d447a003/images/screenshot_weather-forecast.png -------------------------------------------------------------------------------- /migration.md: -------------------------------------------------------------------------------- 1 | [![Release](https://img.shields.io/github/v/release/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/releases) [![Hassfest Workflow Status](https://img.shields.io/github/actions/workflow/status/hg1337/homeassistant-dwd/hassfest.yml?label=Hassfest&style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/actions/workflows/hassfest.yml) [![License](https://img.shields.io/github/license/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/blob/main/LICENSE) [![Donation](https://img.shields.io/badge/Donation-Buy%20me%20a%20coffee-ffd557?style=for-the-badge)](https://www.buymeacoffee.com/hg1337) 2 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 3 | 4 | # Migration to New Weather Entity and Forecasts 5 | 6 | This page is for people running this integration already. If you are new, please start at [README.md](./README.md). 7 | 8 | With release 2023.8, Home Assistant has switched to a new mechanism how weather forecasts are provided. Before that, weather forecasts were provided via a state attribute of the weather entities, and the weather entities for each station needed to be duplicated to provide both hourly and daily forecasts. 9 | 10 | Since Home Assistant 2023.8, all forecasts are provdided by a single entity (per station) and are retrieved by the [weather.get_forecasts service](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts). 11 | 12 | With release 2024.4 Home Assistant has removed the previously deprecated state attribute from the weather entities. Therefore, from version 2024.4 on, this integration does not provide the old entities with the `_daily` and `_hourly` suffix any more. If you are still using them, you have to migrate to the new entities and the new mechanism now. 13 | 14 | If you only use the built-in Weather Forecast Card from Home Assistant or a third party weather card like the one at https://github.com/bramkragten/weather-card, the migration is usually as easy as removing the `_daily` or `_hourly` suffix from the Entity ID in the configuration of the weather card and selecting the desired forecast type (daily or hourly). 15 | 16 | ![Screenshot Weather Forecast Card Configuration](./images/screenshot_weather-forecast-card-configuration.png) 17 | 18 | If you use the forecasts from the state attribute in a template sensor or automation, or if you are using a third party weather card that has not been adapted yet, there is a bit more work to do. For that, you may find these resources helpful: 19 | 20 | - [Examples](https://www.home-assistant.io/integrations/weather/#examples) in the Home Assistant documentation. 21 | - [Questions & Answers](./questions_and_answers.md) for this integration which also include a complete example for a template sensor that can be used as a drop-in replacement for third party weather cards that have not be adapted yet. 22 | -------------------------------------------------------------------------------- /questions_and_answers.md: -------------------------------------------------------------------------------- 1 | [![Release](https://img.shields.io/github/v/release/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/releases) [![Hassfest Workflow Status](https://img.shields.io/github/actions/workflow/status/hg1337/homeassistant-dwd/hassfest.yml?label=Hassfest&style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/actions/workflows/hassfest.yml) [![License](https://img.shields.io/github/license/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/blob/main/LICENSE) [![Donation](https://img.shields.io/badge/Donation-Buy%20me%20a%20coffee-ffd557?style=for-the-badge)](https://www.buymeacoffee.com/hg1337) 2 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 3 | 4 | # Deutscher Wetterdienst (DWD) Questions & Answers 5 | 6 | Please read [README.md](./README.md) first, if you haven't already. 7 | 8 | - [Why is the station that I would like to use not in the selection list when setting up the integration?](#why-is-the-station-that-i-would-like-to-use-not-in-the-selection-list-when-setting-up-the-integration) 9 | - [How can I access forecast data from templates?](#how-can-i-access-forecast-data-from-templates) 10 | - [I'm using a third party weather card that doesn't support the new forecast mechanism. Can I continue using it?](#im-using-a-third-party-weather-card-that-doesnt-support-the-new-forecast-mechanism-can-i-continue-using-it) 11 | - [Why does the daily forecast for the current day differ from the Warnwetter app?](#why-does-the-daily-forecast-for-the-current-day-differ-from-the-warnwetter-app) 12 | - [What is the difference to https://github.com/FL550/dwd_weather?](#what-is-the-difference-to-httpsgithubcomfl550dwd_weather) 13 | 14 | ## Why is the station that I would like to use not in the selection list when setting up the integration? 15 | 16 | In the selection list the same stations as listed at [stations.md](./stations.md) are listed, ordered by distance from your home location configured in Home Assistant. If your station is missing, first try adding it manually by selecting "Custom..." from the list and entering the ID of the station. If this works, probably the station list is outdated and will be updated with the next release. If it doesn't work and you believe it should, please open an [issue](https://github.com/hg1337/homeassistant-dwd/issues), mentioning the station ID and name. 17 | 18 | ## How can I access forecast data from templates? 19 | 20 | Accessing forecast data is basically done by calling the `weather.get_forecasts` service, so wherever you want to access forecast data, you need to be able to call services. This is easily possible in automation actions where you can call services, but if you e.g. need it in a template condition, you need a different approach. The most universally applicable way is to create a template sensor that provides the information you need. 21 | 22 | For more information on template sensors see https://www.home-assistant.io/integrations/template/ 23 | 24 | To create a template sensor that can call services, you need access to the config folder of Home Assistant as this is not possible with Helpers yet. How to access the config folder depends on how you have installed Home Assistant. If you are using the Home Assistant Operating System, you may e.g. use the "Studio Code Server" or "Samba share" add-on. 25 | 26 | If your config folder doesn't contain a `templates.yaml` file yet, create an empty one and include it from the `configuration.yaml` by adding this line: 27 | 28 | ```yaml 29 | template: !include templates.yaml 30 | ``` 31 | 32 | After doing this, go in the Developer Tools to the YAML tab and select "Check Configuration" to make sure you didn't break the configuration. 33 | 34 | Then you can create your template sensor in the `templates.yaml` file. The following example shows a templates sensor that provides the precipitation for the next 3 hours: 35 | 36 | ```yaml 37 | - trigger: 38 | - platform: time_pattern 39 | minutes: "*" 40 | - platform: homeassistant 41 | event: start 42 | - platform: event 43 | event_type: event_template_reloaded 44 | action: 45 | - service: weather.get_forecasts 46 | target: 47 | entity_id: weather.stuttgart_echterdingen 48 | data: 49 | type: hourly 50 | response_variable: forecast 51 | sensor: 52 | - name: "Precipitation next 3 hours" 53 | unique_id: precipitation_next_3_hours 54 | state: > 55 | {{ 56 | forecast['weather.stuttgart_echterdingen'].forecast[0].precipitation 57 | + forecast['weather.stuttgart_echterdingen'].forecast[1].precipitation 58 | + forecast['weather.stuttgart_echterdingen'].forecast[2].precipitation 59 | }} 60 | ``` 61 | 62 | After making changes to your template sensors, you can reload them in the YAML tab of the Developer Tools by selecting to reload the Template Entities. 63 | 64 | If everything went fine, you should see your new sensor in the States tab of the Developer Tools. You can now use it in any template, e.g.: 65 | 66 | ```yaml 67 | {{ states("sensor.precipitation_next_3_hours") }} 68 | ``` 69 | 70 | Or in a [template condition](https://www.home-assistant.io/docs/scripts/conditions/#template-condition) in an automation: 71 | 72 | ```yaml 73 | condition: template 74 | value_template: 'states("sensor.precipitation_next_3_hours") > 10 }}' 75 | ``` 76 | 77 | ## I'm using a third party weather card that doesn't support the new forecast mechanism. Can I continue using it? 78 | 79 | More and more third party weather cards are being updated for new the forecast mechanism, but there might still be some that have not switched yet. The good news is, you can most likely continue using them by creating a template sensor. The approach is basically to call the `weather.get_forecasts` service to get the hourly or daily forecast and provide the result in a state attribute. 80 | 81 | **Before you continue: This is only a workaround. The correct way is to do the necessary changes in the third party weather cards to work with the new forecast mechanism.** 82 | 83 | For more information on template sensors and to create them see also [How can I access forecast data from templates?](#how-can-i-access-forecast-data-from-templates) above. 84 | 85 | You can use the following code as a starting point for your own template sensors. Change all occurrences of `stuttgart_echterdingen` to the Entity ID of your station. Also change the `name` and `unique_id` of the new sensors accordingly. 86 | 87 | ```yaml 88 | - trigger: 89 | - platform: time_pattern 90 | minutes: "*" 91 | - platform: homeassistant 92 | event: start 93 | - platform: event 94 | event_type: event_template_reloaded 95 | action: 96 | - service: weather.get_forecasts 97 | target: 98 | entity_id: weather.stuttgart_echterdingen 99 | data: 100 | type: hourly 101 | response_variable: hourly_forecast 102 | - service: weather.get_forecasts 103 | target: 104 | entity_id: weather.stuttgart_echterdingen 105 | data: 106 | type: daily 107 | response_variable: daily_forecast 108 | sensor: 109 | - name: "Stuttgart-Echterdingen Hourly" 110 | unique_id: stuttgart_echterdingen_hourly 111 | state: "{{ states('weather.stuttgart_echterdingen') }}" 112 | attributes: 113 | temperature: "{{ state_attr('weather.stuttgart_echterdingen', 'temperature') }}" 114 | dew_point: "{{ state_attr('weather.stuttgart_echterdingen', 'dew_point') }}" 115 | temperature_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'temperature_unit') }}" 116 | humidity: "{{ state_attr('weather.stuttgart_echterdingen', 'humidity') }}" 117 | cloud_coverage: "{{ state_attr('weather.stuttgart_echterdingen', 'cloud_coverage') }}" 118 | pressure: "{{ state_attr('weather.stuttgart_echterdingen', 'pressure') }}" 119 | pressure_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'pressure_unit') }}" 120 | wind_bearing: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_bearing') }}" 121 | wind_gust_speed: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_gust_speed') }}" 122 | wind_speed: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_speed') }}" 123 | wind_speed_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_speed_unit') }}" 124 | visibility: "{{ state_attr('weather.stuttgart_echterdingen', 'visibility') }}" 125 | visibility_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'visibility_unit') }}" 126 | precipitation: "{{ state_attr('weather.stuttgart_echterdingen', 'precipitation') }}" 127 | precipitation_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'precipitation_unit') }}" 128 | forecast: "{{ hourly_forecast['weather.stuttgart_echterdingen'].forecast[:5] }}" 129 | - name: "Stuttgart-Echterdingen Daily" 130 | unique_id: stuttgart_echterdingen_daily 131 | state: "{{ states('weather.stuttgart_echterdingen') }}" 132 | attributes: 133 | temperature: "{{ state_attr('weather.stuttgart_echterdingen', 'temperature') }}" 134 | dew_point: "{{ state_attr('weather.stuttgart_echterdingen', 'dew_point') }}" 135 | temperature_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'temperature_unit') }}" 136 | humidity: "{{ state_attr('weather.stuttgart_echterdingen', 'humidity') }}" 137 | cloud_coverage: "{{ state_attr('weather.stuttgart_echterdingen', 'cloud_coverage') }}" 138 | pressure: "{{ state_attr('weather.stuttgart_echterdingen', 'pressure') }}" 139 | pressure_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'pressure_unit') }}" 140 | wind_bearing: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_bearing') }}" 141 | wind_gust_speed: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_gust_speed') }}" 142 | wind_speed: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_speed') }}" 143 | wind_speed_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'wind_speed_unit') }}" 144 | visibility: "{{ state_attr('weather.stuttgart_echterdingen', 'visibility') }}" 145 | visibility_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'visibility_unit') }}" 146 | precipitation: "{{ state_attr('weather.stuttgart_echterdingen', 'precipitation') }}" 147 | precipitation_unit: "{{ state_attr('weather.stuttgart_echterdingen', 'precipitation_unit') }}" 148 | forecast: "{{ daily_forecast['weather.stuttgart_echterdingen'].forecast[:5] }}" 149 | ``` 150 | 151 | To save resources, the template sensors above limit the forecasts to 5 items. If you need more, just change the `5` in `forecast[:5]` to a greater number. If the forecast array gets to large, you will see warnings from the Recorder in the logs that the state is too large to be stored in the history. 152 | 153 | After making changes to your template sensors, you can reload them in the YAML tab of the Developer Tools by selecting to reload the Template Entities. 154 | 155 | If everything went fine, you should find the two new senors in the States tab of the Developer Tools. They look pretty much like Weather entities, just that they are sensors with the "sensor" prefix instead of the "weather" prefix. Usually that doesn't disturb weather cards. They might just not show the entities in the selection list, but you can usually enter the ID manually. 156 | 157 | ## Why does the daily forecast for the current day differ from the Warnwetter app? 158 | 159 | Currently, the daily forecast only takes the future into account which means for the current day the remaining hours including the current one. This was the most straight forward way during implementation, but it also makes sense from a user's perspective, that a *forecast* only shows what's coming up and not what already happened. 160 | 161 | For example, if it rained the whole morning and the sun is going to shine the whole afternoon, and it's already afternoon, it's more useful to see the sun icon and not the rain oder mixed icon to know what's coming up. 162 | 163 | If this is an issue in your scenario, please open an [issue](https://github.com/hg1337/homeassistant-dwd/issues) so we can discuss this. 164 | 165 | ## What is the difference to https://github.com/FL550/dwd_weather? 166 | 167 | The reason that both exist is mainly because the other one didn’t exist yet when this one was started, so they were more or less developed in parallel. This one was just added to HACS much much later, and it was running privately at home for some time, before it was even put on GitHub. So none of the two is a clone or fork of the other. 168 | 169 | That‘s why when you look at the two integrations, they are quite different in their approach. For example, this one from the beginning on focused much on real measurements while the other one only uses forecast data on purpose. However, while this one supports „only“ on the official Weather Entity, the other one provides additional sensors. There are many other differences, best you compare and decide for yourself, which one better fits your needs. 170 | 171 | Because of the differences, it would be a huge effort to unify the two, and the outcome would probably rather be a third one. 172 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | [![Release](https://img.shields.io/github/v/release/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/releases) [![Hassfest Workflow Status](https://img.shields.io/github/actions/workflow/status/hg1337/homeassistant-dwd/hassfest.yml?label=Hassfest&style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/actions/workflows/hassfest.yml) [![License](https://img.shields.io/github/license/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/blob/main/LICENSE) [![Donation](https://img.shields.io/badge/Donation-Buy%20me%20a%20coffee-ffd557?style=for-the-badge)](https://www.buymeacoffee.com/hg1337) 2 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 3 | 4 | # Deutscher Wetterdienst (DWD) Setup 5 | 6 | Please read [README.md](./README.md) first, if you haven't already. 7 | 8 | - [Download](#download) 9 | - [Download via HACS](#download-via-hacs) 10 | - [Manual Download](#manual-download) 11 | - [Configuration](#configuration) 12 | 13 | ## Download 14 | 15 | As this integration is currently not part of Home Assistant Core, you have to download it first into your Home Assistant installation. The recommended way is via the [Home Assistant Community Store (HACS)](https://hacs.xyz), because it makes updates easier, but of course you can also do it manually, if you don't want to use HACS. 16 | 17 | ### Download via HACS 18 | 19 | The easiest way is by clicking on the following button. It will directly open the download page for this integration in HACS. 20 | 21 | [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) 22 | 23 | If you don't want to use the My Home Assistant button or if it doesn't work in your setup, follow these steps: 24 | 25 | 1. Open "HACS" from the Home Assistant main menu. 26 | 2. Select "Integrations". 27 | 3. Select "Explore & Dowload Repositories". 28 | 4. Search for "Deutscher Wetterdienst (by hg1337)" and select it. 29 | ![Screenshot Add Repository](./images/screenshot_hacs_add-repository.png) 30 | There is another integration named "Deutscher Wetterdienst" available, that's not this one. However, feel free to try out both. ;) 31 | 32 | Select "Download" and follow the instructions. To use the newly downloaded integration, you have to restart Home Assistant 33 | 34 | ### Manual Download 35 | 36 | For manual download, you need access to the config folder of Home Assistant. This depends on how you have installed Home Assistant. If you are using the Home Assistant Operating System, you may e.g. use the "Samba share" or the "Terminal & SSH" add-on. 37 | 38 | [![Open your Home Assistant instance and show the Supervisor add-on store.](https://my.home-assistant.io/badges/supervisor_store.svg)](https://my.home-assistant.io/redirect/supervisor_store/) 39 | 40 | These steps are based on the "Samba share" add-on, but other methods are quite similar. 41 | 42 | 1. Create a folder named "custom_components" within the config folder, if it doesn't already exist. 43 | 2. Optional: If you have Python installed and if you like, you may run [tools/generate_stations/generate_stations.py](tools/generate_stations/generate_stations.py) to update the station list. However, it shouldn't change too often, that's why it is "pre-compiled". 44 | 3. Copy the whole custom_components/dwd folder of this repository into the custom_components folder. I.e. your structure should in the end be /config/custom_components/dwd. 45 | ![Screenshot Installation Folder](./images/screenshot_installation-folder.png) 46 | 4. Restart Home Assistant. If you see a warning "You are using a custom integration dwd which has not been tested by Home Assistant." (and no errors of course) in the log, everything went well. 47 | 48 | ## Configuration 49 | 50 | To add the actual weather device and entities, just add a new instance of the "Deutscher Wetterdienst" integration: 51 | 52 | [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd) 53 | 54 | If you don't want to use the My Home Assistant button or if it doesn't work in your setup, follow these steps: 55 | 56 | 1. Open "Settings" from the Home Assistant main menu. 57 | 2. Select "Devices & Services". 58 | 3. Select "Add Integration". 59 | 4. Search for "Deutscher Wetterdienst" and select it. 60 | ![Screenshot Search Integration](./images/screenshot_search-integration.png) 61 | 62 | Follow the instructions, select a different station or enter a custom one if needed. By default, the closest station that provides measurement as well as forcast data is preselected, if it is not more than 20 km away and if the difference in elevation is less than 500 m. Otherwise the closest available station is preselected. 63 | 64 | After that, you should have one new service and one new weather entity for the selected station. 65 | 66 | ![Screenshot Service](./images/screenshot_service.png) 67 | 68 | ![Screenshot Entities](./images/screenshot_entities.png) 69 | 70 | You may use these entities with any component that supports weather entities, e.g. the standard Weather Forecast Card: 71 | 72 | ![Screenshot Weather Forecast Card Configuration](./images/screenshot_weather-forecast-card-configuration.png) 73 | 74 | I really like the custom weather card at https://github.com/bramkragten/weather-card. I started using that a long time ago, because it shows more information and allows more customizations than the standard Weather Forecast Card. 75 | 76 | ![Screenshot Weather Card](./images/screenshot_bramkragten-weather-card.png) 77 | 78 | The layout in the screenshot above was created using this configuration: 79 | 80 | ```yaml 81 | type: horizontal-stack 82 | cards: 83 | - type: custom:weather-card 84 | entity: weather.stuttgart_echterdingen 85 | current: true 86 | details: true 87 | forecast: false 88 | - type: custom:weather-card 89 | entity: weather.stuttgart_echterdingen 90 | current: false 91 | details: false 92 | forecast: true 93 | forecast_type: hourly 94 | hourly_forecast: true 95 | number_of_forecasts: '5' 96 | - type: custom:weather-card 97 | entity: weather.stuttgart_echterdingen 98 | current: false 99 | details: false 100 | forecast: true 101 | forecast_type: daily 102 | hourly_forecast: false 103 | number_of_forecasts: '5' 104 | ``` 105 | 106 | This integration also works well with the weather chart card from https://github.com/mlamberts78/weather-chart-card. 107 | 108 | ![Screenshot Weather Chart Card](./images/screenshot_mlamberts78-weather-chart-card.png) 109 | 110 | This is the configuration I used for the screenshot above: 111 | 112 | ```yaml 113 | type: vertical-stack 114 | cards: 115 | - type: custom:weather-chart-card 116 | entity: weather.stuttgart_echterdingen 117 | show_main: true 118 | show_temperature: true 119 | show_current_condition: true 120 | show_attributes: true 121 | show_time: false 122 | show_time_seconds: false 123 | show_day: false 124 | show_date: false 125 | show_humidity: true 126 | show_pressure: true 127 | show_wind_direction: true 128 | show_wind_speed: true 129 | show_sun: true 130 | show_feels_like: false 131 | show_dew_point: true 132 | show_wind_gust_speed: true 133 | show_visibility: true 134 | show_last_changed: false 135 | use_12hour_format: false 136 | icons_size: 25 137 | animated_icons: false 138 | icon_style: style1 139 | forecast: 140 | precipitation_type: rainfall 141 | show_probability: true 142 | labels_font_size: '11' 143 | precip_bar_size: '100' 144 | style: style1 145 | show_wind_forecast: true 146 | condition_icons: true 147 | round_temp: false 148 | type: hourly 149 | number_of_forecasts: '0' 150 | disable_animation: false 151 | units: 152 | pressure: '' 153 | speed: '' 154 | - type: custom:weather-chart-card 155 | entity: weather.stuttgart_echterdingen 156 | show_main: false 157 | show_temperature: false 158 | show_current_condition: false 159 | show_attributes: false 160 | show_time: false 161 | show_time_seconds: false 162 | show_day: false 163 | show_date: false 164 | show_humidity: false 165 | show_pressure: false 166 | show_wind_direction: false 167 | show_wind_speed: false 168 | show_sun: false 169 | show_feels_like: false 170 | show_dew_point: false 171 | show_wind_gust_speed: false 172 | show_visibility: false 173 | show_last_changed: false 174 | use_12hour_format: false 175 | icons_size: 25 176 | animated_icons: false 177 | icon_style: style1 178 | forecast: 179 | precipitation_type: rainfall 180 | show_probability: false 181 | labels_font_size: '11' 182 | precip_bar_size: '100' 183 | style: style1 184 | show_wind_forecast: true 185 | condition_icons: true 186 | round_temp: false 187 | type: daily 188 | number_of_forecasts: '0' 189 | disable_animation: false 190 | units: 191 | pressure: '' 192 | speed: '' 193 | ``` 194 | -------------------------------------------------------------------------------- /tools/generate_stations/generate_stations.py: -------------------------------------------------------------------------------- 1 | # Takes station information from the following URLs and creates the stations.json file: 2 | # - https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication 3 | # - https://opendata.dwd.de/weather/weather_reports/poi/ 4 | # - https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/ 5 | # As the station list doesn't change frequently, we don't download everything online in the dwd component. 6 | # See also https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg 7 | 8 | import json 9 | import os 10 | import re 11 | import urllib.request 12 | import codecs 13 | import datetime 14 | from html.parser import HTMLParser 15 | 16 | SOURCE_STATIONSLEXIKON = 0 17 | SOURCE_MOSMIX_STATIONSKATALOG = 1 18 | 19 | NAME_RE = re.compile("[A-ZÄÖÜ]{2,}") 20 | 21 | def beautify_name(name: str) -> str: 22 | return NAME_RE.sub(lambda x: x.group()[0] + x.group()[1:].lower(), name) 23 | 24 | if __name__ == "__main__": 25 | 26 | measurement_href_pattern = re.compile(r"^(.*[^_])_*-BEOB\.csv$") 27 | forecast_href_pattern = re.compile(r"^(.+)/$") 28 | mosmix_stationskatalog_pattern = re.compile(r"^([^ ]+)[ ]+([^ ]+)[ ]+(.+[^ ])[ ]+(-?[0-9]+\.[0-9]+)[ ]+(-?[0-9]+\.[0-9]+)[ ]+(-?[0-9]+?)$") 29 | 30 | class HtmlStationListParser(HTMLParser): 31 | 32 | def __init__(self, href_pattern: re.Pattern) -> None: 33 | self._href_pattern = href_pattern 34 | self.result = set() 35 | super().__init__() 36 | 37 | def handle_starttag(self, tag, attrs): 38 | if tag == "a": 39 | for attr in attrs: 40 | if attr[0] == "href": 41 | match = self._href_pattern.match(attr[1]) 42 | if match: 43 | self.result.add(match.groups()[0]) 44 | 45 | class HtmlStationslexikonParser(HTMLParser): 46 | def __init__(self) -> None: 47 | self.result = {} 48 | self._current_row = None 49 | self._current_content = None 50 | super().__init__() 51 | 52 | def handle_starttag(self, tag, attrs): 53 | if tag == "tr": 54 | self._current_row = [] 55 | 56 | def handle_endtag(self, tag): 57 | if tag == "tr": 58 | if len(self._current_row) >= 11: 59 | name = self._current_row[0] 60 | feature = self._current_row[2] 61 | station_id = self._current_row[3] 62 | latitude = self._current_row[4] 63 | longitude = self._current_row[5] 64 | altitude = self._current_row[6] 65 | start = self._current_row[9] 66 | end = self._current_row[10] 67 | if name and feature and station_id and latitude and longitude and altitude and start and end: 68 | startdate = datetime.datetime.strptime(start, r"%d.%m.%Y").date() 69 | enddate = datetime.datetime.strptime(end, r"%d.%m.%Y").date() 70 | if startdate < datetime.date.today() + datetime.timedelta(days=7) and enddate > datetime.date.today() - datetime.timedelta(days=7) and feature in ["SY", "TU"]: 71 | self.result[station_id] = (name, latitude, longitude, altitude) 72 | self._current_content = None 73 | self._current_row = None 74 | elif tag == "td": 75 | self._current_row.append(self._current_content) 76 | self._current_content = None 77 | 78 | def handle_data(self, data): 79 | if self._current_row is not None: 80 | self._current_content = data.strip() 81 | 82 | result = [] 83 | 84 | measurement_stations = None 85 | forecast_stations = None 86 | stationslexikon_stations = None 87 | 88 | url = "https://rcc.dwd.de/DE/leistungen/klimadatendeutschland/statliste/statlex_html.html?view=nasPublication" 89 | print(f"Loading and parsing Stationslexikon from {url}...", end="", flush=True) 90 | with urllib.request.urlopen(url) as response: 91 | stationslexikon_parser = HtmlStationslexikonParser() 92 | stationslexikon_parser.feed(codecs.decode(response.read(), 'iso-8859-1')) 93 | stationslexikon_stations = stationslexikon_parser.result 94 | print(f"done.") 95 | print(f"Found useful information about {len(stationslexikon_stations)} stations.") 96 | 97 | url = "https://opendata.dwd.de/weather/weather_reports/poi/" 98 | print(f"Checking which stations provide measurement data at {url}...", end="", flush=True) 99 | with urllib.request.urlopen(url) as response: 100 | measurement_parser = HtmlStationListParser(measurement_href_pattern) 101 | measurement_parser.feed(codecs.decode(response.read(), 'iso-8859-1')) 102 | measurement_stations = measurement_parser.result 103 | print(f"done.") 104 | print(f"Found {len(measurement_stations)} stations.") 105 | 106 | url = "https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/" 107 | print(f"Checking which stations provide forecast data at {url}...", end="", flush=True) 108 | with urllib.request.urlopen(url) as response: 109 | forecast_parser = HtmlStationListParser(forecast_href_pattern) 110 | forecast_parser.feed(codecs.decode(response.read(), 'iso-8859-1')) 111 | forecast_stations = forecast_parser.result 112 | print(f"done.") 113 | print(f"Found {len(forecast_stations)} stations.") 114 | 115 | url = "https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication" 116 | print(f"Getting MISMIX Stationskatalog from {url} and combining all information...", end="", flush=True) 117 | with urllib.request.urlopen(url) as response: 118 | for line in response: 119 | line = codecs.decode(line, 'iso-8859-1') 120 | match = mosmix_stationskatalog_pattern.match(line) 121 | if match: 122 | station_id = match.groups()[0] 123 | station_measurement = (station_id in measurement_stations) 124 | station_forecast = (station_id in forecast_stations) 125 | if station_measurement or station_forecast: 126 | data_from_stationslexikon = stationslexikon_stations.get(station_id, None) 127 | if data_from_stationslexikon is None: 128 | station_name = beautify_name(match.groups()[2]) 129 | station_latitude = float(match.groups()[3]) 130 | station_longitude = float(match.groups()[4]) 131 | station_altitude = float(match.groups()[5]) 132 | station_source = SOURCE_MOSMIX_STATIONSKATALOG 133 | else: 134 | station_name = data_from_stationslexikon[0] 135 | station_latitude = float(data_from_stationslexikon[1]) 136 | station_longitude = float(data_from_stationslexikon[2]) 137 | station_altitude = float(data_from_stationslexikon[3]) 138 | station_source = SOURCE_STATIONSLEXIKON 139 | result.append({"id": station_id, "name": station_name, "latitude": station_latitude, "longitude": station_longitude, "altitude": station_altitude, "measurement": station_measurement, "forecast": station_forecast, "source": station_source}) 140 | print(f"done.") 141 | print(f"Result contains {len(result)} stations providing measurement or forcast data.") 142 | 143 | print(f"Sorting list by station name...", end="", flush=True) 144 | result = list(sorted(result, key=lambda x: x["name"].casefold())) 145 | print(f"done.") 146 | 147 | filename = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "custom_components", "dwd", "stations.json") 148 | print(f"Writing stations to {filename}...", end="", flush=True) 149 | with open(filename, "wt", encoding="utf-8") as file: 150 | json.dump(result, file, ensure_ascii=False) 151 | print(f"done.") 152 | 153 | filename = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "stations.md") 154 | print(f"Writing stations to {filename}...", end="", flush=True) 155 | with open(filename, "wt", encoding="utf-8") as file: 156 | file.write("[![Release](https://img.shields.io/github/v/release/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/releases) [![Hassfest Workflow Status](https://img.shields.io/github/actions/workflow/status/hg1337/homeassistant-dwd/hassfest.yml?label=Hassfest&style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/actions/workflows/hassfest.yml) [![License](https://img.shields.io/github/license/hg1337/homeassistant-dwd?style=for-the-badge)](https://github.com/hg1337/homeassistant-dwd/blob/main/LICENSE) [![Donation](https://img.shields.io/badge/Donation-Buy%20me%20a%20coffee-ffd557?style=for-the-badge)](https://www.buymeacoffee.com/hg1337) \n") 157 | file.write("[![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hg1337&repository=homeassistant-dwd&category=integration) [![Open your Home Assistant instance and start setting up this integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=dwd)\n") 158 | file.write("\n") 159 | file.write("# Deutscher Wetterdienst Stations\n") 160 | file.write("\n") 161 | file.write("Please read [README.md](./README.md) first, if you haven't already.\n") 162 | file.write("\n") 163 | file.write("The following table lists the stations of Deutscher Wetterdienst that provide\n") 164 | file.write("measurement or forecast data and can probably be used by this Home Assistant\n") 165 | file.write(f"integration. It contains a total of {len(result)} stations and was automatically\n") 166 | file.write("generated by\n") 167 | file.write("[tools/generate_stations/generate_stations.py](./tools/generate_stations/generate_stations.py)\n") 168 | file.write("using information provided by Deutscher Wetterdienst.\n") 169 | file.write("\n") 170 | file.write(f"{sum(1 for _ in filter(lambda x: x['source'] == SOURCE_STATIONSLEXIKON, result))} stations were found in the\n") 171 | file.write("[Stationslexikon](https://rcc.dwd.de/DE/leistungen/klimadatendeutschland/stationsliste.html)\n") 172 | file.write(f"and additional {sum(1 for _ in filter(lambda x: x['source'] == SOURCE_MOSMIX_STATIONSKATALOG, result))} stations were found in the\n") 173 | file.write("[MOSMIX Stationskatalog](https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg)\n") 174 | file.write(f"that were not listed in the Stationslexikon. The Stationslexikon is preferred\n") 175 | file.write("because it contains more precise geo coordinates and nicer names.\n") 176 | file.write("\n") 177 | file.write(f"{sum(1 for _ in filter(lambda x: x['measurement'], result))} of these stations provide [measurement data](https://opendata.dwd.de/weather/weather_reports/poi/),\n") 178 | file.write(f"{sum(1 for _ in filter(lambda x: x['forecast'], result))} provide [forecast data](https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/)\n") 179 | file.write(f"and {sum(1 for _ in filter(lambda x: x['measurement'] and x['forecast'], result))} provide both.\n") 180 | file.write("\n") 181 | file.write("Although this list is generated from original data from Deutscher Wetterdienst,\n") 182 | file.write("this is no offical list and it is not updated automatically. There might also be\n") 183 | file.write("stations that are not in this list that work as well. So when in doubt, please\n") 184 | file.write("check the original lists above and try their ID.\n") 185 | file.write("\n") 186 | file.write("ID | Name | Latitude | Longitude | Altitude | Limitations | Source \n") 187 | file.write("---------|-----------------------------|-------------|-------------|----------|---------------------|-----------------------\n") 188 | for station in result: 189 | file.write(station["id"].ljust(9)) 190 | file.write("| ") 191 | file.write(station["name"].ljust(28)) 192 | file.write("| ") 193 | file.write(str(station["latitude"]).ljust(12)) 194 | file.write("| ") 195 | file.write(str(station["longitude"]).ljust(12)) 196 | file.write("| ") 197 | file.write(str(station["altitude"]).ljust(9)) 198 | file.write("| ") 199 | if not station["measurement"]: 200 | file.write("no measurement data ") 201 | elif not station["forecast"]: 202 | file.write("no forecast data ") 203 | else: 204 | file.write(" ") 205 | file.write("| "); 206 | if station["source"] == SOURCE_STATIONSLEXIKON: 207 | file.write("Stationslexikon") 208 | elif station["source"] == SOURCE_MOSMIX_STATIONSKATALOG: 209 | file.write("MOSMIX Stationskatalog") 210 | else: 211 | file.write(" ") 212 | file.write("\n") 213 | print(f"done.") 214 | --------------------------------------------------------------------------------