├── img ├── 0-d.png ├── 0-n.png ├── 1-d.png ├── 1-n.png ├── 2-d.png ├── 2-n.png ├── 3-d.png ├── 3-n.png ├── 4-d.png ├── 4-n.png ├── 5-d.png ├── 5-n.png ├── 6-d.png ├── 6-n.png ├── 7-d.png ├── 7-n.png ├── 8-d.png ├── 8-n.png ├── 9-d.png ├── 9-n.png ├── 10-d.png ├── 10-n.png ├── 11-d.png ├── 11-n.png ├── 12-d.png ├── 12-n.png ├── 13-d.png ├── 13-n.png ├── 14-d.png ├── 14-n.png ├── 15-d.png ├── 15-n.png ├── 16-d.png ├── 16-n.png ├── 17-d.png ├── 17-n.png ├── 18-d.png ├── 18-n.png ├── 19-d.png ├── 19-n.png ├── 20-d.png ├── 20-n.png ├── 21-d.png ├── 21-n.png ├── 22-d.png ├── 22-n.png ├── 23-d.png ├── 23-n.png ├── 24-d.png ├── 24-n.png ├── 25-d.png ├── 25-n.png ├── 26-d.png ├── 26-n.png ├── 27-d.png ├── 27-n.png ├── monday.png ├── forecast.png ├── pollens.png ├── sensors.png ├── camera_dark.png ├── camera_sat.png └── camera_light.png ├── requirements.txt ├── setup.cfg ├── tests ├── fixtures │ ├── clouds_be.png │ ├── clouds_nl.png │ ├── loc_layer_nl.png │ ├── loc_layer_be_n.png │ ├── loc_layer_nl_d.png │ ├── pollen.svg │ ├── pollens-2025.svg │ └── new_two_pollens.svg ├── __init__.py ├── test_pollen.py ├── test_current_weather_sensors.py ├── test_init.py ├── test_coordinator.py ├── test_config_flow.py ├── test_repairs.py ├── conftest.py ├── test_weather.py └── test_sensors.py ├── custom_components └── irm_kmi │ ├── icons.json │ ├── services.yaml │ ├── manifest.json │ ├── data.py │ ├── utils.py │ ├── binary_sensor.py │ ├── camera.py │ ├── __init__.py │ ├── repairs.py │ ├── translations │ ├── en.json │ ├── nl.json │ ├── pt.json │ └── fr.json │ ├── coordinator.py │ ├── config_flow.py │ ├── const.py │ ├── weather.py │ └── sensor.py ├── requirements_tests.txt ├── hacs.json ├── .github ├── workflows │ ├── validate.yml │ ├── release.yml │ └── pytest.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /img/0-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/0-d.png -------------------------------------------------------------------------------- /img/0-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/0-n.png -------------------------------------------------------------------------------- /img/1-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/1-d.png -------------------------------------------------------------------------------- /img/1-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/1-n.png -------------------------------------------------------------------------------- /img/2-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/2-d.png -------------------------------------------------------------------------------- /img/2-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/2-n.png -------------------------------------------------------------------------------- /img/3-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/3-d.png -------------------------------------------------------------------------------- /img/3-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/3-n.png -------------------------------------------------------------------------------- /img/4-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/4-d.png -------------------------------------------------------------------------------- /img/4-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/4-n.png -------------------------------------------------------------------------------- /img/5-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/5-d.png -------------------------------------------------------------------------------- /img/5-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/5-n.png -------------------------------------------------------------------------------- /img/6-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/6-d.png -------------------------------------------------------------------------------- /img/6-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/6-n.png -------------------------------------------------------------------------------- /img/7-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/7-d.png -------------------------------------------------------------------------------- /img/7-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/7-n.png -------------------------------------------------------------------------------- /img/8-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/8-d.png -------------------------------------------------------------------------------- /img/8-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/8-n.png -------------------------------------------------------------------------------- /img/9-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/9-d.png -------------------------------------------------------------------------------- /img/9-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/9-n.png -------------------------------------------------------------------------------- /img/10-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/10-d.png -------------------------------------------------------------------------------- /img/10-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/10-n.png -------------------------------------------------------------------------------- /img/11-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/11-d.png -------------------------------------------------------------------------------- /img/11-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/11-n.png -------------------------------------------------------------------------------- /img/12-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/12-d.png -------------------------------------------------------------------------------- /img/12-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/12-n.png -------------------------------------------------------------------------------- /img/13-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/13-d.png -------------------------------------------------------------------------------- /img/13-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/13-n.png -------------------------------------------------------------------------------- /img/14-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/14-d.png -------------------------------------------------------------------------------- /img/14-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/14-n.png -------------------------------------------------------------------------------- /img/15-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/15-d.png -------------------------------------------------------------------------------- /img/15-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/15-n.png -------------------------------------------------------------------------------- /img/16-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/16-d.png -------------------------------------------------------------------------------- /img/16-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/16-n.png -------------------------------------------------------------------------------- /img/17-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/17-d.png -------------------------------------------------------------------------------- /img/17-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/17-n.png -------------------------------------------------------------------------------- /img/18-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/18-d.png -------------------------------------------------------------------------------- /img/18-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/18-n.png -------------------------------------------------------------------------------- /img/19-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/19-d.png -------------------------------------------------------------------------------- /img/19-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/19-n.png -------------------------------------------------------------------------------- /img/20-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/20-d.png -------------------------------------------------------------------------------- /img/20-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/20-n.png -------------------------------------------------------------------------------- /img/21-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/21-d.png -------------------------------------------------------------------------------- /img/21-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/21-n.png -------------------------------------------------------------------------------- /img/22-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/22-d.png -------------------------------------------------------------------------------- /img/22-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/22-n.png -------------------------------------------------------------------------------- /img/23-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/23-d.png -------------------------------------------------------------------------------- /img/23-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/23-n.png -------------------------------------------------------------------------------- /img/24-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/24-d.png -------------------------------------------------------------------------------- /img/24-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/24-n.png -------------------------------------------------------------------------------- /img/25-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/25-d.png -------------------------------------------------------------------------------- /img/25-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/25-n.png -------------------------------------------------------------------------------- /img/26-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/26-d.png -------------------------------------------------------------------------------- /img/26-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/26-n.png -------------------------------------------------------------------------------- /img/27-d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/27-d.png -------------------------------------------------------------------------------- /img/27-n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/27-n.png -------------------------------------------------------------------------------- /img/monday.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/monday.png -------------------------------------------------------------------------------- /img/forecast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/forecast.png -------------------------------------------------------------------------------- /img/pollens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/pollens.png -------------------------------------------------------------------------------- /img/sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/sensors.png -------------------------------------------------------------------------------- /img/camera_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/camera_dark.png -------------------------------------------------------------------------------- /img/camera_sat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/camera_sat.png -------------------------------------------------------------------------------- /img/camera_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/img/camera_light.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.11.13 2 | homeassistant==2025.6.1 3 | voluptuous==0.15.2 4 | irm-kmi-api==0.2.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | norecursedirs = .git 4 | addopts = -s -v 5 | asyncio_mode = auto -------------------------------------------------------------------------------- /tests/fixtures/clouds_be.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/tests/fixtures/clouds_be.png -------------------------------------------------------------------------------- /tests/fixtures/clouds_nl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/tests/fixtures/clouds_nl.png -------------------------------------------------------------------------------- /tests/fixtures/loc_layer_nl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/tests/fixtures/loc_layer_nl.png -------------------------------------------------------------------------------- /tests/fixtures/loc_layer_be_n.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/tests/fixtures/loc_layer_be_n.png -------------------------------------------------------------------------------- /tests/fixtures/loc_layer_nl_d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdejaegh/irm-kmi-ha/HEAD/tests/fixtures/loc_layer_nl_d.png -------------------------------------------------------------------------------- /custom_components/irm_kmi/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "get_forecasts_radar": "mdi:weather-cloudy-clock" 4 | } 5 | } -------------------------------------------------------------------------------- /requirements_tests.txt: -------------------------------------------------------------------------------- 1 | homeassistant==2025.6.1 2 | pytest_homeassistant_custom_component==0.13.252 3 | pytest 4 | freezegun 5 | isort 6 | bumpver -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "IRM KMI Belgian weather", 3 | "country": ["BE", "NL", "LU"], 4 | "render_readme": true, 5 | "homeassistant": "2024.6.0" 6 | } -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the IRM KMI custom integration.""" 2 | # Test suite inspired by https://github.com/home-assistant/core/tree/dev/tests/components/open_meteo 3 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/services.yaml: -------------------------------------------------------------------------------- 1 | get_forecasts_radar: 2 | target: 3 | entity: 4 | integration: irm_kmi 5 | domain: weather 6 | fields: 7 | include_past_forecasts: 8 | required: false 9 | default: false 10 | selector: 11 | boolean: 12 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "irm_kmi", 3 | "name": "IRM KMI Weather Belgium", 4 | "codeowners": ["@jdejaegh"], 5 | "config_flow": true, 6 | "dependencies": ["zone"], 7 | "documentation": "https://github.com/jdejaegh/irm-kmi-ha/", 8 | "integration_type": "service", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues", 11 | "requirements": [ 12 | "irm-kmi-api==0.2.0" 13 | ], 14 | "version": "0.3.2" 15 | } -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | validate-hassfest: 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | - uses: "actions/checkout@v3" 23 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.bumpver] 2 | current_version = "0.3.2" 3 | version_pattern = "MAJOR.MINOR.PATCH" 4 | commit_message = "bump version {old_version} -> {new_version}" 5 | tag_message = "{new_version}" 6 | tag_scope = "default" 7 | pre_commit_hook = "" 8 | post_commit_hook = "" 9 | commit = true 10 | tag = true 11 | push = true 12 | 13 | [tool.bumpver.file_patterns] 14 | "pyproject.toml" = [ 15 | 'current_version = "{version}"', 16 | ] 17 | "custom_components/irm_kmi/manifest.json" = ['"version": "{version}"'] 18 | "custom_components/irm_kmi/const.py" = ["'github.com/jdejaegh/irm-kmi-ha {version}'"] -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | tests: 13 | uses: ./.github/workflows/pytest.yml 14 | release: 15 | name: Release pushed tag 16 | needs: [tests] 17 | runs-on: ubuntu-22.04 18 | steps: 19 | - name: Create release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | tag: ${{ github.ref_name }} 23 | run: | 24 | gh release create "$tag" \ 25 | --repo="$GITHUB_REPOSITORY" \ 26 | --title="${tag#v}" \ 27 | --generate-notes -------------------------------------------------------------------------------- /custom_components/irm_kmi/data.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypedDict 2 | 3 | from homeassistant.components.weather import Forecast 4 | from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData 5 | from irm_kmi_api.rain_graph import RainGraph 6 | 7 | 8 | class ProcessedCoordinatorData(TypedDict, total=False): 9 | """Data class that will be exposed to the entities consuming data from an IrmKmiCoordinator""" 10 | current_weather: CurrentWeatherData 11 | hourly_forecast: List[Forecast] | None 12 | daily_forecast: List[IrmKmiForecast] | None 13 | radar_forecast: List[Forecast] | None 14 | animation: RainGraph | None 15 | warnings: List[WarningData] 16 | pollen: dict 17 | country: str 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Describe alternatives you've considered** 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /tests/test_pollen.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from homeassistant.core import HomeAssistant 4 | from irm_kmi_api.api import IrmKmiApiError 5 | from irm_kmi_api.pollen import PollenParser 6 | from pytest_homeassistant_custom_component.common import MockConfigEntry 7 | 8 | from custom_components.irm_kmi import IrmKmiCoordinator 9 | from tests.conftest import get_api_with_data 10 | 11 | 12 | async def test_pollen_error_leads_to_unavailable_on_first_call( 13 | hass: HomeAssistant, 14 | mock_config_entry: MockConfigEntry, 15 | ) -> None: 16 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 17 | api = get_api_with_data("be_forecast_warning.json") 18 | 19 | api.get_svg = AsyncMock() 20 | api.get_svg.side_effect = IrmKmiApiError 21 | 22 | coordinator._api = api 23 | 24 | result = await coordinator.process_api_data() 25 | expected = PollenParser.get_unavailable_data() 26 | assert result['pollen'] == expected 27 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Run Python tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_call: 7 | 8 | jobs: 9 | build: 10 | name: Run tests 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.13"] 15 | 16 | steps: 17 | - uses: MathRobin/timezone-action@v1.1 18 | with: 19 | timezoneLinux: "Europe/Brussels" 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: pip install pytest pytest-md pytest-emoji 27 | - name: Install requirements 28 | run: pip install -r requirements.txt && pip install -r requirements_tests.txt 29 | - uses: pavelzw/pytest-action@v2 30 | with: 31 | emoji: false 32 | verbose: false 33 | job-summary: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 Jules Dejaeghere 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers import device_registry 7 | 8 | from .const import CONF_LANGUAGE_OVERRIDE, LANGS 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | def disable_from_config(hass: HomeAssistant, config_entry: ConfigEntry): 14 | modify_from_config(hass, config_entry.entry_id, False) 15 | 16 | 17 | def enable_from_config(hass: HomeAssistant, config_entry: ConfigEntry): 18 | modify_from_config(hass, config_entry.entry_id, True) 19 | 20 | 21 | def modify_from_config(hass: HomeAssistant, config_entry_id: str, enable: bool): 22 | dr = device_registry.async_get(hass) 23 | devices = device_registry.async_entries_for_config_entry(dr, config_entry_id) 24 | _LOGGER.info(f"Trying to {'enable' if enable else 'disable'} {config_entry_id}: {len(devices)} device(s)") 25 | for device in devices: 26 | dr.async_update_device(device_id=device.id, 27 | disabled_by=None if enable else device_registry.DeviceEntryDisabler.INTEGRATION) 28 | 29 | 30 | def get_config_value(config_entry: ConfigEntry, key: str) -> Any: 31 | if config_entry.options and key in config_entry.options: 32 | return config_entry.options[key] 33 | return config_entry.data[key] 34 | 35 | 36 | def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry) -> str: 37 | if get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE) == 'none': 38 | return hass.config.language if hass.config.language in LANGS else 'en' 39 | 40 | return get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE) 41 | 42 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 19 | 20 | **Describe the bug** 21 | A clear and concise description of what the bug is. 22 | 23 | **Checklist** 24 | - [ ] I included debug logs or at least a screenshot of the IRM KMI official app 25 | - [ ] If I use a custom card, I checked if the stock Lovelace weather card is working 26 | 27 | **To Reproduce** 28 | Steps to reproduce the behavior: 29 | 1. Go to '...' 30 | 2. Click on '....' 31 | 3. Scroll down to '....' 32 | 4. See error 33 | 34 | **Expected behavior** 35 | A clear and concise description of what you expected to happen. 36 | 37 | **Screenshots** 38 | If applicable, add screenshots to help explain your problem. 39 | 40 | **Version** 41 | - Home Assistant: [e.g. 2024.1.3] 42 | - IRM KMI integration [e.g. 0.2.0] 43 | 44 | **Additional context** 45 | Add any other context about the problem here. If you use a custom card or component alongside the integration, mention it and include the link to the repository. 46 | -------------------------------------------------------------------------------- /tests/test_current_weather_sensors.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from zoneinfo import ZoneInfo 3 | 4 | import pytest 5 | from homeassistant.core import HomeAssistant 6 | from irm_kmi_api.data import CurrentWeatherData 7 | from pytest_homeassistant_custom_component.common import MockConfigEntry 8 | 9 | from custom_components.irm_kmi import IrmKmiCoordinator 10 | from custom_components.irm_kmi.const import (CURRENT_WEATHER_SENSOR_CLASS, 11 | CURRENT_WEATHER_SENSOR_UNITS, 12 | CURRENT_WEATHER_SENSORS) 13 | from custom_components.irm_kmi.data import ProcessedCoordinatorData 14 | from custom_components.irm_kmi.sensor import IrmKmiCurrentRainfall 15 | from tests.conftest import get_api_with_data 16 | 17 | 18 | def test_sensors_in_current_weather_data(): 19 | weather_data_keys = inspect.get_annotations(CurrentWeatherData).keys() 20 | 21 | for sensor in CURRENT_WEATHER_SENSORS: 22 | assert sensor in weather_data_keys 23 | 24 | def test_sensors_have_unit(): 25 | weather_sensor_units_keys = CURRENT_WEATHER_SENSOR_UNITS.keys() 26 | 27 | for sensor in CURRENT_WEATHER_SENSORS: 28 | assert sensor in weather_sensor_units_keys 29 | 30 | def test_sensors_have_class(): 31 | weather_sensor_class_keys = CURRENT_WEATHER_SENSOR_CLASS.keys() 32 | 33 | for sensor in CURRENT_WEATHER_SENSORS: 34 | assert sensor in weather_sensor_class_keys 35 | 36 | 37 | 38 | @pytest.mark.parametrize("expected,filename", 39 | [ 40 | ('mm/h', 'forecast_ams_no_ww.json'), 41 | ('mm/10min', 'forecast_out_of_benelux.json'), 42 | ('mm/10min', 'forecast_with_rain_on_radar.json'), 43 | ]) 44 | async def test_current_rainfall_unit( 45 | hass: HomeAssistant, 46 | mock_config_entry: MockConfigEntry, 47 | expected, 48 | filename 49 | ) -> None: 50 | hass.config.time_zone = 'Europe/Brussels' 51 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 52 | api = get_api_with_data(filename) 53 | tz = ZoneInfo("Europe/Brussels") 54 | 55 | coordinator.data = ProcessedCoordinatorData( 56 | current_weather=api.get_current_weather(tz), 57 | hourly_forecast=api.get_hourly_forecast(tz), 58 | radar_forecast=api.get_radar_forecast(), 59 | country=api.get_country() 60 | ) 61 | 62 | s = IrmKmiCurrentRainfall(coordinator, mock_config_entry) 63 | 64 | assert s.native_unit_of_measurement == expected 65 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor to signal weather warning from the IRM KMI""" 2 | import logging 3 | 4 | from homeassistant.components import binary_sensor 5 | from homeassistant.components.binary_sensor import (BinarySensorDeviceClass, 6 | BinarySensorEntity) 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | from homeassistant.util import dt 12 | 13 | from . import DOMAIN, IrmKmiCoordinator 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 19 | """Set up the binary platform""" 20 | coordinator = hass.data[DOMAIN][entry.entry_id] 21 | async_add_entities([IrmKmiWarning(coordinator, entry)]) 22 | 23 | 24 | class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity): 25 | """Representation of a weather warning binary sensor""" 26 | 27 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 28 | 29 | def __init__(self, 30 | coordinator: IrmKmiCoordinator, 31 | entry: ConfigEntry 32 | ) -> None: 33 | super().__init__(coordinator) 34 | BinarySensorEntity.__init__(self) 35 | self._attr_device_class = BinarySensorDeviceClass.SAFETY 36 | self._attr_unique_id = entry.entry_id 37 | self.entity_id = binary_sensor.ENTITY_ID_FORMAT.format(f"weather_warning_{str(entry.title).lower()}") 38 | self._attr_name = f"Warning {entry.title}" 39 | self._attr_device_info = coordinator.shared_device_info 40 | 41 | @property 42 | def is_on(self) -> bool | None: 43 | if self.coordinator.data.get('warnings') is None: 44 | return False 45 | 46 | now = dt.now() 47 | for item in self.coordinator.data.get('warnings'): 48 | if item.get('starts_at') < now < item.get('ends_at'): 49 | return True 50 | 51 | return False 52 | 53 | @property 54 | def extra_state_attributes(self) -> dict: 55 | """Return the warning sensor attributes.""" 56 | attrs = {"warnings": self.coordinator.data.get('warnings', [])} 57 | 58 | now = dt.now() 59 | for warning in attrs['warnings']: 60 | warning['is_active'] = warning.get('starts_at') < now < warning.get('ends_at') 61 | 62 | attrs["active_warnings_friendly_names"] = ", ".join([warning['friendly_name'] for warning in attrs['warnings'] 63 | if warning['is_active'] and warning['friendly_name'] != '']) 64 | 65 | return attrs 66 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/camera.py: -------------------------------------------------------------------------------- 1 | """Create a radar view for IRM KMI weather""" 2 | 3 | import logging 4 | 5 | from aiohttp import web 6 | from homeassistant.components.camera import Camera, async_get_still_stream 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | 12 | from . import IrmKmiCoordinator 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 19 | """Set up the camera entry.""" 20 | 21 | coordinator = hass.data[DOMAIN][entry.entry_id] 22 | async_add_entities([IrmKmiRadar(coordinator, entry)]) 23 | 24 | 25 | class IrmKmiRadar(CoordinatorEntity, Camera): 26 | """Representation of a radar view camera.""" 27 | 28 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 29 | 30 | def __init__(self, 31 | coordinator: IrmKmiCoordinator, 32 | entry: ConfigEntry, 33 | ) -> None: 34 | """Initialize IrmKmiRadar component.""" 35 | super().__init__(coordinator) 36 | Camera.__init__(self) 37 | self.content_type = 'image/svg+xml' 38 | self._name = f"Radar {entry.title}" 39 | self._attr_unique_id = entry.entry_id 40 | self._attr_device_info = coordinator.shared_device_info 41 | 42 | self._image_index = False 43 | 44 | @property 45 | def frame_interval(self) -> float: 46 | """Return the interval between frames of the mjpeg stream.""" 47 | return 1 48 | 49 | async def async_camera_image( 50 | self, 51 | width: int | None = None, 52 | height: int | None = None 53 | ) -> bytes | None: 54 | """Return still image to be used as thumbnail.""" 55 | if self.coordinator.data.get('animation', None) is not None: 56 | return await self.coordinator.data.get('animation').get_still() 57 | return None 58 | 59 | async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse: 60 | """Generate an HTTP MJPEG stream from camera images.""" 61 | self._image_index = False 62 | return await async_get_still_stream(request, self.get_animated_svg, self.content_type, interval) 63 | 64 | async def handle_async_mjpeg_stream(self, request: web.Request) -> web.StreamResponse: 65 | """Serve an HTTP MJPEG stream from the camera.""" 66 | return await self.handle_async_still_stream(request, self.frame_interval) 67 | 68 | async def get_animated_svg(self) -> bytes | None: 69 | """Returns the animated svg for camera display""" 70 | # If this is not done this way, the live view can only be opened once 71 | self._image_index = not self._image_index 72 | if self._image_index and self.coordinator.data.get('animation', None) is not None: 73 | return await self.coordinator.data.get('animation').get_animated() 74 | else: 75 | return None 76 | 77 | @property 78 | def name(self) -> str: 79 | """Return the name of this camera.""" 80 | return self._name 81 | 82 | @property 83 | def extra_state_attributes(self) -> dict: 84 | """Return the camera state attributes.""" 85 | rain_graph = self.coordinator.data.get('animation', None) 86 | hint = rain_graph.get_hint() if rain_graph is not None else None 87 | attrs = {"hint": hint} 88 | return attrs 89 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration for IRM KMI weather""" 2 | 3 | # File inspired from https://github.com/ludeeus/integration_blueprint 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.exceptions import ConfigEntryError 9 | from irm_kmi_api.const import OPTION_STYLE_STD 10 | 11 | from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE, 12 | CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN, 13 | OPTION_DEPRECATED_FORECAST_NOT_USED, PLATFORMS) 14 | from .coordinator import IrmKmiCoordinator 15 | from .weather import IrmKmiWeather 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 21 | """Set up this integration using UI.""" 22 | hass.data.setdefault(DOMAIN, {}) 23 | hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry) 24 | 25 | # When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging 26 | logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel()) 27 | try: 28 | # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities 29 | await coordinator.async_config_entry_first_refresh() 30 | except ConfigEntryError: 31 | # This happens when the zone is out of Benelux (no forecast available there) 32 | # This should be caught by the config flow anyway 33 | return False 34 | 35 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 36 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 37 | 38 | return True 39 | 40 | 41 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 42 | """Handle removal of an entry.""" 43 | if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 44 | hass.data[DOMAIN].pop(entry.entry_id) 45 | return unloaded 46 | 47 | 48 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 49 | """Reload config entry.""" 50 | await hass.config_entries.async_reload(entry.entry_id) 51 | 52 | 53 | async def async_migrate_entry(hass, config_entry: ConfigEntry): 54 | """Migrate old entry.""" 55 | _LOGGER.debug(f"Migrating from version {config_entry.version}") 56 | 57 | if config_entry.version > CONFIG_FLOW_VERSION - 1: 58 | # This means the user has downgraded from a future version 59 | _LOGGER.error(f"Downgrading configuration is not supported: your config version is {config_entry.version}, " 60 | f"the current version used by the integration is {CONFIG_FLOW_VERSION}") 61 | return False 62 | 63 | new = {**config_entry.data} 64 | if config_entry.version == 1: 65 | new = new | {CONF_STYLE: OPTION_STYLE_STD, CONF_DARK_MODE: True} 66 | hass.config_entries.async_update_entry(config_entry, data=new, version=2) 67 | 68 | if config_entry.version == 2: 69 | new = new | {CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED} 70 | hass.config_entries.async_update_entry(config_entry, data=new, version=3) 71 | 72 | if config_entry.version == 3: 73 | new = new | {CONF_LANGUAGE_OVERRIDE: None} 74 | hass.config_entries.async_update_entry(config_entry, data=new, version=4) 75 | 76 | if config_entry.version == 4: 77 | new[CONF_LANGUAGE_OVERRIDE] = 'none' if new[CONF_LANGUAGE_OVERRIDE] is None else new[CONF_LANGUAGE_OVERRIDE] 78 | hass.config_entries.async_update_entry(config_entry, data=new, version=5) 79 | 80 | _LOGGER.debug(f"Migration to version {config_entry.version} successful") 81 | 82 | return True 83 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for the IRM KMI integration.""" 2 | 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | from homeassistant.config_entries import ConfigEntryState 7 | from homeassistant.const import CONF_ZONE 8 | from homeassistant.core import HomeAssistant 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from custom_components.irm_kmi import OPTION_STYLE_STD, async_migrate_entry 12 | from custom_components.irm_kmi.const import ( 13 | CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE, 14 | CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN, 15 | OPTION_DEPRECATED_FORECAST_NOT_USED) 16 | 17 | 18 | async def test_load_unload_config_entry( 19 | hass: HomeAssistant, 20 | mock_config_entry: MockConfigEntry, 21 | mock_irm_kmi_api: AsyncMock, 22 | ) -> None: 23 | """Test the IRM KMI configuration entry loading/unloading.""" 24 | hass.states.async_set( 25 | "zone.home", 26 | 0, 27 | {"latitude": 50.738681639, "longitude": 4.054077148}, 28 | ) 29 | 30 | mock_config_entry.add_to_hass(hass) 31 | 32 | await hass.config_entries.async_setup(mock_config_entry.entry_id) 33 | await hass.async_block_till_done() 34 | 35 | assert mock_config_entry.state is ConfigEntryState.LOADED 36 | 37 | await hass.config_entries.async_unload(mock_config_entry.entry_id) 38 | await hass.async_block_till_done() 39 | 40 | assert not hass.data.get(DOMAIN) 41 | assert mock_config_entry.state is ConfigEntryState.NOT_LOADED 42 | 43 | 44 | async def test_config_entry_not_ready( 45 | hass: HomeAssistant, 46 | mock_config_entry: MockConfigEntry, 47 | mock_exception_irm_kmi_api: AsyncMock 48 | ) -> None: 49 | """Test the IRM KMI configuration entry not ready.""" 50 | hass.states.async_set( 51 | "zone.home", 52 | 0, 53 | {"latitude": 50.738681639, "longitude": 4.054077148}, 54 | ) 55 | mock_config_entry.add_to_hass(hass) 56 | await hass.config_entries.async_setup(mock_config_entry.entry_id) 57 | await hass.async_block_till_done() 58 | 59 | assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1 60 | assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 61 | 62 | 63 | async def test_config_entry_zone_removed( 64 | hass: HomeAssistant, 65 | caplog: pytest.LogCaptureFixture, 66 | ) -> None: 67 | """Test the IRM KMI configuration entry not ready.""" 68 | mock_config_entry = MockConfigEntry( 69 | title="My Castle", 70 | domain=DOMAIN, 71 | data={CONF_ZONE: "zone.castle"}, 72 | unique_id="zone.castle", 73 | ) 74 | mock_config_entry.add_to_hass(hass) 75 | await hass.config_entries.async_setup(mock_config_entry.entry_id) 76 | await hass.async_block_till_done() 77 | 78 | assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 79 | assert "Zone 'zone.castle' not found" in caplog.text 80 | 81 | 82 | async def test_config_entry_migration( 83 | hass: HomeAssistant, 84 | ) -> None: 85 | """Test the IRM KMI configuration entry not ready.""" 86 | mock_config_entry = MockConfigEntry( 87 | title="My Castle", 88 | domain=DOMAIN, 89 | data={CONF_ZONE: "zone.castle"}, 90 | unique_id="zone.castle", 91 | ) 92 | mock_config_entry.add_to_hass(hass) 93 | 94 | success = await async_migrate_entry(hass, mock_config_entry) 95 | assert success 96 | 97 | assert mock_config_entry.data == { 98 | CONF_ZONE: "zone.castle", 99 | CONF_STYLE: OPTION_STYLE_STD, 100 | CONF_DARK_MODE: True, 101 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED, 102 | CONF_LANGUAGE_OVERRIDE: 'none' 103 | } 104 | 105 | assert mock_config_entry.version == CONFIG_FLOW_VERSION 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | docker/ 163 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/repairs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import async_timeout 4 | import voluptuous as vol 5 | from homeassistant import data_entry_flow 6 | from homeassistant.components.repairs import RepairsFlow 7 | from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 10 | from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig 11 | from irm_kmi_api.api import IrmKmiApiClient 12 | 13 | from . import async_reload_entry 14 | from .const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE, REPAIR_OPT_MOVE, 15 | REPAIR_OPTIONS, REPAIR_SOLUTION, USER_AGENT) 16 | from .utils import modify_from_config 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | class OutOfBeneluxRepairFlow(RepairsFlow): 22 | """Handler for an issue fixing flow.""" 23 | 24 | def __init__(self, data: dict): 25 | self._data: dict = data 26 | 27 | async def async_step_init( 28 | self, user_input: dict[str, str] | None = None 29 | ) -> data_entry_flow.FlowResult: 30 | """Handle the first step of a fix flow.""" 31 | 32 | return await (self.async_step_confirm()) 33 | 34 | async def async_step_confirm( 35 | self, user_input: dict[str, str] | None = None 36 | ) -> data_entry_flow.FlowResult: 37 | """Handle the confirm step of a fix flow.""" 38 | errors = {} 39 | 40 | config_entry = self.hass.config_entries.async_get_entry(self._data['config_entry_id']) 41 | 42 | if user_input is not None: 43 | if user_input[REPAIR_SOLUTION] == REPAIR_OPT_MOVE: 44 | if (zone := self.hass.states.get(self._data['zone'])) is None: 45 | errors[REPAIR_SOLUTION] = "zone_not_exist" 46 | 47 | if not errors: 48 | api_data = {} 49 | try: 50 | async with async_timeout.timeout(10): 51 | api_data = await IrmKmiApiClient( 52 | session=async_get_clientsession(self.hass), 53 | user_agent=USER_AGENT 54 | ).get_forecasts_coord( 55 | {'lat': zone.attributes[ATTR_LATITUDE], 56 | 'long': zone.attributes[ATTR_LONGITUDE]} 57 | ) 58 | except Exception: 59 | errors[REPAIR_SOLUTION] = 'api_error' 60 | 61 | if api_data.get('cityName', None) in OUT_OF_BENELUX: 62 | errors[REPAIR_SOLUTION] = 'out_of_benelux' 63 | 64 | if not errors: 65 | modify_from_config(self.hass, self._data['config_entry_id'], enable=True) 66 | await async_reload_entry(self.hass, config_entry) 67 | 68 | elif user_input[REPAIR_SOLUTION] == REPAIR_OPT_DELETE: 69 | await self.hass.config_entries.async_remove(self._data['config_entry_id']) 70 | else: 71 | errors[REPAIR_SOLUTION] = "invalid_choice" 72 | 73 | if not errors: 74 | return self.async_create_entry(title="", data={}) 75 | 76 | return self.async_show_form( 77 | step_id="confirm", 78 | errors=errors, 79 | description_placeholders={'zone': self._data['zone']}, 80 | data_schema=vol.Schema({ 81 | vol.Required(REPAIR_SOLUTION, default=REPAIR_OPT_MOVE): 82 | SelectSelector(SelectSelectorConfig(options=REPAIR_OPTIONS, 83 | translation_key=REPAIR_SOLUTION)), 84 | })) 85 | 86 | 87 | async def async_create_fix_flow( 88 | _hass: HomeAssistant, 89 | _issue_id: str, 90 | data: dict[str, str | int | float | None] | None, 91 | ) -> OutOfBeneluxRepairFlow: 92 | """Create flow.""" 93 | return OutOfBeneluxRepairFlow(data) 94 | -------------------------------------------------------------------------------- /tests/fixtures/pollen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 12 | 14 | 16 | 17 | 18 | Active pollen 21 | 24 | 26 | 27 | 29 | 31 | 33 | 35 | 37 | very high 38 | Grasses 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/test_coordinator.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from homeassistant.components.weather import ATTR_CONDITION_CLOUDY 4 | from homeassistant.core import HomeAssistant 5 | from irm_kmi_api.data import CurrentWeatherData, IrmKmiRadarForecast 6 | from irm_kmi_api.pollen import PollenParser 7 | from pytest_homeassistant_custom_component.common import MockConfigEntry 8 | 9 | from custom_components.irm_kmi.coordinator import IrmKmiCoordinator 10 | from custom_components.irm_kmi.data import ProcessedCoordinatorData 11 | from tests.conftest import get_api_data, get_api_with_data 12 | 13 | 14 | async def test_jules_forgot_to_revert_update_interval_before_pushing( 15 | hass: HomeAssistant, 16 | mock_config_entry: MockConfigEntry, 17 | ) -> None: 18 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 19 | 20 | assert timedelta(minutes=5) <= coordinator.update_interval 21 | 22 | 23 | async def test_refresh_succeed_even_when_pollen_and_radar_fail( 24 | hass: HomeAssistant, 25 | mock_config_entry: MockConfigEntry, 26 | ): 27 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 28 | coordinator._api._api_data = get_api_data("forecast.json") 29 | 30 | result = await coordinator.process_api_data() 31 | 32 | assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY 33 | 34 | assert result.get('animation').get_hint() == "No rain forecasted shortly" 35 | 36 | assert result.get('pollen') == PollenParser.get_unavailable_data() 37 | 38 | existing_data = ProcessedCoordinatorData( 39 | current_weather=CurrentWeatherData(), 40 | daily_forecast=[], 41 | hourly_forecast=[], 42 | animation=None, 43 | warnings=[], 44 | pollen={'foo': 'bar'} 45 | ) 46 | coordinator.data = existing_data 47 | result = await coordinator.process_api_data() 48 | 49 | assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY 50 | 51 | assert result.get('animation').get_hint() == "No rain forecasted shortly" 52 | 53 | assert result.get('pollen') == {'foo': 'bar'} 54 | 55 | 56 | def test_radar_forecast() -> None: 57 | api = get_api_with_data("forecast.json") 58 | result = api.get_radar_forecast() 59 | 60 | expected = [ 61 | IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False, 62 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 63 | IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False, 64 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 65 | IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False, 66 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 67 | IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False, 68 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 69 | IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False, 70 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 71 | IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False, 72 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 73 | IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False, 74 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 75 | IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False, 76 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 77 | IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False, 78 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 79 | IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False, 80 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 81 | IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False, 82 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min') 83 | ] 84 | 85 | assert expected == result 86 | 87 | 88 | def test_radar_forecast_rain_interval() -> None: 89 | api = get_api_with_data('forecast_with_rain_on_radar.json') 90 | result = api.get_radar_forecast() 91 | 92 | _12 = IrmKmiRadarForecast( 93 | datetime='2024-05-30T18:00:00+02:00', 94 | native_precipitation=0.89, 95 | might_rain=True, 96 | rain_forecast_max=1.12, 97 | rain_forecast_min=0.50, 98 | unit='mm/10min' 99 | ) 100 | 101 | _13 = IrmKmiRadarForecast( 102 | datetime="2024-05-30T18:10:00+02:00", 103 | native_precipitation=0.83, 104 | might_rain=True, 105 | rain_forecast_max=1.09, 106 | rain_forecast_min=0.64, 107 | unit='mm/10min' 108 | ) 109 | 110 | assert result[12] == _12 111 | assert result[13] == _13 112 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for the IRM KMI config flow.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | from homeassistant.components.zone import ENTITY_ID_HOME 6 | from homeassistant.config_entries import SOURCE_USER 7 | from homeassistant.const import CONF_ZONE 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.data_entry_flow import FlowResultType 10 | from irm_kmi_api.const import OPTION_STYLE_SATELLITE, OPTION_STYLE_STD 11 | from pytest_homeassistant_custom_component.common import MockConfigEntry 12 | 13 | from custom_components.irm_kmi import async_migrate_entry 14 | from custom_components.irm_kmi.const import ( 15 | CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE, 16 | CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN, 17 | OPTION_DEPRECATED_FORECAST_NOT_USED) 18 | 19 | 20 | async def test_full_user_flow( 21 | hass: HomeAssistant, 22 | mock_setup_entry: MagicMock, 23 | mock_get_forecast_in_benelux: MagicMock 24 | ) -> None: 25 | """Test the full user configuration flow.""" 26 | result = await hass.config_entries.flow.async_init( 27 | DOMAIN, context={"source": SOURCE_USER} 28 | ) 29 | 30 | assert result.get("type") == FlowResultType.FORM 31 | assert result.get("step_id") == "user" 32 | 33 | result2 = await hass.config_entries.flow.async_configure( 34 | result["flow_id"], 35 | user_input={CONF_ZONE: ENTITY_ID_HOME, 36 | CONF_STYLE: OPTION_STYLE_STD, 37 | CONF_DARK_MODE: False}, 38 | ) 39 | assert result2.get("type") == FlowResultType.CREATE_ENTRY 40 | assert result2.get("title") == "test home" 41 | assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME, 42 | CONF_STYLE: OPTION_STYLE_STD, 43 | CONF_DARK_MODE: False, 44 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED, 45 | CONF_LANGUAGE_OVERRIDE: 'none'} 46 | 47 | 48 | async def test_config_flow_out_benelux_zone( 49 | hass: HomeAssistant, 50 | mock_setup_entry: MagicMock, 51 | mock_get_forecast_out_benelux: MagicMock 52 | ) -> None: 53 | result = await hass.config_entries.flow.async_init( 54 | DOMAIN, context={"source": SOURCE_USER} 55 | ) 56 | 57 | result2 = await hass.config_entries.flow.async_configure( 58 | result["flow_id"], 59 | user_input={CONF_ZONE: ENTITY_ID_HOME, 60 | CONF_STYLE: OPTION_STYLE_STD, 61 | CONF_DARK_MODE: False}, 62 | ) 63 | 64 | assert result2.get("type") == FlowResultType.FORM 65 | assert result2.get("step_id") == "user" 66 | assert CONF_ZONE in result2.get('errors') 67 | 68 | 69 | async def test_config_flow_with_api_error( 70 | hass: HomeAssistant, 71 | mock_setup_entry: MagicMock, 72 | mock_get_forecast_api_error: MagicMock 73 | ) -> None: 74 | result = await hass.config_entries.flow.async_init( 75 | DOMAIN, context={"source": SOURCE_USER} 76 | ) 77 | 78 | result2 = await hass.config_entries.flow.async_configure( 79 | result["flow_id"], 80 | user_input={CONF_ZONE: ENTITY_ID_HOME, 81 | CONF_STYLE: OPTION_STYLE_STD, 82 | CONF_DARK_MODE: False}, 83 | ) 84 | 85 | assert result2.get("type") == FlowResultType.FORM 86 | assert result2.get("step_id") == "user" 87 | assert 'base' in result2.get('errors') 88 | 89 | 90 | async def test_config_flow_unknown_zone(hass: HomeAssistant) -> None: 91 | result = await hass.config_entries.flow.async_init( 92 | DOMAIN, context={"source": SOURCE_USER} 93 | ) 94 | 95 | result2 = await hass.config_entries.flow.async_configure( 96 | result["flow_id"], 97 | user_input={CONF_ZONE: "zone.what", 98 | CONF_STYLE: OPTION_STYLE_STD, 99 | CONF_DARK_MODE: False}, 100 | ) 101 | 102 | assert result2.get("type") == FlowResultType.FORM 103 | assert result2.get("step_id") == "user" 104 | assert CONF_ZONE in result2.get('errors') 105 | 106 | 107 | async def test_option_flow( 108 | hass: HomeAssistant, 109 | mock_config_entry: MockConfigEntry 110 | ) -> None: 111 | mock_config_entry.add_to_hass(hass) 112 | 113 | assert not mock_config_entry.options 114 | 115 | result = await hass.config_entries.options.async_init(mock_config_entry.entry_id, data=None) 116 | 117 | assert result["type"] == FlowResultType.FORM 118 | assert result["step_id"] == "init" 119 | 120 | result = await hass.config_entries.options.async_configure( 121 | result["flow_id"], 122 | user_input={ 123 | CONF_STYLE: OPTION_STYLE_SATELLITE, 124 | CONF_DARK_MODE: True, 125 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED 126 | } 127 | ) 128 | 129 | assert result["type"] == FlowResultType.CREATE_ENTRY 130 | assert result["data"] == { 131 | CONF_STYLE: OPTION_STYLE_SATELLITE, 132 | CONF_DARK_MODE: True, 133 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED, 134 | CONF_LANGUAGE_OVERRIDE: 'none' 135 | } 136 | 137 | 138 | async def test_config_entry_migration(hass: HomeAssistant) -> None: 139 | """Ensure that config entry migration takes the configuration to the latest version""" 140 | entry = MockConfigEntry( 141 | title="Home", 142 | domain=DOMAIN, 143 | data={CONF_ZONE: "zone.home"}, 144 | unique_id="zone.home", 145 | version=1 146 | ) 147 | 148 | entry.add_to_hass(hass) 149 | await async_migrate_entry(hass, entry) 150 | result_entry = hass.config_entries.async_get_entry(entry_id=entry.entry_id) 151 | 152 | assert result_entry.version == CONFIG_FLOW_VERSION 153 | -------------------------------------------------------------------------------- /tests/test_repairs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from unittest.mock import AsyncMock, MagicMock 4 | 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.data_entry_flow import FlowResultType 7 | from homeassistant.helpers import issue_registry 8 | from pytest_homeassistant_custom_component.common import (MockConfigEntry, 9 | load_fixture) 10 | 11 | from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator 12 | from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE, 13 | REPAIR_OPT_MOVE, REPAIR_SOLUTION) 14 | from custom_components.irm_kmi.repairs import (OutOfBeneluxRepairFlow, 15 | async_create_fix_flow) 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def get_repair_flow( 21 | hass: HomeAssistant, 22 | mock_config_entry: MockConfigEntry 23 | ) -> OutOfBeneluxRepairFlow: 24 | hass.states.async_set( 25 | "zone.home", 26 | 0, 27 | {"latitude": 50.738681639, "longitude": 4.054077148}, 28 | ) 29 | mock_config_entry.add_to_hass(hass) 30 | await hass.config_entries.async_setup(mock_config_entry.entry_id) 31 | await hass.async_block_till_done() 32 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 33 | 34 | fixture: str = "forecast_out_of_benelux.json" 35 | forecast = json.loads(load_fixture(fixture)) 36 | coordinator._api.get_forecasts_coord = AsyncMock(return_value=forecast) 37 | 38 | await coordinator._async_update_data() 39 | ir = issue_registry.async_get(hass) 40 | issue = ir.async_get_issue(DOMAIN, "zone_moved") 41 | repair_flow = await async_create_fix_flow(hass, issue.issue_id, issue.data) 42 | repair_flow.hass = hass 43 | return repair_flow 44 | 45 | 46 | async def test_repair_triggers_when_out_of_benelux( 47 | hass: HomeAssistant, 48 | mock_config_entry: MockConfigEntry 49 | ) -> None: 50 | hass.states.async_set( 51 | "zone.home", 52 | 0, 53 | {"latitude": 50.738681639, "longitude": 4.054077148}, 54 | ) 55 | 56 | mock_config_entry.add_to_hass(hass) 57 | 58 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 59 | coordinator._api.get_forecasts_coord = AsyncMock(return_value=json.loads(load_fixture("forecast_out_of_benelux.json"))) 60 | 61 | await coordinator._async_update_data() 62 | 63 | ir = issue_registry.async_get(hass) 64 | 65 | issue = ir.async_get_issue(DOMAIN, "zone_moved") 66 | 67 | assert issue is not None 68 | assert issue.data == {'config_entry_id': mock_config_entry.entry_id, 'zone': "zone.home"} 69 | assert issue.translation_key == "zone_moved" 70 | assert issue.is_fixable 71 | assert issue.translation_placeholders == {'zone': "zone.home"} 72 | 73 | 74 | async def test_repair_flow( 75 | hass: HomeAssistant, 76 | mock_irm_kmi_api_repair_in_benelux: MagicMock, 77 | mock_config_entry: MockConfigEntry 78 | ) -> None: 79 | repair_flow = await get_repair_flow(hass, mock_config_entry) 80 | result = await repair_flow.async_step_init() 81 | 82 | assert result['type'] == FlowResultType.FORM 83 | assert result['errors'] == {} 84 | assert result['description_placeholders'] == {"zone": "zone.home"} 85 | 86 | user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} 87 | 88 | result = await repair_flow.async_step_confirm(user_input) 89 | 90 | assert result['type'] == FlowResultType.CREATE_ENTRY 91 | assert result['title'] == "" 92 | assert result['data'] == {} 93 | 94 | 95 | async def test_repair_flow_invalid_choice( 96 | hass: HomeAssistant, 97 | mock_irm_kmi_api_repair_in_benelux: MagicMock, 98 | mock_config_entry: MockConfigEntry 99 | ) -> None: 100 | repair_flow = await get_repair_flow(hass, mock_config_entry) 101 | result = await repair_flow.async_step_init() 102 | 103 | assert result['type'] == FlowResultType.FORM 104 | user_input = {REPAIR_SOLUTION: "whut?"} 105 | 106 | result = await repair_flow.async_step_confirm(user_input) 107 | 108 | assert result['type'] == FlowResultType.FORM 109 | assert REPAIR_SOLUTION in result['errors'] 110 | assert result['errors'][REPAIR_SOLUTION] == 'invalid_choice' 111 | 112 | 113 | async def test_repair_flow_api_error( 114 | hass: HomeAssistant, 115 | mock_get_forecast_api_error_repair: MagicMock, 116 | mock_config_entry: MockConfigEntry 117 | ) -> None: 118 | repair_flow = await get_repair_flow(hass, mock_config_entry) 119 | result = await repair_flow.async_step_init() 120 | 121 | assert result['type'] == FlowResultType.FORM 122 | user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} 123 | 124 | result = await repair_flow.async_step_confirm(user_input) 125 | 126 | assert result['type'] == FlowResultType.FORM 127 | assert REPAIR_SOLUTION in result['errors'] 128 | assert result['errors'][REPAIR_SOLUTION] == 'api_error' 129 | 130 | 131 | async def test_repair_flow_out_of_benelux( 132 | hass: HomeAssistant, 133 | mock_irm_kmi_api_repair_out_of_benelux: MagicMock, 134 | mock_config_entry: MockConfigEntry 135 | ) -> None: 136 | repair_flow = await get_repair_flow(hass, mock_config_entry) 137 | result = await repair_flow.async_step_init() 138 | 139 | assert result['type'] == FlowResultType.FORM 140 | user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE} 141 | 142 | result = await repair_flow.async_step_confirm(user_input) 143 | 144 | assert result['type'] == FlowResultType.FORM 145 | assert REPAIR_SOLUTION in result['errors'] 146 | assert result['errors'][REPAIR_SOLUTION] == 'out_of_benelux' 147 | 148 | 149 | async def test_repair_flow_delete_entry( 150 | hass: HomeAssistant, 151 | mock_config_entry: MockConfigEntry 152 | ) -> None: 153 | repair_flow = await get_repair_flow(hass, mock_config_entry) 154 | result = await repair_flow.async_step_init() 155 | 156 | assert result['type'] == FlowResultType.FORM 157 | assert len(hass.config_entries.async_entries(DOMAIN)) == 1 158 | assert hass.config_entries.async_entries(DOMAIN)[0].entry_id == mock_config_entry.entry_id 159 | 160 | user_input = {REPAIR_SOLUTION: REPAIR_OPT_DELETE} 161 | result = await repair_flow.async_step_confirm(user_input) 162 | 163 | assert result['type'] == FlowResultType.CREATE_ENTRY 164 | assert result['title'] == "" 165 | assert result['data'] == {} 166 | assert len(hass.config_entries.async_entries(DOMAIN)) == 0 167 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Royal Meteorological Institute of Belgium", 3 | "config": { 4 | "abort": { 5 | "already_configured": "The weather for this zone is already configured", 6 | "unknown": "Unknown error" 7 | }, 8 | "step": { 9 | "user": { 10 | "title": "Configuration", 11 | "data": { 12 | "zone": "Zone", 13 | "style": "Style of the radar", 14 | "dark_mode": "Radar dark mode", 15 | "use_deprecated_forecast_attribute": "Use the deprecated forecat attribute", 16 | "language_override": "Language" 17 | } 18 | } 19 | }, 20 | "error": { 21 | "out_of_benelux": "{zone} is out of Benelux. Pick a zone in Benelux.", 22 | "api_error": "Could not get data from the API", 23 | "zone_not_exist": "{zone} does not exist" 24 | } 25 | }, 26 | "selector": { 27 | "style": { 28 | "options": { 29 | "standard_style": "Standard", 30 | "contrast_style": "High contrast", 31 | "yellow_red_style": "Yellow-Red", 32 | "satellite_style": "Satellite map" 33 | } 34 | }, 35 | "use_deprecated_forecast_attribute": { 36 | "options": { 37 | "do_not_use_deprecated_forecast": "Do not use (recommended)", 38 | "daily_in_deprecated_forecast": "Use for daily forecast", 39 | "twice_daily_in_deprecated_forecast": "Use for twice daily forecast", 40 | "hourly_in_deprecated_forecast": "Use for hourly forecast" 41 | } 42 | }, 43 | "repair_solution": { 44 | "options": { 45 | "repair_option_move": "I moved the zone in Benelux", 46 | "repair_option_delete": "Delete that config entry" 47 | } 48 | }, 49 | "language_override": { 50 | "options": { 51 | "none": "Follow Home Assistant server language", 52 | "fr": "French", 53 | "nl": "Dutch", 54 | "de": "German", 55 | "en": "English" 56 | } 57 | } 58 | }, 59 | "options": { 60 | "step": { 61 | "init": { 62 | "title": "Options", 63 | "data": { 64 | "style": "Style of the radar", 65 | "dark_mode": "Radar dark mode", 66 | "use_deprecated_forecast_attribute": "Use the deprecated forecat attribute", 67 | "language_override": "Language" 68 | } 69 | } 70 | } 71 | }, 72 | "issues": { 73 | "zone_moved": { 74 | "title": "{zone} is outside of Benelux", 75 | "fix_flow": { 76 | "step": { 77 | "confirm": { 78 | "title": "Repair: {zone} is outside of Benelux", 79 | "description": "This integration can only get data for location in the Benelux. Move the zone or delete this configuration entry." 80 | } 81 | }, 82 | "error": { 83 | "out_of_benelux": "{zone} is out of Benelux. Move it inside Benelux first.", 84 | "api_error": "Could not get data from the API", 85 | "zone_not_exist": "{zone} does not exist", 86 | "invalid_choice": "The choice is not valid" 87 | } 88 | } 89 | } 90 | }, 91 | "entity": { 92 | "sensor": { 93 | "next_warning": { 94 | "name": "Next warning" 95 | }, 96 | "next_sunrise": { 97 | "name": "Next sunrise" 98 | }, 99 | "next_sunset": { 100 | "name": "Next sunset" 101 | }, 102 | "pollen_alder": { 103 | "name": "Alder pollen", 104 | "state": { 105 | "active": "Active", 106 | "green": "Green", 107 | "yellow": "Yellow", 108 | "orange": "Orange", 109 | "red": "Red", 110 | "purple": "Purple", 111 | "none": "None" 112 | } 113 | }, 114 | "pollen_ash": { 115 | "name": "Ash pollen", 116 | "state": { 117 | "active": "Active", 118 | "green": "Green", 119 | "yellow": "Yellow", 120 | "orange": "Orange", 121 | "red": "Red", 122 | "purple": "Purple", 123 | "none": "None" 124 | } 125 | }, 126 | "pollen_birch": { 127 | "name": "Birch pollen", 128 | "state": { 129 | "active": "Active", 130 | "green": "Green", 131 | "yellow": "Yellow", 132 | "orange": "Orange", 133 | "red": "Red", 134 | "purple": "Purple", 135 | "none": "None" 136 | } 137 | }, 138 | "pollen_grasses": { 139 | "name": "Grass pollen", 140 | "state": { 141 | "active": "Active", 142 | "green": "Green", 143 | "yellow": "Yellow", 144 | "orange": "Orange", 145 | "red": "Red", 146 | "purple": "Purple", 147 | "none": "None" 148 | } 149 | }, 150 | "pollen_hazel": { 151 | "name": "Hazel pollen", 152 | "state": { 153 | "active": "Active", 154 | "green": "Green", 155 | "yellow": "Yellow", 156 | "orange": "Orange", 157 | "red": "Red", 158 | "purple": "Purple", 159 | "none": "None" 160 | } 161 | }, 162 | "pollen_mugwort": { 163 | "name": "Mugwort pollen", 164 | "state": { 165 | "active": "Active", 166 | "green": "Green", 167 | "yellow": "Yellow", 168 | "orange": "Orange", 169 | "red": "Red", 170 | "purple": "Purple", 171 | "none": "None" 172 | } 173 | }, 174 | "pollen_oak": { 175 | "name": "Oak pollen", 176 | "state": { 177 | "active": "Active", 178 | "green": "Green", 179 | "yellow": "Yellow", 180 | "orange": "Orange", 181 | "red": "Red", 182 | "purple": "Purple", 183 | "none": "None" 184 | } 185 | }, 186 | "current_temperature": { 187 | "name": "Temperature" 188 | }, 189 | "current_wind_speed": { 190 | "name": "Wind speed" 191 | }, 192 | "current_wind_gust_speed": { 193 | "name": "Wind gust speed" 194 | }, 195 | "current_wind_bearing": { 196 | "name": "Wind bearing" 197 | }, 198 | "current_uv_index": { 199 | "name": "UV index" 200 | }, 201 | "current_pressure": { 202 | "name": "Atmospheric pressure" 203 | }, 204 | "current_rainfall": { 205 | "name": "Rainfall" 206 | } 207 | } 208 | }, 209 | "services": { 210 | "get_forecasts_radar": { 211 | "name": "Get forecast from the radar", 212 | "description": "Get weather forecast from the radar. Only precipitation is available.", 213 | "fields": { 214 | "include_past_forecasts": { 215 | "name": "Include past forecasts", 216 | "description": "Also return forecasts for that are in the past." 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Koninklijk Meteorologisch Instituut van België", 3 | "config": { 4 | "abort": { 5 | "already_configured": "De weersvoorspellingen voor deze zone zijn al geconfigureerd", 6 | "unknown": "Onbekende fout" 7 | }, 8 | "step": { 9 | "user": { 10 | "title": "Instellingen", 11 | "data": { 12 | "zone": "Zone", 13 | "style": "Radarstijl", 14 | "dark_mode": "Radar in donkere modus", 15 | "use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)", 16 | "language_override": "Taal" 17 | } 18 | } 19 | }, 20 | "error": { 21 | "out_of_benelux": "{zone} ligt buiten de Benelux. Kies een zone in de Benelux.", 22 | "api_error": "Kon geen gegevens van de API krijgen", 23 | "zone_not_exist": "{zone} bestaat niet" 24 | } 25 | }, 26 | "selector": { 27 | "style": { 28 | "options": { 29 | "standard_style": "Standaard", 30 | "contrast_style": "Hoog contrast", 31 | "yellow_red_style": "Geel-Rood", 32 | "satellite_style": "Satellietkaart" 33 | } 34 | }, 35 | "use_deprecated_forecast_attribute": { 36 | "options": { 37 | "do_not_use_deprecated_forecast": "Niet gebruiken (aanbevolen)", 38 | "daily_in_deprecated_forecast": "Gebruik voor dagelijkse voorspellingen", 39 | "twice_daily_in_deprecated_forecast": "Gebruik voor tweemaal daags voorspellingen", 40 | "hourly_in_deprecated_forecast": "Gebruik voor uurlijkse voorspellingen" 41 | } 42 | }, 43 | "repair_solution": { 44 | "options": { 45 | "repair_option_move": "Ik heb de zone verplaats naar de Benelux", 46 | "repair_option_delete": "Deze configuratie verwijderen" 47 | } 48 | }, 49 | "language_override": { 50 | "options": { 51 | "none": "Zelfde als Home Assistant server taal", 52 | "fr": "Frans", 53 | "nl": "Nederlands", 54 | "de": "Duits", 55 | "en": "Engels" 56 | } 57 | } 58 | }, 59 | "options": { 60 | "step": { 61 | "init": { 62 | "title": "Opties", 63 | "data": { 64 | "style": "Radarstijl", 65 | "dark_mode": "Radar in donkere modus", 66 | "use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)", 67 | "language_override": "Taal" 68 | } 69 | } 70 | } 71 | }, 72 | "issues": { 73 | "zone_moved": { 74 | "title": "{zone} ligt buiten de Benelux", 75 | "fix_flow": { 76 | "step": { 77 | "confirm": { 78 | "title": "Reparatie: {zone} ligt buiten de Benelux", 79 | "description": "Deze integratie levert alleen gegevens op voor de Benelux. Verplaats de zone of verwijder de configuratie." 80 | } 81 | }, 82 | "error": { 83 | "out_of_benelux": "{zone} ligt buiten de Benelux. Kies een zone in de Benelux.", 84 | "api_error": "Kon geen gegevens van de API krijgen", 85 | "zone_not_exist": "{zone} bestaat niet", 86 | "invalid_choice": "Ongeldige keuze" 87 | } 88 | } 89 | } 90 | }, 91 | "entity": { 92 | "sensor": { 93 | "next_warning": { 94 | "name": "Volgende waarschuwing" 95 | }, 96 | "next_sunrise": { 97 | "name": "Volgende zonsopkomst" 98 | }, 99 | "next_sunset": { 100 | "name": "Volgende zonsondergang" 101 | }, 102 | "pollen_alder": { 103 | "name": "Elzenpollen", 104 | "state": { 105 | "active": "Actief", 106 | "green": "Groen", 107 | "yellow": "Geel", 108 | "orange": "Oranje", 109 | "red": "Rood", 110 | "purple": "Paars", 111 | "none": "Geen" 112 | } 113 | }, 114 | "pollen_ash": { 115 | "name": "Essen pollen", 116 | "state": { 117 | "active": "Actief", 118 | "green": "Groen", 119 | "yellow": "Geel", 120 | "orange": "Oranje", 121 | "red": "Rood", 122 | "purple": "Paars", 123 | "none": "Geen" 124 | } 125 | }, 126 | "pollen_birch": { 127 | "name": "Berken pollen", 128 | "state": { 129 | "active": "Actief", 130 | "green": "Groen", 131 | "yellow": "Geel", 132 | "orange": "Oranje", 133 | "red": "Rood", 134 | "purple": "Paars", 135 | "none": "Geen" 136 | } 137 | }, 138 | "pollen_grasses": { 139 | "name": "Graspollen", 140 | "state": { 141 | "active": "Actief", 142 | "green": "Groen", 143 | "yellow": "Geel", 144 | "orange": "Oranje", 145 | "red": "Rood", 146 | "purple": "Paars", 147 | "none": "Geen" 148 | } 149 | }, 150 | "pollen_hazel": { 151 | "name": "Hazelaar pollen", 152 | "state": { 153 | "active": "Actief", 154 | "green": "Groen", 155 | "yellow": "Geel", 156 | "orange": "Oranje", 157 | "red": "Rood", 158 | "purple": "Paars", 159 | "none": "Geen" 160 | } 161 | }, 162 | "pollen_mugwort": { 163 | "name": "Alsem pollen", 164 | "state": { 165 | "active": "Actief", 166 | "green": "Groen", 167 | "yellow": "Geel", 168 | "orange": "Oranje", 169 | "red": "Rood", 170 | "purple": "Paars", 171 | "none": "Geen" 172 | } 173 | }, 174 | "pollen_oak": { 175 | "name": "Eiken pollen", 176 | "state": { 177 | "active": "Actief", 178 | "green": "Groen", 179 | "yellow": "Geel", 180 | "orange": "Oranje", 181 | "red": "Rood", 182 | "purple": "Paars", 183 | "none": "Geen" 184 | } 185 | }, 186 | "current_temperature": { 187 | "name": "Temperatuur" 188 | }, 189 | "current_wind_speed": { 190 | "name": "Windsnelheid" 191 | }, 192 | "current_wind_gust_speed": { 193 | "name": "Snelheid windvlaag" 194 | }, 195 | "current_wind_bearing": { 196 | "name": "Windrichting" 197 | }, 198 | "current_uv_index": { 199 | "name": "UV-index" 200 | }, 201 | "current_pressure": { 202 | "name": "Luchtdruk" 203 | }, 204 | "current_rainfall": { 205 | "name": "Neerslag" 206 | } 207 | } 208 | }, 209 | "services": { 210 | "get_forecasts_radar": { 211 | "name": "Get forecast from the radar", 212 | "description": "Weersverwachting van radar ophalen. Alleen neerslag is beschikbaar.", 213 | "fields": { 214 | "include_past_forecasts": { 215 | "name": "Verleden weersvoorspellingen opnemen", 216 | "description": "Geeft ook weersvoorspellingen uit het verleden." 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Instituto Real Meteorológico da Bélgica", 3 | "config": { 4 | "abort": { 5 | "already_configured": "O clima para esta zona já está configurado", 6 | "unknown": "Erro desconhecido" 7 | }, 8 | "step": { 9 | "user": { 10 | "title": "Configuração", 11 | "data": { 12 | "zone": "Zona", 13 | "style": "Estilo do radar", 14 | "dark_mode": "Modo escuro do radar", 15 | "use_deprecated_forecast_attribute": "Usar o atributo de previsão descontinuado", 16 | "language_override": "Idioma" 17 | } 18 | } 19 | }, 20 | "error": { 21 | "out_of_benelux": "{zone} está fora do Benelux. Escolha uma zona no Benelux.", 22 | "api_error": "Não foi possível obter dados da API", 23 | "zone_not_exist": "{zone} não existe" 24 | } 25 | }, 26 | "selector": { 27 | "style": { 28 | "options": { 29 | "standard_style": "Padrão", 30 | "contrast_style": "Alto contraste", 31 | "yellow_red_style": "Amarelo-Vermelho", 32 | "satellite_style": "Mapa de satélite" 33 | } 34 | }, 35 | "use_deprecated_forecast_attribute": { 36 | "options": { 37 | "do_not_use_deprecated_forecast": "Não usar (recomendado)", 38 | "daily_in_deprecated_forecast": "Usar para previsão diária", 39 | "twice_daily_in_deprecated_forecast": "Usar para previsão duas vezes ao dia", 40 | "hourly_in_deprecated_forecast": "Usar para previsão horária" 41 | } 42 | }, 43 | "repair_solution": { 44 | "options": { 45 | "repair_option_move": "Mudei a zona para o Benelux", 46 | "repair_option_delete": "Apagar essa entrada de configuração" 47 | } 48 | }, 49 | "language_override": { 50 | "options": { 51 | "none": "Seguir o idioma do servidor do Home Assistant", 52 | "fr": "Francês", 53 | "nl": "Neerlandês", 54 | "de": "Alemão", 55 | "en": "Inglês" 56 | } 57 | } 58 | }, 59 | "options": { 60 | "step": { 61 | "init": { 62 | "title": "Opções", 63 | "data": { 64 | "style": "Estilo do radar", 65 | "dark_mode": "Modo escuro do radar", 66 | "use_deprecated_forecast_attribute": "Usar o atributo de previsão descontinuado", 67 | "language_override": "Idioma" 68 | } 69 | } 70 | } 71 | }, 72 | "issues": { 73 | "zone_moved": { 74 | "title": "{zone} está fora do Benelux", 75 | "fix_flow": { 76 | "step": { 77 | "confirm": { 78 | "title": "Reparação: {zone} está fora do Benelux", 79 | "description": "Esta integração só pode obter dados para locais no Benelux. Mova a zona ou apague esta entrada de configuração." 80 | } 81 | }, 82 | "error": { 83 | "out_of_benelux": "{zone} está fora do Benelux. Mova-a para dentro do Benelux primeiro.", 84 | "api_error": "Não foi possível obter dados da API", 85 | "zone_not_exist": "{zone} não existe", 86 | "invalid_choice": "A escolha não é válida" 87 | } 88 | } 89 | } 90 | }, 91 | "entity": { 92 | "sensor": { 93 | "next_warning": { 94 | "name": "Próximo aviso" 95 | }, 96 | "next_sunrise": { 97 | "name": "Próximo nascer do sol" 98 | }, 99 | "next_sunset": { 100 | "name": "Próximo pôr do sol" 101 | }, 102 | "pollen_alder": { 103 | "name": "Pólen de amieiro", 104 | "state": { 105 | "active": "Ativo", 106 | "green": "Verde", 107 | "yellow": "Amarelo", 108 | "orange": "Laranja", 109 | "red": "Vermelho", 110 | "purple": "Roxo", 111 | "none": "Nenhum" 112 | } 113 | }, 114 | "pollen_ash": { 115 | "name": "Pólen de freixo", 116 | "state": { 117 | "active": "Ativo", 118 | "green": "Verde", 119 | "yellow": "Amarelo", 120 | "orange": "Laranja", 121 | "red": "Vermelho", 122 | "purple": "Roxo", 123 | "none": "Nenhum" 124 | } 125 | }, 126 | "pollen_birch": { 127 | "name": "Pólen de bétula", 128 | "state": { 129 | "active": "Ativo", 130 | "green": "Verde", 131 | "yellow": "Amarelo", 132 | "orange": "Laranja", 133 | "red": "Vermelho", 134 | "purple": "Roxo", 135 | "none": "Nenhum" 136 | } 137 | }, 138 | "pollen_grasses": { 139 | "name": "Pólen de gramíneas", 140 | "state": { 141 | "active": "Ativo", 142 | "green": "Verde", 143 | "yellow": "Amarelo", 144 | "orange": "Laranja", 145 | "red": "Vermelho", 146 | "purple": "Roxo", 147 | "none": "Nenhum" 148 | } 149 | }, 150 | "pollen_hazel": { 151 | "name": "Pólen de aveleira", 152 | "state": { 153 | "active": "Ativo", 154 | "green": "Verde", 155 | "yellow": "Amarelo", 156 | "orange": "Laranja", 157 | "red": "Vermelho", 158 | "purple": "Roxo", 159 | "none": "Nenhum" 160 | } 161 | }, 162 | "pollen_mugwort": { 163 | "name": "Pólen de artemísia", 164 | "state": { 165 | "active": "Ativo", 166 | "green": "Verde", 167 | "yellow": "Amarelo", 168 | "orange": "Laranja", 169 | "red": "Vermelho", 170 | "purple": "Roxo", 171 | "none": "Nenhum" 172 | } 173 | }, 174 | "pollen_oak": { 175 | "name": "Pólen de carvalho", 176 | "state": { 177 | "active": "Ativo", 178 | "green": "Verde", 179 | "yellow": "Amarelo", 180 | "orange": "Laranja", 181 | "red": "Vermelho", 182 | "purple": "Roxo", 183 | "none": "Nenhum" 184 | } 185 | }, 186 | "current_temperature": { 187 | "name": "Temperatura" 188 | }, 189 | "current_wind_speed": { 190 | "name": "Velocidade do vento" 191 | }, 192 | "current_wind_gust_speed": { 193 | "name": "Velocidade da rajada de vento" 194 | }, 195 | "current_wind_bearing": { 196 | "name": "Direção do vento" 197 | }, 198 | "current_uv_index": { 199 | "name": "Índice UV" 200 | }, 201 | "current_pressure": { 202 | "name": "Pressão atmosférica" 203 | }, 204 | "current_rainfall": { 205 | "name": "Precipitação" 206 | } 207 | } 208 | }, 209 | "services": { 210 | "get_forecasts_radar": { 211 | "name": "Obter previsão do radar", 212 | "description": "Obter previsão do tempo do radar. Apenas precipitação está disponível.", 213 | "fields": { 214 | "include_past_forecasts": { 215 | "name": "Incluir previsões passadas", 216 | "description": "Também retornar previsões que estão no passado." 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Institut Royal Météorologique de Belgique", 3 | "config": { 4 | "abort": { 5 | "already_configured": "Les prévisions météo pour cette zone sont déjà configurées", 6 | "unknown": "Erreur inconnue" 7 | }, 8 | "step": { 9 | "user": { 10 | "title": "Configuration", 11 | "data": { 12 | "zone": "Zone", 13 | "style": "Style du radar", 14 | "dark_mode": "Radar en mode sombre", 15 | "use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)", 16 | "language_override": "Langue" 17 | } 18 | } 19 | }, 20 | "error": { 21 | "out_of_benelux": "{zone} est hors du Benelux. Choisissez une zone dans le Benelux.", 22 | "api_error": "Impossible d'obtenir les données depuis l'API", 23 | "zone_not_exist": "{zone} n'existe pas" 24 | } 25 | }, 26 | "selector": { 27 | "style": { 28 | "options": { 29 | "standard_style": "Standard", 30 | "contrast_style": "Contraste élevé", 31 | "yellow_red_style": "Jaune-Rouge", 32 | "satellite_style": "Carte satellite" 33 | } 34 | }, 35 | "use_deprecated_forecast_attribute": { 36 | "options": { 37 | "do_not_use_deprecated_forecast": "Ne pas utiliser (recommandé)", 38 | "daily_in_deprecated_forecast": "Utiliser pour les prévisions quotidiennes", 39 | "twice_daily_in_deprecated_forecast": "Utiliser pour les prévisions biquotidiennes", 40 | "hourly_in_deprecated_forecast": "Utiliser pour les prévisions horaires" 41 | } 42 | }, 43 | "repair_solution": { 44 | "options": { 45 | "repair_option_move": "J'ai déplacé la zone dans le Benelux", 46 | "repair_option_delete": "Supprimer cette configuration" 47 | } 48 | }, 49 | "language_override": { 50 | "options": { 51 | "none": "Langue du serveur Home Assistant", 52 | "fr": "Français", 53 | "nl": "Néerlandais", 54 | "de": "Allemand", 55 | "en": "Anglais" 56 | } 57 | } 58 | }, 59 | "options": { 60 | "step": { 61 | "init": { 62 | "title": "Options", 63 | "data": { 64 | "style": "Style du radar", 65 | "dark_mode": "Radar en mode sombre", 66 | "use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)", 67 | "language_override": "Langue" 68 | } 69 | } 70 | } 71 | }, 72 | "issues": { 73 | "zone_moved": { 74 | "title": "{zone} est hors du Benelux", 75 | "fix_flow": { 76 | "step": { 77 | "confirm": { 78 | "title": "Correction: {zone} est hors du Benelux", 79 | "description": "Cette intégration ne fournit des données que pour le Benelux. Déplacer la zone ou supprimer la configuration." 80 | } 81 | }, 82 | "error": { 83 | "out_of_benelux": "{zone} est hors du Benelux. Commencez par déplacer la zone dans le Benelux.", 84 | "api_error": "Impossible d'obtenir les données depuis l'API", 85 | "zone_not_exist": "{zone} n'existe pas", 86 | "invalid_choice": "Choix non valide" 87 | } 88 | } 89 | } 90 | }, 91 | "entity": { 92 | "sensor": { 93 | "next_warning": { 94 | "name": "Prochain avertissement" 95 | }, 96 | "next_sunrise": { 97 | "name": "Prochain lever de soleil" 98 | }, 99 | "next_sunset": { 100 | "name": "Prochain coucher de soleil" 101 | }, 102 | "pollen_alder": { 103 | "name": "Pollen d'aulne", 104 | "state": { 105 | "active": "Actif", 106 | "green": "Vert", 107 | "yellow": "Jaune", 108 | "orange": "Orange", 109 | "red": "Rouge", 110 | "purple": "Violet", 111 | "none": "Aucun" 112 | } 113 | }, 114 | "pollen_ash": { 115 | "name": "Pollen de frêne", 116 | "state": { 117 | "active": "Actif", 118 | "green": "Vert", 119 | "yellow": "Jaune", 120 | "orange": "Orange", 121 | "red": "Rouge", 122 | "purple": "Violet", 123 | "none": "Aucun" 124 | } 125 | }, 126 | "pollen_birch": { 127 | "name": "Pollen de bouleau", 128 | "state": { 129 | "active": "Actif", 130 | "green": "Vert", 131 | "yellow": "Jaune", 132 | "orange": "Orange", 133 | "red": "Rouge", 134 | "purple": "Violet", 135 | "none": "Aucun" 136 | } 137 | }, 138 | "pollen_grasses": { 139 | "name": "Pollen de graminées", 140 | "state": { 141 | "active": "Actif", 142 | "green": "Vert", 143 | "yellow": "Jaune", 144 | "orange": "Orange", 145 | "red": "Rouge", 146 | "purple": "Violet", 147 | "none": "Aucun" 148 | } 149 | }, 150 | "pollen_hazel": { 151 | "name": "Pollen de noisetier", 152 | "state": { 153 | "active": "Actif", 154 | "green": "Vert", 155 | "yellow": "Jaune", 156 | "orange": "Orange", 157 | "red": "Rouge", 158 | "purple": "Violet", 159 | "none": "Aucun" 160 | } 161 | }, 162 | "pollen_mugwort": { 163 | "name": "Pollen d'armoise", 164 | "state": { 165 | "active": "Actif", 166 | "green": "Vert", 167 | "yellow": "Jaune", 168 | "orange": "Orange", 169 | "red": "Rouge", 170 | "purple": "Violet", 171 | "none": "Aucun" 172 | } 173 | }, 174 | "pollen_oak": { 175 | "name": "Pollen de chêne", 176 | "state": { 177 | "active": "Actif", 178 | "green": "Vert", 179 | "yellow": "Jaune", 180 | "orange": "Orange", 181 | "red": "Rouge", 182 | "purple": "Violet", 183 | "none": "Aucun" 184 | } 185 | }, 186 | "current_temperature": { 187 | "name": "Température" 188 | }, 189 | "current_wind_speed": { 190 | "name": "Vitesse du vent" 191 | }, 192 | "current_wind_gust_speed": { 193 | "name": "Vitesse des rafales de vent" 194 | }, 195 | "current_wind_bearing": { 196 | "name": "Direction du vent" 197 | }, 198 | "current_uv_index": { 199 | "name": "Index UV" 200 | }, 201 | "current_pressure": { 202 | "name": "Pression atmosphérique" 203 | }, 204 | "current_rainfall": { 205 | "name": "Précipitation" 206 | } 207 | } 208 | }, 209 | "services": { 210 | "get_forecasts_radar": { 211 | "name": "Obtenir les prévisions du radar", 212 | "description": "Obtenez les prévisions météorologiques depuis le radar. Seules les précipitations sont disponibles.", 213 | "fields": { 214 | "include_past_forecasts": { 215 | "name": "Inclure les prévisions passées", 216 | "description": "Retourne également les prévisions qui sont dans le passé." 217 | } 218 | } 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for the IRM KMI integration tests.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from datetime import datetime, timedelta 6 | from typing import Generator 7 | from unittest.mock import MagicMock, patch 8 | 9 | import pytest 10 | from homeassistant.const import CONF_ZONE 11 | from irm_kmi_api.api import (IrmKmiApiClientHa, IrmKmiApiError, 12 | IrmKmiApiParametersError) 13 | from irm_kmi_api.data import AnimationFrameData, RadarAnimationData 14 | from pytest_homeassistant_custom_component.common import (MockConfigEntry, 15 | load_fixture) 16 | 17 | from custom_components.irm_kmi import OPTION_STYLE_STD 18 | from custom_components.irm_kmi.const import ( 19 | CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE, 20 | CONF_USE_DEPRECATED_FORECAST, DOMAIN, IRM_KMI_TO_HA_CONDITION_MAP, 21 | OPTION_DEPRECATED_FORECAST_NOT_USED, 22 | OPTION_DEPRECATED_FORECAST_TWICE_DAILY) 23 | 24 | 25 | def get_api_data(fixture: str) -> dict: 26 | return json.loads(load_fixture(fixture)) 27 | 28 | 29 | def get_api_with_data(fixture: str) -> IrmKmiApiClientHa: 30 | api = IrmKmiApiClientHa(session=MagicMock(), user_agent='', cdt_map=IRM_KMI_TO_HA_CONDITION_MAP) 31 | api._api_data = get_api_data(fixture) 32 | return api 33 | 34 | 35 | @pytest.fixture(autouse=True) 36 | def auto_enable_custom_integrations(enable_custom_integrations): 37 | yield 38 | 39 | 40 | @pytest.fixture 41 | def mock_config_entry() -> MockConfigEntry: 42 | """Return the default mocked config entry.""" 43 | return MockConfigEntry( 44 | title="Home", 45 | domain=DOMAIN, 46 | data={CONF_ZONE: "zone.home", 47 | CONF_STYLE: OPTION_STYLE_STD, 48 | CONF_DARK_MODE: True, 49 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED, 50 | CONF_LANGUAGE_OVERRIDE: 'none'}, 51 | unique_id="zone.home", 52 | ) 53 | 54 | 55 | @pytest.fixture 56 | def mock_config_entry_with_deprecated() -> MockConfigEntry: 57 | """Return the default mocked config entry.""" 58 | return MockConfigEntry( 59 | title="Home", 60 | domain=DOMAIN, 61 | data={CONF_ZONE: "zone.home", 62 | CONF_STYLE: OPTION_STYLE_STD, 63 | CONF_DARK_MODE: True, 64 | CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_TWICE_DAILY, 65 | CONF_LANGUAGE_OVERRIDE: 'none'}, 66 | unique_id="zone.home", 67 | ) 68 | 69 | 70 | @pytest.fixture 71 | def mock_setup_entry() -> Generator[None, None, None]: 72 | """Mock setting up a config entry.""" 73 | with patch( 74 | "custom_components.irm_kmi.async_setup_entry", return_value=True 75 | ): 76 | yield 77 | 78 | 79 | @pytest.fixture 80 | def mock_get_forecast_in_benelux(): 81 | """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux""" 82 | with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", 83 | return_value={'cityName': 'Brussels'}): 84 | yield 85 | 86 | 87 | @pytest.fixture 88 | def mock_get_forecast_out_benelux(): 89 | """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux""" 90 | with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", 91 | return_value={'cityName': "Outside the Benelux (Brussels)"}): 92 | yield 93 | 94 | 95 | @pytest.fixture 96 | def mock_get_forecast_api_error(): 97 | """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error""" 98 | with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", 99 | side_effet=IrmKmiApiError): 100 | return 101 | 102 | 103 | @pytest.fixture 104 | def mock_get_forecast_api_error_repair(): 105 | """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error""" 106 | with patch("custom_components.irm_kmi.repairs.IrmKmiApiClient.get_forecasts_coord", 107 | side_effet=IrmKmiApiError): 108 | return 109 | 110 | 111 | @pytest.fixture() 112 | def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: 113 | """Return a mocked IrmKmi api client.""" 114 | fixture: str = "forecast.json" 115 | 116 | forecast = json.loads(load_fixture(fixture)) 117 | with patch( 118 | "custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True 119 | ) as irm_kmi_api_mock: 120 | irm_kmi = irm_kmi_api_mock.return_value 121 | irm_kmi.get_forecasts_coord.return_value = forecast 122 | irm_kmi.get_radar_forecast.return_value = {} 123 | yield irm_kmi 124 | 125 | 126 | @pytest.fixture() 127 | def mock_irm_kmi_api_repair_in_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: 128 | """Return a mocked IrmKmi api client.""" 129 | fixture: str = "forecast.json" 130 | 131 | forecast = json.loads(load_fixture(fixture)) 132 | with patch( 133 | "custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True 134 | ) as irm_kmi_api_mock: 135 | irm_kmi = irm_kmi_api_mock.return_value 136 | irm_kmi.get_forecasts_coord.return_value = forecast 137 | yield irm_kmi 138 | 139 | 140 | @pytest.fixture() 141 | def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: 142 | """Return a mocked IrmKmi api client.""" 143 | fixture: str = "forecast_out_of_benelux.json" 144 | 145 | forecast = json.loads(load_fixture(fixture)) 146 | with patch( 147 | "custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True 148 | ) as irm_kmi_api_mock: 149 | irm_kmi = irm_kmi_api_mock.return_value 150 | irm_kmi.get_forecasts_coord.return_value = forecast 151 | yield irm_kmi 152 | 153 | 154 | @pytest.fixture() 155 | def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: 156 | """Return a mocked IrmKmi api client.""" 157 | with patch( 158 | "custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True 159 | ) as irm_kmi_api_mock: 160 | irm_kmi = irm_kmi_api_mock.return_value 161 | irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiParametersError 162 | yield irm_kmi 163 | 164 | def get_radar_animation_data() -> RadarAnimationData: 165 | with open("tests/fixtures/clouds_be.png", "rb") as file: 166 | image_data = file.read() 167 | with open("tests/fixtures/loc_layer_be_n.png", "rb") as file: 168 | location = file.read() 169 | 170 | sequence = [ 171 | AnimationFrameData( 172 | time=datetime.fromisoformat("2023-12-26T18:30:00+00:00") + timedelta(minutes=10 * i), 173 | image=image_data, 174 | value=2, 175 | position=.5, 176 | position_lower=.4, 177 | position_higher=.6 178 | ) 179 | for i in range(10) 180 | ] 181 | 182 | return RadarAnimationData( 183 | sequence=sequence, 184 | most_recent_image_idx=2, 185 | hint="Testing SVG camera", 186 | unit="mm/10min", 187 | location=location 188 | ) 189 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for the IRM KMI integration.""" 2 | import logging 3 | from datetime import timedelta 4 | 5 | import async_timeout 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers import issue_registry 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 12 | from homeassistant.helpers.update_coordinator import ( 13 | TimestampDataUpdateCoordinator, UpdateFailed) 14 | from homeassistant.util import dt 15 | from homeassistant.util.dt import utcnow 16 | from irm_kmi_api.api import IrmKmiApiClientHa, IrmKmiApiError 17 | from irm_kmi_api.pollen import PollenParser 18 | from irm_kmi_api.rain_graph import RainGraph 19 | 20 | from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN, IRM_KMI_NAME 21 | from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP 22 | from .const import OUT_OF_BENELUX, USER_AGENT 23 | from .data import ProcessedCoordinatorData 24 | from .utils import disable_from_config, get_config_value, preferred_language 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | class IrmKmiCoordinator(TimestampDataUpdateCoordinator): 30 | """Coordinator to update data from IRM KMI""" 31 | 32 | def __init__(self, hass: HomeAssistant, entry: ConfigEntry): 33 | """Initialize the coordinator.""" 34 | super().__init__( 35 | hass, 36 | _LOGGER, 37 | config_entry=entry, 38 | # Name of the data. For logging purposes. 39 | name="IRM KMI weather", 40 | # Polling interval. Will only be polled if there are subscribers. 41 | update_interval=timedelta(minutes=7), 42 | ) 43 | self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP) 44 | self._zone = get_config_value(entry, CONF_ZONE) 45 | self._dark_mode = get_config_value(entry, CONF_DARK_MODE) 46 | self._style = get_config_value(entry, CONF_STYLE) 47 | self.shared_device_info = DeviceInfo( 48 | entry_type=DeviceEntryType.SERVICE, 49 | identifiers={(DOMAIN, entry.entry_id)}, 50 | manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, self.config_entry)), 51 | name=f"{entry.title}" 52 | ) 53 | 54 | async def _async_update_data(self) -> ProcessedCoordinatorData: 55 | """Fetch data from API endpoint. 56 | 57 | This is the place to pre-process the data to lookup tables 58 | so entities can quickly look up their data. 59 | """ 60 | # When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging 61 | logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel()) 62 | 63 | self._api.expire_cache() 64 | if (zone := self.hass.states.get(self._zone)) is None: 65 | raise UpdateFailed(f"Zone '{self._zone}' not found") 66 | try: 67 | # Note: asyncio.TimeoutError and aiohttp.ClientError are already 68 | # handled by the data update coordinator. 69 | async with async_timeout.timeout(60): 70 | await self._api.refresh_forecasts_coord( 71 | {'lat': zone.attributes[ATTR_LATITUDE], 72 | 'long': zone.attributes[ATTR_LONGITUDE]} 73 | ) 74 | 75 | except IrmKmiApiError as err: 76 | if self.last_update_success_time is not None \ 77 | and self.last_update_success_time - utcnow() < 2.5 * self.update_interval: 78 | _LOGGER.warning(f"Error communicating with API for general forecast: {err}. Keeping the old data.") 79 | return self.data 80 | else: 81 | raise UpdateFailed(f"Error communicating with API for general forecast: {err}. " 82 | f"Last success time is: {self.last_update_success_time}") 83 | 84 | if self._api.get_city() in OUT_OF_BENELUX: 85 | _LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. " 86 | f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix " 87 | f"this") 88 | disable_from_config(self.hass, self.config_entry) 89 | 90 | issue_registry.async_create_issue( 91 | self.hass, 92 | DOMAIN, 93 | "zone_moved", 94 | is_fixable=True, 95 | severity=issue_registry.IssueSeverity.ERROR, 96 | translation_key='zone_moved', 97 | data={'config_entry_id': self.config_entry.entry_id, 'zone': self._zone}, 98 | translation_placeholders={'zone': self._zone} 99 | ) 100 | return ProcessedCoordinatorData() 101 | 102 | return await self.process_api_data() 103 | 104 | async def async_refresh(self) -> None: 105 | """Refresh data and log errors.""" 106 | await self._async_refresh(log_failures=True, raise_on_entry_error=True) 107 | 108 | async def process_api_data(self) -> ProcessedCoordinatorData: 109 | """From the API data, create the object that will be used in the entities""" 110 | tz = await dt.async_get_time_zone('Europe/Brussels') 111 | lang = preferred_language(self.hass, self.config_entry) 112 | try: 113 | pollen = await self._api.get_pollen() 114 | except IrmKmiApiError as err: 115 | _LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.") 116 | pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \ 117 | if self.data is not None else PollenParser.get_unavailable_data() 118 | 119 | try: 120 | radar_animation = self._api.get_animation_data(tz, lang, self._style, self._dark_mode) 121 | animation = await RainGraph(radar_animation, 122 | country=self._api.get_country(), 123 | style=self._style, 124 | tz=tz, 125 | dark_mode=self._dark_mode, 126 | api_client=self._api 127 | ).build() 128 | except ValueError: 129 | animation = None 130 | 131 | 132 | # Make 'condition_evol' in a str instead of enum variant 133 | daily_forecast = [ 134 | {**d, "condition_evol": d["condition_evol"].value} 135 | if "condition_evol" in d and hasattr(d["condition_evol"], "value") 136 | else d 137 | for d in self._api.get_daily_forecast(tz, lang) 138 | ] 139 | 140 | return ProcessedCoordinatorData( 141 | current_weather=self._api.get_current_weather(tz), 142 | daily_forecast=daily_forecast, 143 | hourly_forecast=self._api.get_hourly_forecast(tz), 144 | radar_forecast=self._api.get_radar_forecast(), 145 | animation=animation, 146 | warnings=self._api.get_warnings(lang), 147 | pollen=pollen, 148 | country=self._api.get_country() 149 | ) 150 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to set up IRM KMI integration via the UI.""" 2 | import logging 3 | 4 | import async_timeout 5 | import voluptuous as vol 6 | from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN 7 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow 8 | from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE 9 | from homeassistant.core import callback 10 | from homeassistant.data_entry_flow import FlowResult 11 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 12 | from homeassistant.helpers.selector import (EntitySelector, 13 | EntitySelectorConfig, 14 | SelectSelector, 15 | SelectSelectorConfig, 16 | SelectSelectorMode) 17 | from irm_kmi_api.api import IrmKmiApiClient 18 | 19 | from . import OPTION_STYLE_STD 20 | from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, 21 | CONF_LANGUAGE_OVERRIDE_OPTIONS, CONF_STYLE, 22 | CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST, 23 | CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION, 24 | DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, 25 | OUT_OF_BENELUX, USER_AGENT) 26 | from .utils import get_config_value 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN): 32 | VERSION = CONFIG_FLOW_VERSION 33 | 34 | @staticmethod 35 | @callback 36 | def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: 37 | """Create the options flow.""" 38 | return IrmKmiOptionFlow(config_entry) 39 | 40 | async def async_step_user(self, user_input: dict | None = None) -> FlowResult: 41 | """Define the user step of the configuration flow.""" 42 | errors = {} 43 | 44 | if user_input: 45 | _LOGGER.debug(f"Provided config user is: {user_input}") 46 | 47 | if (zone := self.hass.states.get(user_input[CONF_ZONE])) is None: 48 | errors[CONF_ZONE] = 'zone_not_exist' 49 | 50 | # Check if zone is in Benelux 51 | if not errors: 52 | api_data = {} 53 | try: 54 | async with (async_timeout.timeout(60)): 55 | api_data = await IrmKmiApiClient( 56 | session=async_get_clientsession(self.hass), 57 | user_agent=USER_AGENT 58 | ).get_forecasts_coord( 59 | {'lat': zone.attributes[ATTR_LATITUDE], 60 | 'long': zone.attributes[ATTR_LONGITUDE]} 61 | ) 62 | except Exception: 63 | errors['base'] = "api_error" 64 | 65 | if api_data.get('cityName', None) in OUT_OF_BENELUX: 66 | errors[CONF_ZONE] = 'out_of_benelux' 67 | 68 | if not errors: 69 | await self.async_set_unique_id(user_input[CONF_ZONE]) 70 | self._abort_if_unique_id_configured() 71 | 72 | state = self.hass.states.get(user_input[CONF_ZONE]) 73 | return self.async_create_entry( 74 | title=state.name if state else "IRM KMI", 75 | data={CONF_ZONE: user_input[CONF_ZONE], 76 | CONF_STYLE: user_input[CONF_STYLE], 77 | CONF_DARK_MODE: user_input[CONF_DARK_MODE], 78 | CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST], 79 | CONF_LANGUAGE_OVERRIDE: user_input[CONF_LANGUAGE_OVERRIDE]}, 80 | ) 81 | 82 | return self.async_show_form( 83 | step_id="user", 84 | errors=errors, 85 | description_placeholders={'zone': user_input.get('zone') if user_input is not None else None}, 86 | data_schema=vol.Schema({ 87 | vol.Required(CONF_ZONE): 88 | EntitySelector(EntitySelectorConfig(domain=ZONE_DOMAIN)), 89 | 90 | vol.Optional(CONF_STYLE, default=OPTION_STYLE_STD): 91 | SelectSelector(SelectSelectorConfig(options=CONF_STYLE_OPTIONS, 92 | mode=SelectSelectorMode.DROPDOWN, 93 | translation_key=CONF_STYLE)), 94 | 95 | vol.Optional(CONF_DARK_MODE, default=False): bool, 96 | 97 | vol.Optional(CONF_USE_DEPRECATED_FORECAST, default=OPTION_DEPRECATED_FORECAST_NOT_USED): 98 | SelectSelector(SelectSelectorConfig(options=CONF_USE_DEPRECATED_FORECAST_OPTIONS, 99 | mode=SelectSelectorMode.DROPDOWN, 100 | translation_key=CONF_USE_DEPRECATED_FORECAST)), 101 | 102 | vol.Optional(CONF_LANGUAGE_OVERRIDE, default='none'): 103 | SelectSelector(SelectSelectorConfig(options=CONF_LANGUAGE_OVERRIDE_OPTIONS, 104 | mode=SelectSelectorMode.DROPDOWN, 105 | translation_key=CONF_LANGUAGE_OVERRIDE)) 106 | 107 | })) 108 | 109 | 110 | class IrmKmiOptionFlow(OptionsFlow): 111 | def __init__(self, config_entry: ConfigEntry) -> None: 112 | """Initialize options flow.""" 113 | self.current_config_entry = config_entry 114 | 115 | async def async_step_init(self, user_input: dict | None = None) -> FlowResult: 116 | """Manage the options.""" 117 | if user_input is not None: 118 | _LOGGER.debug(user_input) 119 | return self.async_create_entry(data=user_input) 120 | 121 | return self.async_show_form( 122 | step_id="init", 123 | data_schema=vol.Schema( 124 | { 125 | vol.Optional(CONF_STYLE, default=get_config_value(self.current_config_entry, CONF_STYLE)): 126 | SelectSelector(SelectSelectorConfig(options=CONF_STYLE_OPTIONS, 127 | mode=SelectSelectorMode.DROPDOWN, 128 | translation_key=CONF_STYLE)), 129 | 130 | vol.Optional(CONF_DARK_MODE, default=get_config_value(self.current_config_entry, CONF_DARK_MODE)): bool, 131 | 132 | vol.Optional(CONF_USE_DEPRECATED_FORECAST, 133 | default=get_config_value(self.current_config_entry, CONF_USE_DEPRECATED_FORECAST)): 134 | SelectSelector(SelectSelectorConfig(options=CONF_USE_DEPRECATED_FORECAST_OPTIONS, 135 | mode=SelectSelectorMode.DROPDOWN, 136 | translation_key=CONF_USE_DEPRECATED_FORECAST)), 137 | 138 | vol.Optional(CONF_LANGUAGE_OVERRIDE, 139 | default=get_config_value(self.current_config_entry, CONF_LANGUAGE_OVERRIDE)): 140 | SelectSelector(SelectSelectorConfig(options=CONF_LANGUAGE_OVERRIDE_OPTIONS, 141 | mode=SelectSelectorMode.DROPDOWN, 142 | translation_key=CONF_LANGUAGE_OVERRIDE)) 143 | } 144 | ), 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_weather.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from typing import List 4 | 5 | from freezegun import freeze_time 6 | from homeassistant.components.weather import Forecast 7 | from homeassistant.core import HomeAssistant 8 | from irm_kmi_api.data import IrmKmiRadarForecast 9 | from pytest_homeassistant_custom_component.common import (MockConfigEntry, 10 | load_fixture) 11 | 12 | from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather 13 | from custom_components.irm_kmi.data import ProcessedCoordinatorData 14 | from tests.conftest import get_api_with_data 15 | 16 | 17 | @freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00")) 18 | async def test_weather_nl( 19 | hass: HomeAssistant, 20 | mock_config_entry: MockConfigEntry 21 | ) -> None: 22 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 23 | forecast = json.loads(load_fixture("forecast_nl.json")) 24 | coordinator._api._api_data = forecast 25 | 26 | coordinator.data = await coordinator.process_api_data() 27 | weather = IrmKmiWeather(coordinator, mock_config_entry) 28 | result = await weather.async_forecast_daily() 29 | 30 | assert isinstance(result, list) 31 | assert len(result) == 7 32 | 33 | # When getting daily forecast, the min temperature of the current day 34 | # should be the min temperature of the coming night 35 | assert result[0]['native_templow'] == 9 36 | 37 | 38 | @freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00")) 39 | async def test_weather_higher_temp_at_night( 40 | hass: HomeAssistant, 41 | mock_config_entry: MockConfigEntry 42 | ) -> None: 43 | # Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8 44 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 45 | forecast = json.loads(load_fixture("high_low_temp.json")) 46 | coordinator._api._api_data = forecast 47 | 48 | coordinator.data = await coordinator.process_api_data() 49 | 50 | weather = IrmKmiWeather(coordinator, mock_config_entry) 51 | result: List[Forecast] = await weather.async_forecast_daily() 52 | 53 | for f in result: 54 | if f['native_temperature'] is not None and f['native_templow'] is not None: 55 | assert f['native_temperature'] >= f['native_templow'] 56 | 57 | result: List[Forecast] = await weather.async_forecast_twice_daily() 58 | 59 | for f in result: 60 | if f['native_temperature'] is not None and f['native_templow'] is not None: 61 | assert f['native_temperature'] >= f['native_templow'] 62 | 63 | 64 | @freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00")) 65 | async def test_forecast_attribute_same_as_service_call( 66 | hass: HomeAssistant, 67 | mock_config_entry_with_deprecated: MockConfigEntry 68 | ) -> None: 69 | coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated) 70 | forecast = json.loads(load_fixture("forecast.json")) 71 | coordinator._api._api_data = forecast 72 | 73 | coordinator.data = await coordinator.process_api_data() 74 | 75 | weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated) 76 | 77 | result_service: List[Forecast] = await weather.async_forecast_twice_daily() 78 | result_forecast: List[Forecast] = weather.extra_state_attributes['forecast'] 79 | 80 | assert result_service == result_forecast 81 | 82 | 83 | @freeze_time(datetime.fromisoformat("2023-12-26T17:58:03+01:00")) 84 | async def test_radar_forecast_service( 85 | hass: HomeAssistant, 86 | mock_config_entry: MockConfigEntry 87 | ): 88 | hass.config.time_zone = 'Europe/Brussels' 89 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 90 | 91 | coordinator._api = get_api_with_data("forecast.json") 92 | 93 | coordinator.data = ProcessedCoordinatorData( 94 | radar_forecast=coordinator._api.get_radar_forecast() 95 | ) 96 | 97 | weather = IrmKmiWeather(coordinator, mock_config_entry) 98 | 99 | result_service: List[Forecast] = weather.get_forecasts_radar_service(False) 100 | 101 | expected = [ 102 | IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False, 103 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 104 | IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False, 105 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 106 | IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False, 107 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 108 | IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False, 109 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 110 | IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False, 111 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 112 | IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False, 113 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 114 | IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False, 115 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 116 | IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False, 117 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 118 | IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False, 119 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 120 | IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False, 121 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'), 122 | IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False, 123 | rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min') 124 | ] 125 | 126 | assert result_service == expected[5:] 127 | 128 | result_service: List[Forecast] = weather.get_forecasts_radar_service(True) 129 | 130 | assert result_service == expected 131 | 132 | def is_serializable(x): 133 | try: 134 | json.dumps(x) 135 | return True 136 | except (TypeError, OverflowError): 137 | return False 138 | 139 | def all_serializable(elements: list[Forecast]): 140 | for element in elements: 141 | for v in element.values(): 142 | assert is_serializable(v) 143 | 144 | async def test_forecast_types_are_serializable( 145 | hass: HomeAssistant, 146 | mock_config_entry: MockConfigEntry 147 | ) -> None: 148 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 149 | forecast = json.loads(load_fixture("forecast.json")) 150 | coordinator._api._api_data = forecast 151 | 152 | coordinator.data = await coordinator.process_api_data() 153 | weather = IrmKmiWeather(coordinator, mock_config_entry) 154 | 155 | result = await weather.async_forecast_daily() 156 | all_serializable(result) 157 | 158 | result = await weather.async_forecast_twice_daily() 159 | all_serializable(result) 160 | 161 | result = await weather.async_forecast_hourly() 162 | all_serializable(result) 163 | 164 | result = weather.get_forecasts_radar_service(True) 165 | all_serializable(result) -------------------------------------------------------------------------------- /custom_components/irm_kmi/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the IRM KMI integration.""" 2 | from typing import Final 3 | 4 | from homeassistant.components.sensor import SensorDeviceClass 5 | from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT, 6 | ATTR_CONDITION_CLOUDY, 7 | ATTR_CONDITION_FOG, 8 | ATTR_CONDITION_LIGHTNING_RAINY, 9 | ATTR_CONDITION_PARTLYCLOUDY, 10 | ATTR_CONDITION_POURING, 11 | ATTR_CONDITION_RAINY, 12 | ATTR_CONDITION_SNOWY, 13 | ATTR_CONDITION_SNOWY_RAINY, 14 | ATTR_CONDITION_SUNNY) 15 | from homeassistant.const import (DEGREE, Platform, UnitOfPressure, UnitOfSpeed, 16 | UnitOfTemperature) 17 | from irm_kmi_api.const import (OPTION_STYLE_CONTRAST, OPTION_STYLE_SATELLITE, 18 | OPTION_STYLE_STD, OPTION_STYLE_YELLOW_RED) 19 | 20 | DOMAIN: Final = 'irm_kmi' 21 | PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR] 22 | CONFIG_FLOW_VERSION = 5 23 | 24 | OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)", 25 | "Hors de Belgique (Bxl)", 26 | "Outside the Benelux (Brussels)", 27 | "Buiten de Benelux (Brussel)"] 28 | LANGS: Final = ['en', 'fr', 'nl', 'de'] 29 | 30 | CONF_STYLE: Final = "style" 31 | 32 | CONF_STYLE_OPTIONS: Final = [ 33 | OPTION_STYLE_STD, 34 | OPTION_STYLE_CONTRAST, 35 | OPTION_STYLE_YELLOW_RED, 36 | OPTION_STYLE_SATELLITE 37 | ] 38 | 39 | CONF_DARK_MODE: Final = "dark_mode" 40 | 41 | CONF_USE_DEPRECATED_FORECAST: Final = 'use_deprecated_forecast_attribute' 42 | OPTION_DEPRECATED_FORECAST_NOT_USED: Final = 'do_not_use_deprecated_forecast' 43 | OPTION_DEPRECATED_FORECAST_DAILY: Final = 'daily_in_deprecated_forecast' 44 | OPTION_DEPRECATED_FORECAST_TWICE_DAILY: Final = 'twice_daily_in_deprecated_forecast' 45 | OPTION_DEPRECATED_FORECAST_HOURLY: Final = 'hourly_in_deprecated_forecast' 46 | 47 | CONF_USE_DEPRECATED_FORECAST_OPTIONS: Final = [ 48 | OPTION_DEPRECATED_FORECAST_NOT_USED, 49 | OPTION_DEPRECATED_FORECAST_DAILY, 50 | OPTION_DEPRECATED_FORECAST_TWICE_DAILY, 51 | OPTION_DEPRECATED_FORECAST_HOURLY 52 | ] 53 | 54 | CONF_LANGUAGE_OVERRIDE: Final = 'language_override' 55 | 56 | CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = [ 57 | 'none', "fr", "nl", "de", "en" 58 | ] 59 | 60 | REPAIR_SOLUTION: Final = "repair_solution" 61 | REPAIR_OPT_MOVE: Final = "repair_option_move" 62 | REPAIR_OPT_DELETE: Final = "repair_option_delete" 63 | REPAIR_OPTIONS: Final = [REPAIR_OPT_MOVE, REPAIR_OPT_DELETE] 64 | 65 | # map ('ww', 'dayNight') tuple from IRM KMI to HA conditions 66 | IRM_KMI_TO_HA_CONDITION_MAP: Final = { 67 | (0, 'd'): ATTR_CONDITION_SUNNY, 68 | (0, 'n'): ATTR_CONDITION_CLEAR_NIGHT, 69 | (1, 'd'): ATTR_CONDITION_SUNNY, 70 | (1, 'n'): ATTR_CONDITION_CLEAR_NIGHT, 71 | (2, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 72 | (2, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 73 | (3, 'd'): ATTR_CONDITION_PARTLYCLOUDY, 74 | (3, 'n'): ATTR_CONDITION_PARTLYCLOUDY, 75 | (4, 'd'): ATTR_CONDITION_POURING, 76 | (4, 'n'): ATTR_CONDITION_POURING, 77 | (5, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 78 | (5, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 79 | (6, 'd'): ATTR_CONDITION_POURING, 80 | (6, 'n'): ATTR_CONDITION_POURING, 81 | (7, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 82 | (7, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 83 | (8, 'd'): ATTR_CONDITION_SNOWY_RAINY, 84 | (8, 'n'): ATTR_CONDITION_SNOWY_RAINY, 85 | (9, 'd'): ATTR_CONDITION_SNOWY_RAINY, 86 | (9, 'n'): ATTR_CONDITION_SNOWY_RAINY, 87 | (10, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 88 | (10, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 89 | (11, 'd'): ATTR_CONDITION_SNOWY, 90 | (11, 'n'): ATTR_CONDITION_SNOWY, 91 | (12, 'd'): ATTR_CONDITION_SNOWY, 92 | (12, 'n'): ATTR_CONDITION_SNOWY, 93 | (13, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 94 | (13, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 95 | (14, 'd'): ATTR_CONDITION_CLOUDY, 96 | (14, 'n'): ATTR_CONDITION_CLOUDY, 97 | (15, 'd'): ATTR_CONDITION_CLOUDY, 98 | (15, 'n'): ATTR_CONDITION_CLOUDY, 99 | (16, 'd'): ATTR_CONDITION_POURING, 100 | (16, 'n'): ATTR_CONDITION_POURING, 101 | (17, 'd'): ATTR_CONDITION_LIGHTNING_RAINY, 102 | (17, 'n'): ATTR_CONDITION_LIGHTNING_RAINY, 103 | (18, 'd'): ATTR_CONDITION_RAINY, 104 | (18, 'n'): ATTR_CONDITION_RAINY, 105 | (19, 'd'): ATTR_CONDITION_POURING, 106 | (19, 'n'): ATTR_CONDITION_POURING, 107 | (20, 'd'): ATTR_CONDITION_SNOWY_RAINY, 108 | (20, 'n'): ATTR_CONDITION_SNOWY_RAINY, 109 | (21, 'd'): ATTR_CONDITION_RAINY, 110 | (21, 'n'): ATTR_CONDITION_RAINY, 111 | (22, 'd'): ATTR_CONDITION_SNOWY, 112 | (22, 'n'): ATTR_CONDITION_SNOWY, 113 | (23, 'd'): ATTR_CONDITION_SNOWY, 114 | (23, 'n'): ATTR_CONDITION_SNOWY, 115 | (24, 'd'): ATTR_CONDITION_FOG, 116 | (24, 'n'): ATTR_CONDITION_FOG, 117 | (25, 'd'): ATTR_CONDITION_FOG, 118 | (25, 'n'): ATTR_CONDITION_FOG, 119 | (26, 'd'): ATTR_CONDITION_FOG, 120 | (26, 'n'): ATTR_CONDITION_FOG, 121 | (27, 'd'): ATTR_CONDITION_FOG, 122 | (27, 'n'): ATTR_CONDITION_FOG 123 | } 124 | 125 | POLLEN_TO_ICON_MAP: Final = { 126 | 'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree', 127 | 'mugwort': 'mdi:sprout', 'oak': 'mdi:tree' 128 | } 129 | 130 | IRM_KMI_NAME: Final = { 131 | 'fr': 'Institut Royal Météorologique de Belgique', 132 | 'nl': 'Koninklijk Meteorologisch Instituut van België', 133 | 'de': 'Königliche Meteorologische Institut von Belgien', 134 | 'en': 'Royal Meteorological Institute of Belgium' 135 | } 136 | 137 | USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.3.2' 138 | 139 | CURRENT_WEATHER_SENSORS: Final = {'temperature', 'wind_speed', 'wind_gust_speed', 'wind_bearing', 'uv_index', 140 | 'pressure'} 141 | 142 | CURRENT_WEATHER_SENSOR_UNITS: Final = {'temperature': UnitOfTemperature.CELSIUS, 143 | 'wind_speed': UnitOfSpeed.KILOMETERS_PER_HOUR, 144 | 'wind_gust_speed': UnitOfSpeed.KILOMETERS_PER_HOUR, 145 | 'wind_bearing': DEGREE, 146 | # Need to put '', else the history shows a bar graph instead of a chart 147 | 'uv_index': '', 148 | 'pressure': UnitOfPressure.HPA} 149 | 150 | CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE, 151 | 'wind_speed': SensorDeviceClass.WIND_SPEED, 152 | 'wind_gust_speed': SensorDeviceClass.WIND_SPEED, 153 | 'wind_bearing': None, 154 | 'uv_index': None, 155 | 'pressure': SensorDeviceClass.ATMOSPHERIC_PRESSURE} 156 | 157 | # Leave None when we want the default icon to be shown 158 | CURRENT_WEATHER_SENSOR_ICON: Final = {'temperature': None, 159 | 'wind_speed': None, 160 | 'wind_gust_speed': None, 161 | 'wind_bearing': 'mdi:compass', 162 | 'uv_index': 'mdi:sun-wireless', 163 | 'pressure': None} 164 | -------------------------------------------------------------------------------- /tests/fixtures/pollens-2025.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 12 | 14 | 16 | 17 | 18 | Active pollen 21 | 24 | 26 | 27 | 29 | 31 | 33 | 35 | 37 | 39 | 41 | 43 | 45 | 47 | active 48 | active 49 | Alder 50 | Hazel 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/fixtures/new_two_pollens.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 13 | 15 | 17 | 18 | 19 | Active pollen 22 | 25 | 27 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | high 49 | active 50 | Grasses 51 | Mugwort 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/weather.py: -------------------------------------------------------------------------------- 1 | """Support for IRM KMI weather.""" 2 | import logging 3 | from datetime import datetime 4 | from typing import List 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.weather import (Forecast, WeatherEntity, 8 | WeatherEntityFeature) 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure, 11 | UnitOfSpeed, UnitOfTemperature) 12 | from homeassistant.core import HomeAssistant, SupportsResponse 13 | from homeassistant.helpers import config_validation as cv 14 | from homeassistant.helpers import entity_platform 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 17 | from homeassistant.util import dt 18 | 19 | from . import CONF_USE_DEPRECATED_FORECAST, DOMAIN 20 | from .const import (OPTION_DEPRECATED_FORECAST_DAILY, 21 | OPTION_DEPRECATED_FORECAST_HOURLY, 22 | OPTION_DEPRECATED_FORECAST_NOT_USED, 23 | OPTION_DEPRECATED_FORECAST_TWICE_DAILY) 24 | from .coordinator import IrmKmiCoordinator 25 | from .utils import get_config_value 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 31 | """Set up the weather entry.""" 32 | add_services() 33 | 34 | coordinator = hass.data[DOMAIN][entry.entry_id] 35 | async_add_entities([IrmKmiWeather(coordinator, entry)]) 36 | 37 | 38 | def add_services() -> None: 39 | platform = entity_platform.async_get_current_platform() 40 | 41 | platform.async_register_entity_service( 42 | "get_forecasts_radar", 43 | cv.make_entity_service_schema({ 44 | vol.Optional("include_past_forecasts"): vol.Boolean() 45 | }), 46 | IrmKmiWeather.get_forecasts_radar_service.__name__, 47 | supports_response=SupportsResponse.ONLY 48 | ) 49 | 50 | 51 | class IrmKmiWeather(CoordinatorEntity, WeatherEntity): 52 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 53 | 54 | def __init__(self, 55 | coordinator: IrmKmiCoordinator, 56 | entry: ConfigEntry 57 | ) -> None: 58 | super().__init__(coordinator) 59 | WeatherEntity.__init__(self) 60 | self._name = entry.title 61 | self._attr_unique_id = entry.entry_id 62 | self._attr_device_info = coordinator.shared_device_info 63 | self._deprecated_forecast_as = get_config_value(entry, CONF_USE_DEPRECATED_FORECAST) 64 | 65 | if self._deprecated_forecast_as != OPTION_DEPRECATED_FORECAST_NOT_USED: 66 | _LOGGER.warning(f"You are using the forecast attribute for {entry.title} weather. Home Assistant deleted " 67 | f"that attribute in 2024.4. Consider using the service weather.get_forecasts instead " 68 | f"as the attribute will be delete from this integration in a future release.") 69 | 70 | @property 71 | def supported_features(self) -> WeatherEntityFeature: 72 | features = WeatherEntityFeature(0) 73 | features |= WeatherEntityFeature.FORECAST_DAILY 74 | features |= WeatherEntityFeature.FORECAST_TWICE_DAILY 75 | features |= WeatherEntityFeature.FORECAST_HOURLY 76 | return features 77 | 78 | @property 79 | def name(self) -> str: 80 | return self._name 81 | 82 | @property 83 | def condition(self) -> str | None: 84 | return self.coordinator.data.get('current_weather', {}).get('condition') 85 | 86 | @property 87 | def native_temperature(self) -> float | None: 88 | return self.coordinator.data.get('current_weather', {}).get('temperature') 89 | 90 | @property 91 | def native_temperature_unit(self) -> str | None: 92 | return UnitOfTemperature.CELSIUS 93 | 94 | @property 95 | def native_wind_speed_unit(self) -> str | None: 96 | return UnitOfSpeed.KILOMETERS_PER_HOUR 97 | 98 | @property 99 | def native_wind_speed(self) -> float | None: 100 | return self.coordinator.data.get('current_weather', {}).get('wind_speed') 101 | 102 | @property 103 | def native_wind_gust_speed(self) -> float | None: 104 | return self.coordinator.data.get('current_weather', {}).get('wind_gust_speed') 105 | 106 | @property 107 | def wind_bearing(self) -> float | str | None: 108 | return self.coordinator.data.get('current_weather', {}).get('wind_bearing') 109 | 110 | @property 111 | def native_precipitation_unit(self) -> str | None: 112 | return UnitOfPrecipitationDepth.MILLIMETERS 113 | 114 | @property 115 | def native_pressure(self) -> float | None: 116 | return self.coordinator.data.get('current_weather', {}).get('pressure') 117 | 118 | @property 119 | def native_pressure_unit(self) -> str | None: 120 | return UnitOfPressure.HPA 121 | 122 | @property 123 | def uv_index(self) -> float | None: 124 | return self.coordinator.data.get('current_weather', {}).get('uv_index') 125 | 126 | async def async_forecast_twice_daily(self) -> List[Forecast] | None: 127 | return self.coordinator.data.get('daily_forecast') 128 | 129 | async def async_forecast_daily(self) -> list[Forecast] | None: 130 | return self.daily_forecast() 131 | 132 | async def async_forecast_hourly(self) -> list[Forecast] | None: 133 | return self.coordinator.data.get('hourly_forecast') 134 | 135 | def daily_forecast(self) -> list[Forecast] | None: 136 | data: list[Forecast] = self.coordinator.data.get('daily_forecast') 137 | if not isinstance(data, list): 138 | return None 139 | if len(data) > 1 and not data[0].get('is_daytime') and data[1].get('native_templow') is None: 140 | data[1]['native_templow'] = data[0].get('native_templow') 141 | if data[1]['native_templow'] > data[1]['native_temperature']: 142 | (data[1]['native_templow'], data[1]['native_temperature']) = \ 143 | (data[1]['native_temperature'], data[1]['native_templow']) 144 | 145 | if len(data) > 0 and not data[0].get('is_daytime'): 146 | return data 147 | if len(data) > 1 and data[0].get('native_templow') is None and not data[1].get('is_daytime'): 148 | data[0]['native_templow'] = data[1].get('native_templow') 149 | if data[0]['native_templow'] > data[0]['native_temperature']: 150 | (data[0]['native_templow'], data[0]['native_temperature']) = \ 151 | (data[0]['native_temperature'], data[0]['native_templow']) 152 | 153 | return [f for f in data if f.get('is_daytime')] 154 | 155 | def get_forecasts_radar_service(self, include_past_forecasts: bool = False) -> List[Forecast] | None: 156 | """ 157 | Forecast service based on data from the radar. Only contains datetime and precipitation amount. 158 | The result always include the current 10 minutes interval, even if include_past_forecast is false. 159 | :param include_past_forecasts: whether to include data points that are in the past 160 | :return: ordered list of forecasts 161 | """ 162 | now = dt.now() 163 | now = now.replace(minute=(now.minute // 10) * 10, second=0, microsecond=0) 164 | 165 | # TODO adapt the return value to match the weather.get_forecasts in next breaking change release 166 | # return { 'forecast': [...] } 167 | return [f for f in self.coordinator.data.get('radar_forecast') 168 | if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now] 169 | 170 | # TODO remove on next breaking changes 171 | @property 172 | def extra_state_attributes(self) -> dict: 173 | """Here to keep the DEPRECATED forecast attribute. 174 | This attribute is deprecated by Home Assistant by still implemented for compatibility 175 | with older components. Newer components should use the service weather.get_forecasts instead. 176 | """ 177 | data: List[Forecast] = list() 178 | if self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_NOT_USED: 179 | return {} 180 | elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_HOURLY: 181 | data = self.coordinator.data.get('hourly_forecast') 182 | elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_DAILY: 183 | data = self.daily_forecast() 184 | elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_TWICE_DAILY: 185 | data = self.coordinator.data.get('daily_forecast') 186 | 187 | for forecast in data: 188 | for k in list(forecast.keys()): 189 | if k.startswith('native_'): 190 | forecast[k[7:]] = forecast[k] 191 | 192 | return {'forecast': data} 193 | -------------------------------------------------------------------------------- /tests/test_sensors.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest.mock import AsyncMock, MagicMock 3 | 4 | from freezegun import freeze_time 5 | from homeassistant.core import HomeAssistant 6 | from pytest_homeassistant_custom_component.common import MockConfigEntry 7 | 8 | from custom_components.irm_kmi import IrmKmiCoordinator 9 | from custom_components.irm_kmi.binary_sensor import IrmKmiWarning 10 | from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE 11 | from custom_components.irm_kmi.sensor import (IrmKmiNextSunMove, 12 | IrmKmiNextWarning) 13 | from tests.conftest import get_api_with_data, get_radar_animation_data 14 | 15 | 16 | @freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00')) 17 | async def test_warning_data( 18 | hass: HomeAssistant, 19 | mock_config_entry: MockConfigEntry 20 | ) -> None: 21 | api = get_api_with_data("be_forecast_warning.json") 22 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 23 | 24 | result = api.get_warnings('en') 25 | 26 | coordinator.data = {'warnings': result} 27 | warning = IrmKmiWarning(coordinator, mock_config_entry) 28 | warning.hass = hass 29 | 30 | assert warning.is_on 31 | assert len(warning.extra_state_attributes['warnings']) == 2 32 | 33 | for w in warning.extra_state_attributes['warnings']: 34 | assert w['is_active'] 35 | 36 | assert warning.extra_state_attributes['active_warnings_friendly_names'] == "Fog, Ice or snow" 37 | 38 | 39 | @freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00')) 40 | async def test_warning_data_unknown_lang( 41 | hass: HomeAssistant, 42 | mock_config_entry: MockConfigEntry 43 | ) -> None: 44 | 45 | api = get_api_with_data("be_forecast_warning.json") 46 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 47 | 48 | api.get_pollen = AsyncMock() 49 | api.get_animation_data = MagicMock(return_value=get_radar_animation_data()) 50 | coordinator._api = api 51 | 52 | 53 | result = await coordinator.process_api_data() 54 | 55 | coordinator.data = {'warnings': result['warnings']} 56 | warning = IrmKmiWarning(coordinator, mock_config_entry) 57 | warning.hass = hass 58 | 59 | assert warning.is_on 60 | assert len(warning.extra_state_attributes['warnings']) == 2 61 | 62 | for w in warning.extra_state_attributes['warnings']: 63 | assert w['is_active'] 64 | 65 | assert warning.extra_state_attributes['active_warnings_friendly_names'] == "Fog, Ice or snow" 66 | 67 | 68 | @freeze_time(datetime.fromisoformat('2024-01-11T20:00:00+01:00')) 69 | async def test_next_warning_when_data_available( 70 | hass: HomeAssistant, 71 | mock_config_entry: MockConfigEntry 72 | ) -> None: 73 | api = get_api_with_data("be_forecast_warning.json") 74 | await hass.config_entries.async_add(mock_config_entry) 75 | hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'de'}) 76 | 77 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 78 | 79 | api.get_pollen = AsyncMock() 80 | api.get_animation_data = MagicMock(return_value=get_radar_animation_data()) 81 | coordinator._api = api 82 | 83 | result = await coordinator.process_api_data() 84 | 85 | coordinator.data = {'warnings': result['warnings']} 86 | warning = IrmKmiNextWarning(coordinator, mock_config_entry) 87 | warning.hass = hass 88 | 89 | # This somehow fixes the following error that popped since 2024.12.0 90 | # ValueError: Entity cannot have a translation key for 91 | # unit of measurement before being added to the entity platform 92 | warning._attr_translation_key = None 93 | 94 | assert warning.state == "2024-01-12T06:00:00+00:00" 95 | assert len(warning.extra_state_attributes['next_warnings']) == 2 96 | 97 | assert warning.extra_state_attributes['next_warnings_friendly_names'] == "Nebel, Glätte" 98 | 99 | 100 | @freeze_time(datetime.fromisoformat('2024-01-12T07:30:00+01:00')) 101 | async def test_next_warning_none_when_only_active_warnings( 102 | hass: HomeAssistant, 103 | mock_config_entry: MockConfigEntry 104 | ) -> None: 105 | api = get_api_with_data("be_forecast_warning.json") 106 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 107 | 108 | api.get_pollen = AsyncMock() 109 | api.get_animation_data = MagicMock(return_value=get_radar_animation_data()) 110 | coordinator._api = api 111 | 112 | result = await coordinator.process_api_data() 113 | 114 | coordinator.data = {'warnings': result['warnings']} 115 | warning = IrmKmiNextWarning(coordinator, mock_config_entry) 116 | warning.hass = hass 117 | 118 | # This somehow fixes the following error that popped since 2024.12.0 119 | # ValueError: Entity cannot have a translation key for 120 | # unit of measurement before being added to the entity platform 121 | warning._attr_translation_key = None 122 | 123 | assert warning.state is None 124 | assert len(warning.extra_state_attributes['next_warnings']) == 0 125 | 126 | assert warning.extra_state_attributes['next_warnings_friendly_names'] == "" 127 | 128 | 129 | @freeze_time(datetime.fromisoformat('2024-01-12T07:30:00+01:00')) 130 | async def test_next_warning_none_when_no_warnings( 131 | hass: HomeAssistant, 132 | mock_config_entry: MockConfigEntry 133 | ) -> None: 134 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 135 | 136 | coordinator.data = {'warnings': []} 137 | warning = IrmKmiNextWarning(coordinator, mock_config_entry) 138 | warning.hass = hass 139 | 140 | # This somehow fixes the following error that popped since 2024.12.0 141 | # ValueError: Entity cannot have a translation key for 142 | # unit of measurement before being added to the entity platform 143 | warning._attr_translation_key = None 144 | 145 | assert warning.state is None 146 | assert len(warning.extra_state_attributes['next_warnings']) == 0 147 | 148 | assert warning.extra_state_attributes['next_warnings_friendly_names'] == "" 149 | 150 | coordinator.data = dict() 151 | warning = IrmKmiNextWarning(coordinator, mock_config_entry) 152 | warning.hass = hass 153 | 154 | # This somehow fixes the following error that popped since 2024.12.0 155 | # ValueError: Entity cannot have a translation key for 156 | # unit of measurement before being added to the entity platform 157 | warning._attr_translation_key = None 158 | 159 | assert warning.state is None 160 | assert len(warning.extra_state_attributes['next_warnings']) == 0 161 | 162 | assert warning.extra_state_attributes['next_warnings_friendly_names'] == "" 163 | 164 | 165 | @freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00')) 166 | async def test_next_sunrise_sunset( 167 | hass: HomeAssistant, 168 | mock_config_entry: MockConfigEntry 169 | ) -> None: 170 | api = get_api_with_data("forecast.json") 171 | 172 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 173 | api.get_pollen = AsyncMock() 174 | api.get_animation_data = MagicMock(return_value=get_radar_animation_data()) 175 | coordinator._api = api 176 | 177 | result = await coordinator.process_api_data() 178 | 179 | coordinator.data = {'daily_forecast': result['daily_forecast']} 180 | 181 | sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset') 182 | sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise') 183 | 184 | # This somehow fixes the following error that popped since 2024.12.0 185 | # ValueError: Entity cannot have a translation key for 186 | # unit of measurement before being added to the entity platform 187 | sunrise._attr_translation_key = None 188 | sunset._attr_translation_key = None 189 | 190 | assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00') 191 | assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-27T16:43:00+01:00') 192 | 193 | 194 | @freeze_time(datetime.fromisoformat('2023-12-26T15:30:00+01:00')) 195 | async def test_next_sunrise_sunset_bis( 196 | hass: HomeAssistant, 197 | mock_config_entry: MockConfigEntry 198 | ) -> None: 199 | api = get_api_with_data("forecast.json") 200 | 201 | coordinator = IrmKmiCoordinator(hass, mock_config_entry) 202 | api.get_pollen = AsyncMock() 203 | api.get_animation_data = MagicMock(return_value=get_radar_animation_data()) 204 | coordinator._api = api 205 | 206 | result = await coordinator.process_api_data() 207 | 208 | coordinator.data = {'daily_forecast': result['daily_forecast']} 209 | 210 | sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset') 211 | sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise') 212 | 213 | # This somehow fixes the following error that popped since 2024.12.0 214 | # ValueError: Entity cannot have a translation key for 215 | # unit of measurement before being added to the entity platform 216 | sunrise._attr_translation_key = None 217 | sunset._attr_translation_key = None 218 | 219 | assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00') 220 | assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-26T16:42:00+01:00') 221 | -------------------------------------------------------------------------------- /custom_components/irm_kmi/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor for pollen from the IRM KMI""" 2 | import logging 3 | from datetime import datetime 4 | 5 | from homeassistant.components import sensor 6 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | from homeassistant.util import dt 12 | from irm_kmi_api.const import POLLEN_NAMES 13 | from irm_kmi_api.data import IrmKmiForecast, IrmKmiRadarForecast 14 | from irm_kmi_api.pollen import PollenParser 15 | 16 | from . import DOMAIN, IrmKmiCoordinator 17 | from .const import (CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSOR_ICON, 18 | CURRENT_WEATHER_SENSOR_UNITS, CURRENT_WEATHER_SENSORS, 19 | POLLEN_TO_ICON_MAP) 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 25 | """Set up the sensor platform""" 26 | coordinator = hass.data[DOMAIN][entry.entry_id] 27 | async_add_entities([IrmKmiPollen(coordinator, entry, pollen.lower()) for pollen in POLLEN_NAMES]) 28 | async_add_entities([IrmKmiCurrentWeather(coordinator, entry, name) for name in CURRENT_WEATHER_SENSORS]) 29 | async_add_entities([IrmKmiNextWarning(coordinator, entry), 30 | IrmKmiCurrentRainfall(coordinator, entry)]) 31 | 32 | if coordinator.data.get('country') != 'NL': 33 | async_add_entities([IrmKmiNextSunMove(coordinator, entry, move) for move in ['sunset', 'sunrise']]) 34 | 35 | 36 | class IrmKmiPollen(CoordinatorEntity, SensorEntity): 37 | """Representation of a pollen sensor""" 38 | _attr_has_entity_name = True 39 | _attr_device_class = SensorDeviceClass.ENUM 40 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 41 | 42 | def __init__(self, 43 | coordinator: IrmKmiCoordinator, 44 | entry: ConfigEntry, 45 | pollen: str 46 | ) -> None: 47 | super().__init__(coordinator) 48 | SensorEntity.__init__(self) 49 | self._attr_unique_id = f"{entry.entry_id}-pollen-{pollen}" 50 | self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_{pollen}_level") 51 | self._attr_options = PollenParser.get_option_values() 52 | self._attr_device_info = coordinator.shared_device_info 53 | self._pollen = pollen 54 | self._attr_translation_key = f"pollen_{pollen}" 55 | self._attr_icon = POLLEN_TO_ICON_MAP[pollen] 56 | 57 | @property 58 | def native_value(self) -> str | None: 59 | """Return the state of the sensor.""" 60 | return self.coordinator.data.get('pollen', {}).get(self._pollen, None) 61 | 62 | 63 | class IrmKmiNextWarning(CoordinatorEntity, SensorEntity): 64 | """Representation of the next weather warning""" 65 | 66 | _attr_has_entity_name = True 67 | _attr_device_class = SensorDeviceClass.TIMESTAMP 68 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 69 | 70 | def __init__(self, 71 | coordinator: IrmKmiCoordinator, 72 | entry: ConfigEntry, 73 | ) -> None: 74 | super().__init__(coordinator) 75 | SensorEntity.__init__(self) 76 | self._attr_unique_id = f"{entry.entry_id}-next-warning" 77 | self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_next_warning") 78 | self._attr_device_info = coordinator.shared_device_info 79 | self._attr_translation_key = f"next_warning" 80 | 81 | @property 82 | def native_value(self) -> datetime | None: 83 | """Return the timestamp for the start of the next warning. Is None when no future warning are available""" 84 | if self.coordinator.data.get('warnings') is None: 85 | return None 86 | 87 | now = dt.now() 88 | earliest_next = None 89 | for item in self.coordinator.data.get('warnings'): 90 | if now < item.get('starts_at'): 91 | if earliest_next is None: 92 | earliest_next = item.get('starts_at') 93 | else: 94 | earliest_next = min(earliest_next, item.get('starts_at')) 95 | 96 | return earliest_next 97 | 98 | @property 99 | def extra_state_attributes(self) -> dict: 100 | """Return the attributes related to all the future warnings.""" 101 | now = dt.now() 102 | attrs = {"next_warnings": [w for w in self.coordinator.data.get('warnings', []) if now < w.get('starts_at')]} 103 | 104 | attrs["next_warnings_friendly_names"] = ", ".join( 105 | [warning['friendly_name'] for warning in attrs['next_warnings'] if warning['friendly_name'] != '']) 106 | 107 | return attrs 108 | 109 | 110 | class IrmKmiNextSunMove(CoordinatorEntity, SensorEntity): 111 | """Representation of the next sunrise or sunset""" 112 | 113 | _attr_has_entity_name = True 114 | _attr_device_class = SensorDeviceClass.TIMESTAMP 115 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 116 | 117 | def __init__(self, 118 | coordinator: IrmKmiCoordinator, 119 | entry: ConfigEntry, 120 | move: str) -> None: 121 | assert move in ['sunset', 'sunrise'] 122 | super().__init__(coordinator) 123 | SensorEntity.__init__(self) 124 | self._attr_unique_id = f"{entry.entry_id}-next-{move}" 125 | self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_next_{move}") 126 | self._attr_device_info = coordinator.shared_device_info 127 | self._attr_translation_key = f"next_{move}" 128 | self._move: str = move 129 | self._attr_icon = 'mdi:weather-sunset-down' if move == 'sunset' else 'mdi:weather-sunset-up' 130 | 131 | @property 132 | def native_value(self) -> datetime | None: 133 | """Return the timestamp for the next sunrise or sunset""" 134 | now = dt.now() 135 | data: list[IrmKmiForecast] = self.coordinator.data.get('daily_forecast') 136 | 137 | upcoming = [datetime.fromisoformat(f.get(self._move)) for f in data 138 | if f.get(self._move) is not None and datetime.fromisoformat(f.get(self._move)) >= now] 139 | 140 | if len(upcoming) > 0: 141 | return upcoming[0] 142 | return None 143 | 144 | 145 | class IrmKmiCurrentWeather(CoordinatorEntity, SensorEntity): 146 | """Representation of a current weather sensor""" 147 | 148 | _attr_has_entity_name = True 149 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 150 | 151 | def __init__(self, 152 | coordinator: IrmKmiCoordinator, 153 | entry: ConfigEntry, 154 | sensor_name: str) -> None: 155 | super().__init__(coordinator) 156 | SensorEntity.__init__(self) 157 | self._attr_unique_id = f"{entry.entry_id}-current-{sensor_name}" 158 | self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_{sensor_name}") 159 | self._attr_device_info = coordinator.shared_device_info 160 | self._attr_translation_key = f"current_{sensor_name}" 161 | self._sensor_name: str = sensor_name 162 | 163 | @property 164 | def native_value(self) -> float | None: 165 | """Return the current value of the sensor""" 166 | return self.coordinator.data.get('current_weather', {}).get(self._sensor_name, None) 167 | 168 | @property 169 | def native_unit_of_measurement(self) -> str | None: 170 | return CURRENT_WEATHER_SENSOR_UNITS[self._sensor_name] 171 | 172 | @property 173 | def device_class(self) -> SensorDeviceClass | None: 174 | return CURRENT_WEATHER_SENSOR_CLASS[self._sensor_name] 175 | 176 | @property 177 | def icon(self) -> str | None: 178 | return CURRENT_WEATHER_SENSOR_ICON[self._sensor_name] 179 | 180 | 181 | class IrmKmiCurrentRainfall(CoordinatorEntity, SensorEntity): 182 | """Representation of a current rainfall sensor""" 183 | 184 | _attr_has_entity_name = True 185 | _attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be" 186 | 187 | def __init__(self, 188 | coordinator: IrmKmiCoordinator, 189 | entry: ConfigEntry) -> None: 190 | super().__init__(coordinator) 191 | SensorEntity.__init__(self) 192 | self._attr_unique_id = f"{entry.entry_id}-current-rainfall" 193 | self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_rainfall") 194 | self._attr_device_info = coordinator.shared_device_info 195 | self._attr_translation_key = "current_rainfall" 196 | self._attr_icon = 'mdi:weather-pouring' 197 | 198 | def _current_forecast(self) -> IrmKmiRadarForecast | None: 199 | now = dt.now() 200 | forecasts = self.coordinator.data.get('radar_forecast', None) 201 | 202 | if forecasts is None: 203 | return None 204 | 205 | prev = forecasts[0] 206 | for f in forecasts: 207 | if datetime.fromisoformat(f.get('datetime')) > now: 208 | return prev 209 | prev = f 210 | 211 | return forecasts[-1] 212 | 213 | @property 214 | def native_value(self) -> float | None: 215 | """Return the current value of the sensor""" 216 | current = self._current_forecast() 217 | 218 | if current is None: 219 | return None 220 | 221 | return current.get('native_precipitation', None) 222 | 223 | @property 224 | def native_unit_of_measurement(self) -> str | None: 225 | current = self._current_forecast() 226 | 227 | if current is None: 228 | return None 229 | 230 | return current.get('unit', None) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IRM KMI Weather integration for Home Assistant 2 | 3 | Home Assistant weather provider using data from Belgian IRM KMI. 4 | The data is collected via their non-public mobile application API. 5 | 6 | Although the provider is Belgian, the data is available for Belgium 🇧🇪, Luxembourg 🇱🇺, and The Netherlands 🇳🇱 7 | 8 | > [!NOTE] 9 | > Starting with Home Assistant 2025.10, this integration is also available directly in Home Assistant Core. 10 | > Only some features are available in the Home Assistant Core version, other features will be added over time. 11 | 12 | ## Installing via HACS 13 | 14 | [![Open your Home Assistant instance and open a 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=jdejaegh&repository=irm-kmi-ha&category=integration) 15 | 16 | or 17 | 18 | 1. Go to HACS > Integrations 19 | 2. Add this repo into your [HACS custom repositories](https://hacs.xyz/docs/faq/custom_repositories/) 20 | 3. Search for IRM KMI and download it 21 | 4. Restart Home Assistant 22 | 23 | ## Set up the integration 24 | 25 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=irm_kmi) 26 | 27 | or 28 | 29 | 1. Configure the integration via the UI (search for 'IRM KMI') 30 | 31 | 32 | ## Features 33 | 34 | This integration provides the following things: 35 | 36 | - A weather entity with current weather conditions 37 | - Weather forecasts (hourly, daily and twice-daily) [using the service `weather.get_forecasts`](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts) 38 | - Short-term rain forecasts using the radar data using the [custom service `ìrm_kmi.get_forecasts_radar`](#custom-service-irm_kmiget_forecasts_radar) 39 | - A camera entity for rain radar and short-term rain previsions 40 | - A binary sensor for weather warnings 41 | - A sensor with the timestamp for the start of the next warning 42 | - Sensors for active pollens 43 | 44 | The following options are available: 45 | 46 | - Styles for the radar 47 | - Support for the old `forecast` attribute for components relying on this 48 | 49 | ## Screenshots 50 | 51 |
52 | Show screenshots 53 |
54 |
55 |
56 |
57 | 58 |
59 | 60 | ## Limitations 61 | 62 | 1. The weather provider sometime uses two weather conditions for the same day (see below). When this is the case, only the first 63 | weather condition is taken into account in this integration. 64 |
Example of two weather conditions 65 | 66 | 2. The trends for 14 days are not shown 67 | 3. The provider only has data for Belgium, Luxembourg and The Netherlands 68 | 69 | ## Mapping between IRM KMI and Home Assistant weather conditions 70 | 71 | Mapping was established based on my own interpretation of the icons and conditions. 72 | 73 | | HA Condition | HA Description | IRM KMI icon | IRM KMI data (`ww-dayNight`) | 74 | |-----------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| 75 | | clear-night | Clear night | | `0-n` `1-n` | 76 | | cloudy | Many clouds | | `14-d` `14-n` `15-d` `15-n` | 77 | | exceptional | Exceptional | | | 78 | | fog | Fog | | `24-d` `24-n` `25-d` `25-n` `26-d` `26-n` `27-d` `27-n` | 79 | | hail | Hail | | | 80 | | lightning | Lightning/ thunderstorms | | | 81 | | lightning-rainy | Lightning/ thunderstorms and rain | | `2-d` `2-n` `5-d` `5-n` `7-d` `7-n` `10-d` `10-n` `13-d` `13-n` `17-d` `17-n` | 82 | | partlycloudy | A few clouds | | `3-d` `3-n` | 83 | | pouring | Pouring rain | | `4-d` `4-n` `6-d` `6-n` `16-d` `16-n` `19-d` `19-n` | 84 | | rainy | Rain | | `18-d` `18-n` `21-d` `21-n` | 85 | | snowy | Snow | | `11-d` `11-n` `12-d` `12-n` `22-d` `22-n` `23-d` `23-n` | 86 | | snowy-rainy | Snow and Rain | | `8-d` `8-n` `9-d` `9-n` `20-d` `20-n` | 87 | | sunny | Sunshine | | `0-d` `1-d` | 88 | | windy | Wind | | | 89 | | windy-variant | Wind and clouds | | | 90 | 91 | 92 | ## Warning details 93 | 94 | Warnings are represented with two sensors: 95 | - a binary sensor showing if any warning is currently active 96 | - a timestamp sensor with the start time of the next warning (if any, else `unknown`) 97 | 98 | ### Binary sensor for ongoing warnings 99 | 100 | The warning binary sensor is on if a warning is currently relevant (i.e. warning start time < current time < warning end time). 101 | Warnings may be issued by the IRM KMI ahead of time but the binary sensor is only on when at least one of the issued warnings is relevant. 102 | 103 | The binary sensor has an additional attribute called `warnings`, with a list of warnings for the current location. 104 | Warnings in the list may be warning issued ahead of time. 105 | 106 | Each element in the list has the following attributes: 107 | * `slug: str`: warning slug type, can be used for automation and does not change with language setting. Example: `ice_or_snow` 108 | * `id: int`: internal id for the warning type used by the IRM KMI api. 109 | * `level: int`: warning severity, from 1 (lower risk) to 3 (higher risk) 110 | * `friendly_name: str`: language specific name for the warning type. Examples: `Ice or snow`, `Chute de neige ou verglas`, `Sneeuw of ijzel`, `Glätte` 111 | * `text: str`: language specific additional information about the warning 112 | * `starts_at: datetime`: time at which the warning starts being relevant 113 | * `ends_at: datetime`: time at which the warning stops being relevant 114 | * `is_active: bool`: `true` if `starts_at` < now < `ends_at` 115 | 116 | The following table summarizes the different known warning types. Other warning types may be returned and will have `unknown` as slug. Feel free to open an issue with the id and the English friendly name to have it added to this integration. 117 | 118 | | Warning slug | Warning id | Friendly name (en, fr, nl, de) | 119 | |-----------------------------|------------|------------------------------------------------------------------------------------------| 120 | | wind | 0 | Wind, Vent, Wind, Wind | 121 | | rain | 1 | Rain, Pluie, Regen, Regen | 122 | | ice_or_snow | 2 | Ice or snow, Chute de neige ou verglas, Sneeuw of ijzel, Glätte | 123 | | thunder | 3 | Thunder, Orage, Onweer, Gewitter | 124 | | fog | 7 | Fog, Brouillard, Mist, Nebel | 125 | | cold | 9 | Cold, Froid, Koude, Kalt | 126 | | heat | 10 | Heat, Chaleur, Hitte, Hitze | 127 | | thunder_wind_rain | 12 | Thunder Wind Rain, Orage, rafales et averses, Onweer Wind Regen, Gewitter Windböen Regen | 128 | | thunderstorm_strong_gusts | 13 | Thunderstorm & strong gusts, Orage et rafales, Onweer en wind, Gewitter und Windböen | 129 | | thunderstorm_large_rainfall | 14 | Thunderstorm & large rainfall, Orage et averses, Onweer en regen, Gewitter und Regen | 130 | | storm_surge | 15 | Storm surge, Marée forte, Stormtij, Sturmflut | 131 | | coldspell | 17 | Coldspell, Vague de froid, Koude, Koude | 132 | 133 | The sensor has an attribute called `active_warnings_friendly_names`, holding a comma separated list of the friendly names 134 | of the currently active warnings (e.g. `Fog, Ice or snow`). There is no particular order for the list. 135 | 136 | ### Timestamp sensor for upcoming warnings 137 | 138 | The state is the start time of the earliest next warning, if any; else `unknown`. 139 | 140 | The sensor has two additional attributes: 141 | - `next_warnings`: a list of all the upcoming warnings, with the same data as the `warnings` attribute of the binary sensor (see above) 142 | - `next_warning_friendly_names` holding a comma separated list of the friendly names of the currently active warnings (e.g. `Fog, Ice or snow`). There is no particular order for the list. 143 | 144 | 145 | ## Pollen details 146 | 147 | One sensor per pollen is created and each sensor can have one of the following values: green, yellow, orange, 148 | red, purple or none. 149 | 150 | The exact meaning of each color can be found on the IRM KMI webpage: [Pollen allergy and hay fever](https://www.meteo.be/en/weather/forecasts/pollen-allergy-and-hay-fever) 151 | 152 | Pollen data 153 | 154 | This data sent to the app would result in grasses have the 'purple' state. 155 | All the other pollens would be 'none'. 156 | 157 | Due to a recent update in the pollen SVG format, there may have some edge cases that are not handled by the integration. 158 | 159 | ## Custom service `irm_kmi.get_forecasts_radar` 160 | 161 | The service returns a list of Forecast objects (similar to `weather.get_forecasts`) but only data about precipitation is available. 162 | The data is taken from the radar forecast: it is useful for very short-term rain forecast. 163 | 164 | The service can optionally include data from the past (like shown on the radar). 165 | 166 | Here is an example of service call: 167 | 168 | ```yaml 169 | service: irm_kmi.get_forecasts_radar 170 | target: 171 | entity_id: weather.home 172 | data: 173 | include_past_forecasts: true 174 | ``` 175 | 176 | The data is optional and defaults to `false`. 177 | 178 | Even when `include_past_forecasts` is `false`, the current 10 minutes interval is returned so the first item in the 179 | response is in the past (at most 10 minutes in the past). This can be useful to determine if rain is currently falling 180 | and how strong it is. 181 | 182 | ## Disclaimer 183 | 184 | This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [The Royal Meteorological 185 | Institute of Belgium](https://www.meteo.be). 186 | 187 | All product names, trademarks and registered trademarks in (the images in) this repository, are property of their 188 | respective owners. All images in this repository are used by the project for identification purposes only. --------------------------------------------------------------------------------