├── 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 |
--------------------------------------------------------------------------------
/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 |
56 |
--------------------------------------------------------------------------------
/tests/fixtures/new_two_pollens.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 | [](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 | [](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 |
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 |
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.
--------------------------------------------------------------------------------