Compare commits
259 commits
0.1.4-beta
...
main
Author | SHA1 | Date | |
---|---|---|---|
52be58a9ef | |||
f1b18fe29a | |||
77e94d802b | |||
76a670427b | |||
866b1f3fa0 | |||
914dd75d7b | |||
5c320b57fb | |||
68bcb8aeb4 | |||
702f687a8d | |||
d5a687fff5 | |||
9e178378fc | |||
ef5d3ad126 | |||
d0d542c3fe | |||
fd8aa3029f | |||
fb43a882f8 | |||
57cce48c5f | |||
7951bafefb | |||
f0a1853f67 | |||
2707950ad9 | |||
1a33b3b594 | |||
6476f0e57a | |||
5932884c7a | |||
1e35e24c15 | |||
f729d59d9f | |||
16a5399edb | |||
36bfe49ce2 | |||
16a1991063 | |||
be30c160f4 | |||
9064326860 | |||
ea23f0da2c | |||
7f9cca4960 | |||
fee2a10f5e | |||
18040eb577 | |||
be0a7425d4 | |||
1844d02639 | |||
ca98e12e88 | |||
fb59936c79 | |||
fbab30e33f | |||
7e75e4f184 | |||
5d93102ada | |||
0776cff6d6 | |||
93bda52ac8 | |||
48fca3197f | |||
196d4cc178 | |||
225a853b27 | |||
3ef90ba688 | |||
0a64e7eec2 | |||
117c2d5030 | |||
67a8647f7b | |||
52812487f9 | |||
![]() |
3947273ef7 | ||
b78c8a6779 | |||
72e9d5dc99 | |||
3957eac952 | |||
fdec55e021 | |||
b666f7dd10 | |||
082770f480 | |||
6661bac5ad | |||
9f06486512 | |||
3ede45af43 | |||
34cb9e1bb5 | |||
0de029b30b | |||
4978a92385 | |||
1254ae7157 | |||
91d46dcb6c | |||
fe412dfec3 | |||
41fd90bf63 | |||
cf7519e7db | |||
972ab3f9e6 | |||
bd22b62eef | |||
89db47f653 | |||
e378486d74 | |||
![]() |
c862e184d4 | ||
b78d671bd2 | |||
f9af56547a | |||
1c396fa247 | |||
ec84b405de | |||
09de4fbaa7 | |||
91ff65c19d | |||
4af3d3dcbd | |||
d16a08f647 | |||
3404e1649d | |||
65e31b700d | |||
62e9e5fb9f | |||
5f53d16ce2 | |||
2a5f122dae | |||
50a7a677fb | |||
5e7b01face | |||
da512c5f37 | |||
f1e7c267e6 | |||
0059b2f78f | |||
48ad275cf9 | |||
f66b202b66 | |||
7e153e2b12 | |||
afd62cce95 | |||
eb036f9d05 | |||
671cc26031 | |||
152fa9768e | |||
e716172a93 | |||
1862609bb2 | |||
89b08dca0f | |||
98079d904d | |||
c647e83c4c | |||
a952e3566f | |||
0812ef5eef | |||
b1d21bf858 | |||
d354199ace | |||
f651968b4d | |||
a7d17d707e | |||
2562b2b733 | |||
59a3a3f07a | |||
7bf45ac713 | |||
03677d01ef | |||
a211a5e406 | |||
afea4df5d2 | |||
2e90931996 | |||
2579b79d11 | |||
1f3bc392ba | |||
6babe1e9f4 | |||
b973dd57fc | |||
2bdfa014df | |||
ff5f2f8adc | |||
9e07e18e85 | |||
4decfe72ac | |||
6bc54898ef | |||
8d2fcbefb5 | |||
e1ffb2ec8e | |||
377601cf4d | |||
92d4084cf2 | |||
7ff9705536 | |||
e55a2e5300 | |||
d75756968a | |||
d85d4c7264 | |||
35bc326267 | |||
e4a6d254af | |||
a1e0b7d394 | |||
a93e583c32 | |||
f5ebf31b0e | |||
911aa3a1c7 | |||
121b6e50c3 | |||
22b7305e14 | |||
a7201e5cb6 | |||
a0b6fdc36c | |||
e693224792 | |||
a93b199364 | |||
4e8f1faebc | |||
68491fc7da | |||
d5d4005634 | |||
ac0fe07f4f | |||
87bd3dc256 | |||
580a3a0350 | |||
![]() |
14e0512b12 | ||
3c2bd51e85 | |||
8aeb656360 | |||
191f7f54fb | |||
6060c09cc0 | |||
e4c4c8e954 | |||
f98f846d71 | |||
73236a7649 | |||
4d45275c64 | |||
0ae257aa4c | |||
002255d422 | |||
842ac0a5e4 | |||
6744629bec | |||
cb9cc62ce0 | |||
28cebe63c3 | |||
c8b4a3b109 | |||
14d03b47d4 | |||
686ee62df6 | |||
1d2681ce68 | |||
cb32f77130 | |||
39f5c75486 | |||
b5b9efc65f | |||
eec3564d17 | |||
8a93adb053 | |||
adc9e5d013 | |||
67ab04499e | |||
43a207522a | |||
c8b2954ef3 | |||
33671e7a52 | |||
9d7451af09 | |||
b2bc3a72ba | |||
18737439db | |||
efac0f4fcd | |||
3804c1ae47 | |||
23f690027d | |||
833796bf26 | |||
11c1adda5e | |||
4865b94313 | |||
16f40cf564 | |||
cda538f22f | |||
e1c2e8a659 | |||
274e53a9ca | |||
d838adce73 | |||
fbb3780a41 | |||
9435e9a057 | |||
aa5c0e5748 | |||
11d055e5f4 | |||
ce79abbefe | |||
21abf641e5 | |||
4571ef1f1a | |||
e68d4744fb | |||
34c63fe199 | |||
8ee79f43e9 | |||
5c640a284f | |||
2aa41f0155 | |||
fe13def7e8 | |||
6aef5ffa19 | |||
a401bc172a | |||
44dd78c077 | |||
1613d24f0c | |||
b6e445f9fd | |||
102a939679 | |||
c2ca26d975 | |||
dceb98ae12 | |||
1c4fa60612 | |||
bd26a99b0c | |||
5b02c8e29a | |||
4a5734daf6 | |||
f30821e0a8 | |||
a8cb3ce620 | |||
438f26f514 | |||
67ff243991 | |||
a992f40227 | |||
cf9e361bb1 | |||
9925808b44 | |||
e53f9aded4 | |||
2cca89f5ba | |||
dcb4f09793 | |||
90d4dd3a78 | |||
0eb96a63bf | |||
758eac09c2 | |||
829f6f46db | |||
8b43469c17 | |||
af27179b08 | |||
19f1f593fd | |||
c5a9c2e613 | |||
b7d39bf753 | |||
c9ec30b8b2 | |||
3915d9ff23 | |||
27fab148b6 | |||
77653976cf | |||
e31f14989a | |||
b4685a397f | |||
e74e7c2873 | |||
0e58df9434 | |||
0ca408e0e9 | |||
a2fc11ddbd | |||
2b8fa6e444 | |||
974d6baca2 | |||
f4cd89e3fe | |||
becd6418de | |||
fe0b3419ce | |||
15925baab7 | |||
d7b2e9a742 | |||
3c58da53e5 | |||
67ee56947e | |||
cd48705c1b | |||
b6e3482bf6 |
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Thanks for trying out the integration and filling a bug report !
|
||||||
|
To help with troubleshooting, please include DEBUG logs or at least a screenshot of the official IRM KMI app with the same location at the same time.
|
||||||
|
|
||||||
|
Here is how to enable the logs: go to Settings > Devices & services > IRM KMI integration > Enable debug logging.
|
||||||
|
Then, reload the integration: on the same page, under the "Service" panel, click the three dots and "Reload".
|
||||||
|
Do you thing to trigger the bug.
|
||||||
|
Get the debug logs: go to Settings > Devices & services > IRM KMI integration > Disable debug logging.
|
||||||
|
Download the file and attach it here.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**Checklist**
|
||||||
|
- [ ] I included debug logs or at least a screenshot of the IRM KMI official app
|
||||||
|
- [ ] If I use a custom card, I checked if the stock Lovelace weather card is working
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Version**
|
||||||
|
- Home Assistant: [e.g. 2024.1.3]
|
||||||
|
- IRM KMI integration [e.g. 0.2.0]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
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.
|
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Thanks for trying the integration and sharing new ideas !
|
||||||
|
Please note that not all feature can be implemented, some limitations come from the data available via the API. -->
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
9
.github/workflows/pytest.yml
vendored
|
@ -3,6 +3,7 @@ name: Run Python tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
@ -10,15 +11,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.11"]
|
python-version: ["3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: szenius/set-timezone@v1.2
|
- uses: MathRobin/timezone-action@v1.1
|
||||||
with:
|
with:
|
||||||
timezoneLinux: "Europe/Brussels"
|
timezoneLinux: "Europe/Brussels"
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
27
.github/workflows/release.yml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
name: Create release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
uses: ./.github/workflows/pytest.yml
|
||||||
|
release:
|
||||||
|
name: Release pushed tag
|
||||||
|
needs: [tests]
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" \
|
||||||
|
--repo="$GITHUB_REPOSITORY" \
|
||||||
|
--title="${tag#v}" \
|
||||||
|
--generate-notes
|
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 Jules Dejaeghere
|
Copyright (c) 2023-2025 Jules Dejaeghere
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
166
README.md
|
@ -5,44 +5,180 @@ The data is collected via their non-public mobile application API.
|
||||||
|
|
||||||
Although the provider is Belgian, the data is available for Belgium 🇧🇪, Luxembourg 🇱🇺, and The Netherlands 🇳🇱
|
Although the provider is Belgian, the data is available for Belgium 🇧🇪, Luxembourg 🇱🇺, and The Netherlands 🇳🇱
|
||||||
|
|
||||||
**Note: this is still under development, new versions may not be backward compatible.**
|
|
||||||
|
|
||||||
## Installing via HACS
|
## Installing via HACS
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=jdejaegh&repository=irm-kmi-ha&category=integration)
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
1. Go to HACS > Integrations
|
1. Go to HACS > Integrations
|
||||||
2. Add this repo into your HACS custom repositories
|
2. Add this repo into your [HACS custom repositories](https://hacs.xyz/docs/faq/custom_repositories/)
|
||||||
3. Search for IRM KMI and download it
|
3. Search for IRM KMI and download it
|
||||||
4. Restart Home Assistant
|
4. Restart Home Assistant
|
||||||
5. Configure the integration via the UI (search for 'IRM KMI')
|
|
||||||
|
## Set up the integration
|
||||||
|
|
||||||
|
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=irm_kmi)
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
1. Configure the integration via the UI (search for 'IRM KMI')
|
||||||
|
|
||||||
|
|
||||||
## Roadmap
|
## Features
|
||||||
|
|
||||||
- [X] Basic weather provider capability (current weather only)
|
This integration provides the following things:
|
||||||
- [X] Forecasts
|
|
||||||
- [X] Hourly
|
- A weather entity with current weather conditions
|
||||||
- [X] Daily
|
- Weather forecasts (hourly, daily and twice-daily) [using the service `weather.get_forecasts`](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts)
|
||||||
- [X] Camera entity for the satellite view
|
- Short-term rain forecasts using the radar data using the [custom service `ìrm_kmi.get_forecasts_radar`](#custom-service-irm_kmiget_forecasts_radar)
|
||||||
- [X] Use UI to configure the integration
|
- A camera entity for rain radar and short-term rain previsions
|
||||||
|
- A binary sensor for weather warnings
|
||||||
|
- A sensor with the timestamp for the start of the next warning
|
||||||
|
- Sensors for active pollens
|
||||||
|
|
||||||
|
The following options are available:
|
||||||
|
|
||||||
|
- Styles for the radar
|
||||||
|
- Support for the old `forecast` attribute for components relying on this
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Show screenshots</summary>
|
||||||
|
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/sensors.png"/> <br>
|
||||||
|
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/forecast.png"/> <br>
|
||||||
|
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_light.png"/> <br>
|
||||||
|
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_dark.png"/> <br>
|
||||||
|
<img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/camera_sat.png"/>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
1. The weather provider sometime uses two weather conditions for the same day (see below). When this is the case, only the first
|
||||||
|
weather condition is taken into account in this integration.
|
||||||
|
<br><img src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/monday.png" height="150" alt="Example of two weather conditions">
|
||||||
|
|
||||||
|
2. The trends for 14 days are not shown
|
||||||
|
3. The provider only has data for Belgium, Luxembourg and The Netherlands
|
||||||
|
|
||||||
## Mapping between IRM KMI and Home Assistant weather conditions
|
## Mapping between IRM KMI and Home Assistant weather conditions
|
||||||
|
|
||||||
Mapping was established based on my own interpretation of the icons and conditions.
|
Mapping was established based on my own interpretation of the icons and conditions.
|
||||||
|
|
||||||
| HA Condition | HA Description | IRM KMI icon | IRM KMI data (`ww-dayNight`) |
|
| HA Condition | HA Description | IRM KMI icon | IRM KMI data (`ww-dayNight`) |
|
||||||
|-----------------|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
|
|-----------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
|
||||||
| clear-night | Clear night | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/0-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/1-n.png" width="64"/> | `0-n` `1-n` |
|
| clear-night | Clear night | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/0-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/1-n.png" width="64"/> | `0-n` `1-n` |
|
||||||
| cloudy | Many clouds | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/15-d.png" width="64"/> | `14-d` `14-n` `15-d` `15-n` |
|
| cloudy | Many clouds | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/15-d.png" width="64"/> | `14-d` `14-n` `15-d` `15-n` |
|
||||||
| exceptional | Exceptional | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/21-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/27-d.png" width="64"/> | `21-d` `21-n` `27-d` `27-n` |
|
| exceptional | Exceptional | | |
|
||||||
| fog | Fog | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/24-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/24-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/25-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/26-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/26-n.png" width="64"/> | `24-d` `24-n` `25-d` `25-n` `26-d` `26-n` |
|
| fog | Fog | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/24-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/24-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/25-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/26-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/26-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/27-d.png" width="64"/> | `24-d` `24-n` `25-d` `25-n` `26-d` `26-n` `27-d` `27-n` |
|
||||||
| hail | Hail | | |
|
| hail | Hail | | |
|
||||||
| lightning | Lightning/ thunderstorms | | |
|
| lightning | Lightning/ thunderstorms | | |
|
||||||
| lightning-rainy | Lightning/ thunderstorms and rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/2-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/2-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/10-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/10-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/13-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/13-n.png" width="64"/> | `2-d` `2-n` `5-d` `5-n` `7-d` `7-n` `10-d` `10-n` `13-d` `13-n` `17-d` `17-n` |
|
| lightning-rainy | Lightning/ thunderstorms and rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/2-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/2-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/10-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/10-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/13-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/13-n.png" width="64"/> | `2-d` `2-n` `5-d` `5-n` `7-d` `7-n` `10-d` `10-n` `13-d` `13-n` `17-d` `17-n` |
|
||||||
| partlycloudy | A few clouds | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/3-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/3-n.png" width="64"/> | `3-d` `3-n` |
|
| partlycloudy | A few clouds | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/3-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/3-n.png" width="64"/> | `3-d` `3-n` |
|
||||||
| pouring | Pouring rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/4-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/4-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/16-d.png" width="64"/> | `4-d` `4-n` `6-d` `6-n` `16-d` `16-n` `19-d` `19-n` |
|
| pouring | Pouring rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/4-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/4-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/16-d.png" width="64"/> | `4-d` `4-n` `6-d` `6-n` `16-d` `16-n` `19-d` `19-n` |
|
||||||
| rainy | Rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/18-d.png" width="64"/> | `18-d` `18-n` |
|
| rainy | Rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/18-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/21-d.png" width="64"/> | `18-d` `18-n` `21-d` `21-n` |
|
||||||
| snowy | Snow | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/11-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/11-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/22-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/23-d.png" width="64"/> | `11-d` `11-n` `12-d` `12-n` `22-d` `22-n` `23-d` `23-n` |
|
| snowy | Snow | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/11-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/11-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/22-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/23-d.png" width="64"/> | `11-d` `11-n` `12-d` `12-n` `22-d` `22-n` `23-d` `23-n` |
|
||||||
| snowy-rainy | Snow and Rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/8-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/8-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/20-d.png" width="64"/> | `8-d` `8-n` `9-d` `9-n` `20-d` `20-n` |
|
| snowy-rainy | Snow and Rain | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/8-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/8-n.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/20-d.png" width="64"/> | `8-d` `8-n` `9-d` `9-n` `20-d` `20-n` |
|
||||||
| sunny | Sunshine | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/0-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/1-d.png" width="64"/> | `0-d` `1-d` |
|
| sunny | Sunshine | <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/0-d.png" width="64"/> <img height="64" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/1-d.png" width="64"/> | `0-d` `1-d` |
|
||||||
| windy | Wind | | |
|
| windy | Wind | | |
|
||||||
| windy-variant | Wind and clouds | | |
|
| windy-variant | Wind and clouds | | |
|
||||||
|
|
||||||
|
|
||||||
|
## Warning details
|
||||||
|
|
||||||
|
Warnings are represented with two sensors:
|
||||||
|
- a binary sensor showing if any warning is currently active
|
||||||
|
- a timestamp sensor with the start time of the next warning (if any, else `unknown`)
|
||||||
|
|
||||||
|
### Binary sensor for ongoing warnings
|
||||||
|
|
||||||
|
The warning binary sensor is on if a warning is currently relevant (i.e. warning start time < current time < warning end time).
|
||||||
|
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.
|
||||||
|
|
||||||
|
The binary sensor has an additional attribute called `warnings`, with a list of warnings for the current location.
|
||||||
|
Warnings in the list may be warning issued ahead of time.
|
||||||
|
|
||||||
|
Each element in the list has the following attributes:
|
||||||
|
* `slug: str`: warning slug type, can be used for automation and does not change with language setting. Example: `ice_or_snow`
|
||||||
|
* `id: int`: internal id for the warning type used by the IRM KMI api.
|
||||||
|
* `level: int`: warning severity, from 1 (lower risk) to 3 (higher risk)
|
||||||
|
* `friendly_name: str`: language specific name for the warning type. Examples: `Ice or snow`, `Chute de neige ou verglas`, `Sneeuw of ijzel`, `Glätte`
|
||||||
|
* `text: str`: language specific additional information about the warning
|
||||||
|
* `starts_at: datetime`: time at which the warning starts being relevant
|
||||||
|
* `ends_at: datetime`: time at which the warning stops being relevant
|
||||||
|
* `is_active: bool`: `true` if `starts_at` < now < `ends_at`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Warning slug | Warning id | Friendly name (en, fr, nl, de) |
|
||||||
|
|-----------------------------|------------|------------------------------------------------------------------------------------------|
|
||||||
|
| wind | 0 | Wind, Vent, Wind, Wind |
|
||||||
|
| rain | 1 | Rain, Pluie, Regen, Regen |
|
||||||
|
| ice_or_snow | 2 | Ice or snow, Chute de neige ou verglas, Sneeuw of ijzel, Glätte |
|
||||||
|
| thunder | 3 | Thunder, Orage, Onweer, Gewitter |
|
||||||
|
| fog | 7 | Fog, Brouillard, Mist, Nebel |
|
||||||
|
| cold | 9 | Cold, Froid, Koude, Kalt |
|
||||||
|
| heat | 10 | Heat, Chaleur, Hitte, Hitze |
|
||||||
|
| thunder_wind_rain | 12 | Thunder Wind Rain, Orage, rafales et averses, Onweer Wind Regen, Gewitter Windböen Regen |
|
||||||
|
| thunderstorm_strong_gusts | 13 | Thunderstorm & strong gusts, Orage et rafales, Onweer en wind, Gewitter und Windböen |
|
||||||
|
| thunderstorm_large_rainfall | 14 | Thunderstorm & large rainfall, Orage et averses, Onweer en regen, Gewitter und Regen |
|
||||||
|
| storm_surge | 15 | Storm surge, Marée forte, Stormtij, Sturmflut |
|
||||||
|
| coldspell | 17 | Coldspell, Vague de froid, Koude, Koude |
|
||||||
|
|
||||||
|
The sensor has an attribute called `active_warnings_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.
|
||||||
|
|
||||||
|
### Timestamp sensor for upcoming warnings
|
||||||
|
|
||||||
|
The state is the start time of the earliest next warning, if any; else `unknown`.
|
||||||
|
|
||||||
|
The sensor has two additional attributes:
|
||||||
|
- `next_warnings`: a list of all the upcoming warnings, with the same data as the `warnings` attribute of the binary sensor (see above)
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
|
||||||
|
## Pollen details
|
||||||
|
|
||||||
|
One sensor per pollen is created and each sensor can have one of the following values: green, yellow, orange,
|
||||||
|
red, purple or none.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
<img height="200" src="https://github.com/jdejaegh/irm-kmi-ha/raw/main/img/pollens.png" alt="Pollen data"/>
|
||||||
|
|
||||||
|
This data sent to the app would result in grasses have the 'purple' state.
|
||||||
|
All the other pollens would be 'none'.
|
||||||
|
|
||||||
|
Due to a recent update in the pollen SVG format, there may have some edge cases that are not handled by the integration.
|
||||||
|
|
||||||
|
## Custom service `irm_kmi.get_forecasts_radar`
|
||||||
|
|
||||||
|
The service returns a list of Forecast objects (similar to `weather.get_forecasts`) but only data about precipitation is available.
|
||||||
|
The data is taken from the radar forecast: it is useful for very short-term rain forecast.
|
||||||
|
|
||||||
|
The service can optionally include data from the past (like shown on the radar).
|
||||||
|
|
||||||
|
Here is an example of service call:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: irm_kmi.get_forecasts_radar
|
||||||
|
target:
|
||||||
|
entity_id: weather.home
|
||||||
|
data:
|
||||||
|
include_past_forecasts: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The data is optional and defaults to `false`.
|
||||||
|
|
||||||
|
Even when `include_past_forecasts` is `false`, the current 10 minutes interval is returned so the first item in the
|
||||||
|
response is in the past (at most 10 minutes in the past). This can be useful to determine if rain is currently falling
|
||||||
|
and how strong it is.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is a personal project and isn't in any way affiliated with, sponsored or endorsed by [The Royal Meteorological
|
||||||
|
Institute of Belgium](https://www.meteo.be).
|
||||||
|
|
||||||
|
All product names, trademarks and registered trademarks in (the images in) this repository, are property of their
|
||||||
|
respective owners. All images in this repository are used by the project for identification purposes only.
|
|
@ -1,24 +1,36 @@
|
||||||
"""Integration for IRM KMI weather"""
|
"""Integration for IRM KMI weather"""
|
||||||
|
|
||||||
# File inspired from https://github.com/ludeeus/integration_blueprint
|
# File inspired from https://github.com/ludeeus/integration_blueprint
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ZONE
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
|
from irm_kmi_api.const import OPTION_STYLE_STD
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED, PLATFORMS)
|
||||||
from .coordinator import IrmKmiCoordinator
|
from .coordinator import IrmKmiCoordinator
|
||||||
from .weather import IrmKmiWeather
|
from .weather import IrmKmiWeather
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up this integration using UI."""
|
"""Set up this integration using UI."""
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry)
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry.data[CONF_ZONE])
|
# When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging
|
||||||
|
logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel())
|
||||||
|
try:
|
||||||
# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities
|
# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
except ConfigEntryError:
|
||||||
|
# This happens when the zone is out of Benelux (no forecast available there)
|
||||||
|
# This should be caught by the config flow anyway
|
||||||
|
return False
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||||
|
@ -35,5 +47,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Reload config entry."""
|
"""Reload config entry."""
|
||||||
await async_unload_entry(hass, entry)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
await async_setup_entry(hass, entry)
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass, config_entry: ConfigEntry):
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(f"Migrating from version {config_entry.version}")
|
||||||
|
|
||||||
|
if config_entry.version > CONFIG_FLOW_VERSION - 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
_LOGGER.error(f"Downgrading configuration is not supported: your config version is {config_entry.version}, "
|
||||||
|
f"the current version used by the integration is {CONFIG_FLOW_VERSION}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
new = {**config_entry.data}
|
||||||
|
if config_entry.version == 1:
|
||||||
|
new = new | {CONF_STYLE: OPTION_STYLE_STD, CONF_DARK_MODE: True}
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new, version=2)
|
||||||
|
|
||||||
|
if config_entry.version == 2:
|
||||||
|
new = new | {CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED}
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new, version=3)
|
||||||
|
|
||||||
|
if config_entry.version == 3:
|
||||||
|
new = new | {CONF_LANGUAGE_OVERRIDE: None}
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new, version=4)
|
||||||
|
|
||||||
|
if config_entry.version == 4:
|
||||||
|
new[CONF_LANGUAGE_OVERRIDE] = 'none' if new[CONF_LANGUAGE_OVERRIDE] is None else new[CONF_LANGUAGE_OVERRIDE]
|
||||||
|
hass.config_entries.async_update_entry(config_entry, data=new, version=5)
|
||||||
|
|
||||||
|
_LOGGER.debug(f"Migration to version {config_entry.version} successful")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
@ -1,86 +0,0 @@
|
||||||
"""API Client for IRM KMI weather"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import async_timeout
|
|
||||||
from aiohttp import ClientResponse
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiError(Exception):
|
|
||||||
"""Exception to indicate a general API error."""
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiCommunicationError(IrmKmiApiError):
|
|
||||||
"""Exception to indicate a communication error."""
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiParametersError(IrmKmiApiError):
|
|
||||||
"""Exception to indicate a parameter error."""
|
|
||||||
|
|
||||||
|
|
||||||
def _api_key(method_name: str) -> str:
|
|
||||||
"""Get API key."""
|
|
||||||
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiApiClient:
|
|
||||||
"""API client for IRM KMI weather data"""
|
|
||||||
COORD_DECIMALS = 6
|
|
||||||
|
|
||||||
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
||||||
self._session = session
|
|
||||||
self._base_url = "https://app.meteo.be/services/appv4/"
|
|
||||||
|
|
||||||
async def get_forecasts_coord(self, coord: dict) -> dict:
|
|
||||||
"""Get forecasts for given city."""
|
|
||||||
assert 'lat' in coord
|
|
||||||
assert 'long' in coord
|
|
||||||
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
|
|
||||||
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
|
|
||||||
|
|
||||||
response = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
async def get_image(self, url, params: dict | None = None) -> bytes:
|
|
||||||
"""Get the image at the specified url with the parameters"""
|
|
||||||
# TODO support etag and head request before requesting content
|
|
||||||
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
|
|
||||||
return await r.read()
|
|
||||||
|
|
||||||
async def _api_wrapper(
|
|
||||||
self,
|
|
||||||
params: dict,
|
|
||||||
base_url: str | None = None,
|
|
||||||
path: str = "",
|
|
||||||
method: str = "get",
|
|
||||||
data: dict | None = None,
|
|
||||||
headers: dict | None = None,
|
|
||||||
) -> any:
|
|
||||||
"""Get information from the API."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with async_timeout.timeout(10):
|
|
||||||
response = await self._session.request(
|
|
||||||
method=method,
|
|
||||||
url=f"{self._base_url if base_url is None else base_url}{path}",
|
|
||||||
headers=headers,
|
|
||||||
json=data,
|
|
||||||
params=params
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as exception:
|
|
||||||
raise IrmKmiApiCommunicationError("Timeout error fetching information") from exception
|
|
||||||
except (aiohttp.ClientError, socket.gaierror) as exception:
|
|
||||||
raise IrmKmiApiCommunicationError("Error fetching information") from exception
|
|
||||||
except Exception as exception: # pylint: disable=broad-except
|
|
||||||
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception
|
|
65
custom_components/irm_kmi/binary_sensor.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
"""Sensor to signal weather warning from the IRM KMI"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components import binary_sensor
|
||||||
|
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import dt
|
||||||
|
|
||||||
|
from . import DOMAIN, IrmKmiCoordinator
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||||
|
"""Set up the binary platform"""
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
async_add_entities([IrmKmiWarning(coordinator, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
|
||||||
|
"""Representation of a weather warning binary sensor"""
|
||||||
|
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
BinarySensorEntity.__init__(self)
|
||||||
|
self._attr_device_class = BinarySensorDeviceClass.SAFETY
|
||||||
|
self._attr_unique_id = entry.entry_id
|
||||||
|
self.entity_id = binary_sensor.ENTITY_ID_FORMAT.format(f"weather_warning_{str(entry.title).lower()}")
|
||||||
|
self._attr_name = f"Warning {entry.title}"
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
if self.coordinator.data.get('warnings') is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = dt.now()
|
||||||
|
for item in self.coordinator.data.get('warnings'):
|
||||||
|
if item.get('starts_at') < now < item.get('ends_at'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""Return the warning sensor attributes."""
|
||||||
|
attrs = {"warnings": self.coordinator.data.get('warnings', [])}
|
||||||
|
|
||||||
|
now = dt.now()
|
||||||
|
for warning in attrs['warnings']:
|
||||||
|
warning['is_active'] = warning.get('starts_at') < now < warning.get('ends_at')
|
||||||
|
|
||||||
|
attrs["active_warnings_friendly_names"] = ", ".join([warning['friendly_name'] for warning in attrs['warnings']
|
||||||
|
if warning['is_active'] and warning['friendly_name'] != ''])
|
||||||
|
|
||||||
|
return attrs
|
|
@ -6,7 +6,6 @@ from aiohttp import web
|
||||||
from homeassistant.components.camera import Camera, async_get_still_stream
|
from homeassistant.components.camera import Camera, async_get_still_stream
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
@ -26,6 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||||
class IrmKmiRadar(CoordinatorEntity, Camera):
|
class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||||
"""Representation of a radar view camera."""
|
"""Representation of a radar view camera."""
|
||||||
|
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
coordinator: IrmKmiCoordinator,
|
coordinator: IrmKmiCoordinator,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
|
@ -33,27 +34,17 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||||
"""Initialize IrmKmiRadar component."""
|
"""Initialize IrmKmiRadar component."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
Camera.__init__(self)
|
Camera.__init__(self)
|
||||||
|
self.content_type = 'image/svg+xml'
|
||||||
self._name = f"Radar {entry.title}"
|
self._name = f"Radar {entry.title}"
|
||||||
self._attr_unique_id = entry.entry_id
|
self._attr_unique_id = entry.entry_id
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
identifiers={(DOMAIN, entry.entry_id)},
|
|
||||||
manufacturer="IRM KMI",
|
|
||||||
name=f"Radar {entry.title}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._image_index = 0
|
self._image_index = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frame_interval(self) -> float:
|
def frame_interval(self) -> float:
|
||||||
"""Return the interval between frames of the mjpeg stream."""
|
"""Return the interval between frames of the mjpeg stream."""
|
||||||
return 0.3
|
return 1
|
||||||
|
|
||||||
def camera_image(self,
|
|
||||||
width: int | None = None,
|
|
||||||
height: int | None = None) -> bytes | None:
|
|
||||||
"""Return still image to be used as thumbnail."""
|
|
||||||
return self.coordinator.data.get('animation', {}).get('most_recent_image')
|
|
||||||
|
|
||||||
async def async_camera_image(
|
async def async_camera_image(
|
||||||
self,
|
self,
|
||||||
|
@ -61,24 +52,26 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||||
height: int | None = None
|
height: int | None = None
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Return still image to be used as thumbnail."""
|
"""Return still image to be used as thumbnail."""
|
||||||
return self.camera_image()
|
if self.coordinator.data.get('animation', None) is not None:
|
||||||
|
return await self.coordinator.data.get('animation').get_still()
|
||||||
|
return None
|
||||||
|
|
||||||
async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse:
|
async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse:
|
||||||
"""Generate an HTTP MJPEG stream from camera images."""
|
"""Generate an HTTP MJPEG stream from camera images."""
|
||||||
self._image_index = 0
|
self._image_index = False
|
||||||
return await async_get_still_stream(request, self.iterate, self.content_type, interval)
|
return await async_get_still_stream(request, self.get_animated_svg, self.content_type, interval)
|
||||||
|
|
||||||
async def handle_async_mjpeg_stream(self, request: web.Request) -> web.StreamResponse:
|
async def handle_async_mjpeg_stream(self, request: web.Request) -> web.StreamResponse:
|
||||||
"""Serve an HTTP MJPEG stream from the camera."""
|
"""Serve an HTTP MJPEG stream from the camera."""
|
||||||
return await self.handle_async_still_stream(request, self.frame_interval)
|
return await self.handle_async_still_stream(request, self.frame_interval)
|
||||||
|
|
||||||
async def iterate(self) -> bytes | None:
|
async def get_animated_svg(self) -> bytes | None:
|
||||||
"""Loop over all the frames when called multiple times."""
|
"""Returns the animated svg for camera display"""
|
||||||
sequence = self.coordinator.data.get('animation', {}).get('sequence')
|
# If this is not done this way, the live view can only be opened once
|
||||||
if isinstance(sequence, list) and len(sequence) > 0:
|
self._image_index = not self._image_index
|
||||||
r = sequence[self._image_index].get('image', None)
|
if self._image_index and self.coordinator.data.get('animation', None) is not None:
|
||||||
self._image_index = (self._image_index + 1) % len(sequence)
|
return await self.coordinator.data.get('animation').get_animated()
|
||||||
return r
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -89,6 +82,7 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
"""Return the camera state attributes."""
|
"""Return the camera state attributes."""
|
||||||
attrs = {"hint": self.coordinator.data.get('animation', {}).get('hint')}
|
rain_graph = self.coordinator.data.get('animation', None)
|
||||||
|
hint = rain_graph.get_hint() if rain_graph is not None else None
|
||||||
|
attrs = {"hint": hint}
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
|
@ -1,38 +1,145 @@
|
||||||
"""Config flow to set up IRM KMI integration via the UI."""
|
"""Config flow to set up IRM KMI integration via the UI."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
|
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
|
||||||
from homeassistant.config_entries import ConfigFlow
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||||
from homeassistant.const import CONF_ZONE
|
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
|
||||||
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.selector import (EntitySelector,
|
||||||
|
EntitySelectorConfig,
|
||||||
|
SelectSelector,
|
||||||
|
SelectSelectorConfig,
|
||||||
|
SelectSelectorMode)
|
||||||
|
from irm_kmi_api.api import IrmKmiApiClient
|
||||||
|
|
||||||
from .const import DOMAIN
|
from . import OPTION_STYLE_STD
|
||||||
|
from .const import (CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE,
|
||||||
|
CONF_LANGUAGE_OVERRIDE_OPTIONS, CONF_STYLE,
|
||||||
|
CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION,
|
||||||
|
DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
OUT_OF_BENELUX, USER_AGENT)
|
||||||
|
from .utils import get_config_value
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
|
class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
VERSION = 1
|
VERSION = CONFIG_FLOW_VERSION
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||||
|
"""Create the options flow."""
|
||||||
|
return IrmKmiOptionFlow(config_entry)
|
||||||
|
|
||||||
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
|
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
|
||||||
"""Define the user step of the configuration flow."""
|
"""Define the user step of the configuration flow."""
|
||||||
if user_input is not None:
|
errors = {}
|
||||||
|
|
||||||
|
if user_input:
|
||||||
_LOGGER.debug(f"Provided config user is: {user_input}")
|
_LOGGER.debug(f"Provided config user is: {user_input}")
|
||||||
|
|
||||||
|
if (zone := self.hass.states.get(user_input[CONF_ZONE])) is None:
|
||||||
|
errors[CONF_ZONE] = 'zone_not_exist'
|
||||||
|
|
||||||
|
# Check if zone is in Benelux
|
||||||
|
if not errors:
|
||||||
|
api_data = {}
|
||||||
|
try:
|
||||||
|
async with (async_timeout.timeout(60)):
|
||||||
|
api_data = await IrmKmiApiClient(
|
||||||
|
session=async_get_clientsession(self.hass),
|
||||||
|
user_agent=USER_AGENT
|
||||||
|
).get_forecasts_coord(
|
||||||
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
errors['base'] = "api_error"
|
||||||
|
|
||||||
|
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
||||||
|
errors[CONF_ZONE] = 'out_of_benelux'
|
||||||
|
|
||||||
|
if not errors:
|
||||||
await self.async_set_unique_id(user_input[CONF_ZONE])
|
await self.async_set_unique_id(user_input[CONF_ZONE])
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
state = self.hass.states.get(user_input[CONF_ZONE])
|
state = self.hass.states.get(user_input[CONF_ZONE])
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=state.name if state else "IRM KMI",
|
title=state.name if state else "IRM KMI",
|
||||||
data={CONF_ZONE: user_input[CONF_ZONE]},
|
data={CONF_ZONE: user_input[CONF_ZONE],
|
||||||
|
CONF_STYLE: user_input[CONF_STYLE],
|
||||||
|
CONF_DARK_MODE: user_input[CONF_DARK_MODE],
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST],
|
||||||
|
CONF_LANGUAGE_OVERRIDE: user_input[CONF_LANGUAGE_OVERRIDE]},
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={'zone': user_input.get('zone') if user_input is not None else None},
|
||||||
data_schema=vol.Schema({
|
data_schema=vol.Schema({
|
||||||
vol.Required(CONF_ZONE): EntitySelector(EntitySelectorConfig(domain=ZONE_DOMAIN)),
|
vol.Required(CONF_ZONE):
|
||||||
})
|
EntitySelector(EntitySelectorConfig(domain=ZONE_DOMAIN)),
|
||||||
|
|
||||||
|
vol.Optional(CONF_STYLE, default=OPTION_STYLE_STD):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_STYLE_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_STYLE)),
|
||||||
|
|
||||||
|
vol.Optional(CONF_DARK_MODE, default=False): bool,
|
||||||
|
|
||||||
|
vol.Optional(CONF_USE_DEPRECATED_FORECAST, default=OPTION_DEPRECATED_FORECAST_NOT_USED):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_USE_DEPRECATED_FORECAST_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_USE_DEPRECATED_FORECAST)),
|
||||||
|
|
||||||
|
vol.Optional(CONF_LANGUAGE_OVERRIDE, default='none'):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_LANGUAGE_OVERRIDE))
|
||||||
|
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiOptionFlow(OptionsFlow):
|
||||||
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.current_config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
|
||||||
|
"""Manage the options."""
|
||||||
|
if user_input is not None:
|
||||||
|
_LOGGER.debug(user_input)
|
||||||
|
return self.async_create_entry(data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_STYLE, default=get_config_value(self.current_config_entry, CONF_STYLE)):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_STYLE_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_STYLE)),
|
||||||
|
|
||||||
|
vol.Optional(CONF_DARK_MODE, default=get_config_value(self.current_config_entry, CONF_DARK_MODE)): bool,
|
||||||
|
|
||||||
|
vol.Optional(CONF_USE_DEPRECATED_FORECAST,
|
||||||
|
default=get_config_value(self.current_config_entry, CONF_USE_DEPRECATED_FORECAST)):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_USE_DEPRECATED_FORECAST_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_USE_DEPRECATED_FORECAST)),
|
||||||
|
|
||||||
|
vol.Optional(CONF_LANGUAGE_OVERRIDE,
|
||||||
|
default=get_config_value(self.current_config_entry, CONF_LANGUAGE_OVERRIDE)):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
|
||||||
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
|
translation_key=CONF_LANGUAGE_OVERRIDE))
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
"""Constants for the IRM KMI integration."""
|
"""Constants for the IRM KMI integration."""
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass
|
||||||
from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
ATTR_CONDITION_CLOUDY,
|
ATTR_CONDITION_CLOUDY,
|
||||||
ATTR_CONDITION_EXCEPTIONAL,
|
|
||||||
ATTR_CONDITION_FOG,
|
ATTR_CONDITION_FOG,
|
||||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||||
ATTR_CONDITION_PARTLYCLOUDY,
|
ATTR_CONDITION_PARTLYCLOUDY,
|
||||||
|
@ -11,17 +12,58 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
ATTR_CONDITION_SNOWY,
|
ATTR_CONDITION_SNOWY,
|
||||||
ATTR_CONDITION_SNOWY_RAINY,
|
ATTR_CONDITION_SNOWY_RAINY,
|
||||||
ATTR_CONDITION_SUNNY)
|
ATTR_CONDITION_SUNNY)
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import (DEGREE, Platform, UnitOfPressure, UnitOfSpeed,
|
||||||
|
UnitOfTemperature)
|
||||||
|
from irm_kmi_api.const import (OPTION_STYLE_CONTRAST, OPTION_STYLE_SATELLITE,
|
||||||
|
OPTION_STYLE_STD, OPTION_STYLE_YELLOW_RED)
|
||||||
|
|
||||||
DOMAIN = 'irm_kmi'
|
DOMAIN: Final = 'irm_kmi'
|
||||||
PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.CAMERA]
|
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||||
OUT_OF_BENELUX = ["außerhalb der Benelux (Brussels)",
|
CONFIG_FLOW_VERSION = 5
|
||||||
|
|
||||||
|
OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
|
||||||
"Hors de Belgique (Bxl)",
|
"Hors de Belgique (Bxl)",
|
||||||
"Outside the Benelux (Brussels)",
|
"Outside the Benelux (Brussels)",
|
||||||
"Buiten de Benelux (Brussel)"]
|
"Buiten de Benelux (Brussel)"]
|
||||||
|
LANGS: Final = ['en', 'fr', 'nl', 'de']
|
||||||
|
|
||||||
|
CONF_STYLE: Final = "style"
|
||||||
|
|
||||||
|
CONF_STYLE_OPTIONS: Final = [
|
||||||
|
OPTION_STYLE_STD,
|
||||||
|
OPTION_STYLE_CONTRAST,
|
||||||
|
OPTION_STYLE_YELLOW_RED,
|
||||||
|
OPTION_STYLE_SATELLITE
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF_DARK_MODE: Final = "dark_mode"
|
||||||
|
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: Final = 'use_deprecated_forecast_attribute'
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED: Final = 'do_not_use_deprecated_forecast'
|
||||||
|
OPTION_DEPRECATED_FORECAST_DAILY: Final = 'daily_in_deprecated_forecast'
|
||||||
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY: Final = 'twice_daily_in_deprecated_forecast'
|
||||||
|
OPTION_DEPRECATED_FORECAST_HOURLY: Final = 'hourly_in_deprecated_forecast'
|
||||||
|
|
||||||
|
CONF_USE_DEPRECATED_FORECAST_OPTIONS: Final = [
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
OPTION_DEPRECATED_FORECAST_DAILY,
|
||||||
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY,
|
||||||
|
OPTION_DEPRECATED_FORECAST_HOURLY
|
||||||
|
]
|
||||||
|
|
||||||
|
CONF_LANGUAGE_OVERRIDE: Final = 'language_override'
|
||||||
|
|
||||||
|
CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = [
|
||||||
|
'none', "fr", "nl", "de", "en"
|
||||||
|
]
|
||||||
|
|
||||||
|
REPAIR_SOLUTION: Final = "repair_solution"
|
||||||
|
REPAIR_OPT_MOVE: Final = "repair_option_move"
|
||||||
|
REPAIR_OPT_DELETE: Final = "repair_option_delete"
|
||||||
|
REPAIR_OPTIONS: Final = [REPAIR_OPT_MOVE, REPAIR_OPT_DELETE]
|
||||||
|
|
||||||
# map ('ww', 'dayNight') tuple from IRM KMI to HA conditions
|
# map ('ww', 'dayNight') tuple from IRM KMI to HA conditions
|
||||||
IRM_KMI_TO_HA_CONDITION_MAP = {
|
IRM_KMI_TO_HA_CONDITION_MAP: Final = {
|
||||||
(0, 'd'): ATTR_CONDITION_SUNNY,
|
(0, 'd'): ATTR_CONDITION_SUNNY,
|
||||||
(0, 'n'): ATTR_CONDITION_CLEAR_NIGHT,
|
(0, 'n'): ATTR_CONDITION_CLEAR_NIGHT,
|
||||||
(1, 'd'): ATTR_CONDITION_SUNNY,
|
(1, 'd'): ATTR_CONDITION_SUNNY,
|
||||||
|
@ -64,8 +106,8 @@ IRM_KMI_TO_HA_CONDITION_MAP = {
|
||||||
(19, 'n'): ATTR_CONDITION_POURING,
|
(19, 'n'): ATTR_CONDITION_POURING,
|
||||||
(20, 'd'): ATTR_CONDITION_SNOWY_RAINY,
|
(20, 'd'): ATTR_CONDITION_SNOWY_RAINY,
|
||||||
(20, 'n'): ATTR_CONDITION_SNOWY_RAINY,
|
(20, 'n'): ATTR_CONDITION_SNOWY_RAINY,
|
||||||
(21, 'd'): ATTR_CONDITION_EXCEPTIONAL,
|
(21, 'd'): ATTR_CONDITION_RAINY,
|
||||||
(21, 'n'): ATTR_CONDITION_EXCEPTIONAL,
|
(21, 'n'): ATTR_CONDITION_RAINY,
|
||||||
(22, 'd'): ATTR_CONDITION_SNOWY,
|
(22, 'd'): ATTR_CONDITION_SNOWY,
|
||||||
(22, 'n'): ATTR_CONDITION_SNOWY,
|
(22, 'n'): ATTR_CONDITION_SNOWY,
|
||||||
(23, 'd'): ATTR_CONDITION_SNOWY,
|
(23, 'd'): ATTR_CONDITION_SNOWY,
|
||||||
|
@ -76,6 +118,46 @@ IRM_KMI_TO_HA_CONDITION_MAP = {
|
||||||
(25, 'n'): ATTR_CONDITION_FOG,
|
(25, 'n'): ATTR_CONDITION_FOG,
|
||||||
(26, 'd'): ATTR_CONDITION_FOG,
|
(26, 'd'): ATTR_CONDITION_FOG,
|
||||||
(26, 'n'): ATTR_CONDITION_FOG,
|
(26, 'n'): ATTR_CONDITION_FOG,
|
||||||
(27, 'd'): ATTR_CONDITION_EXCEPTIONAL,
|
(27, 'd'): ATTR_CONDITION_FOG,
|
||||||
(27, 'n'): ATTR_CONDITION_EXCEPTIONAL
|
(27, 'n'): ATTR_CONDITION_FOG
|
||||||
}
|
}
|
||||||
|
|
||||||
|
POLLEN_TO_ICON_MAP: Final = {
|
||||||
|
'alder': 'mdi:tree', 'ash': 'mdi:tree', 'birch': 'mdi:tree', 'grasses': 'mdi:grass', 'hazel': 'mdi:tree',
|
||||||
|
'mugwort': 'mdi:sprout', 'oak': 'mdi:tree'
|
||||||
|
}
|
||||||
|
|
||||||
|
IRM_KMI_NAME: Final = {
|
||||||
|
'fr': 'Institut Royal Météorologique de Belgique',
|
||||||
|
'nl': 'Koninklijk Meteorologisch Instituut van België',
|
||||||
|
'de': 'Königliche Meteorologische Institut von Belgien',
|
||||||
|
'en': 'Royal Meteorological Institute of Belgium'
|
||||||
|
}
|
||||||
|
|
||||||
|
USER_AGENT: Final = 'github.com/jdejaegh/irm-kmi-ha 0.3.2'
|
||||||
|
|
||||||
|
CURRENT_WEATHER_SENSORS: Final = {'temperature', 'wind_speed', 'wind_gust_speed', 'wind_bearing', 'uv_index',
|
||||||
|
'pressure'}
|
||||||
|
|
||||||
|
CURRENT_WEATHER_SENSOR_UNITS: Final = {'temperature': UnitOfTemperature.CELSIUS,
|
||||||
|
'wind_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||||
|
'wind_gust_speed': UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||||
|
'wind_bearing': DEGREE,
|
||||||
|
# Need to put '', else the history shows a bar graph instead of a chart
|
||||||
|
'uv_index': '',
|
||||||
|
'pressure': UnitOfPressure.HPA}
|
||||||
|
|
||||||
|
CURRENT_WEATHER_SENSOR_CLASS: Final = {'temperature': SensorDeviceClass.TEMPERATURE,
|
||||||
|
'wind_speed': SensorDeviceClass.WIND_SPEED,
|
||||||
|
'wind_gust_speed': SensorDeviceClass.WIND_SPEED,
|
||||||
|
'wind_bearing': None,
|
||||||
|
'uv_index': None,
|
||||||
|
'pressure': SensorDeviceClass.ATMOSPHERIC_PRESSURE}
|
||||||
|
|
||||||
|
# Leave None when we want the default icon to be shown
|
||||||
|
CURRENT_WEATHER_SENSOR_ICON: Final = {'temperature': None,
|
||||||
|
'wind_speed': None,
|
||||||
|
'wind_gust_speed': None,
|
||||||
|
'wind_bearing': 'mdi:compass',
|
||||||
|
'uv_index': 'mdi:sun-wireless',
|
||||||
|
'pressure': None}
|
||||||
|
|
|
@ -1,45 +1,55 @@
|
||||||
"""DataUpdateCoordinator for the IRM KMI integration."""
|
"""DataUpdateCoordinator for the IRM KMI integration."""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from io import BytesIO
|
|
||||||
from typing import Any, List, Tuple
|
|
||||||
|
|
||||||
import async_timeout
|
import async_timeout
|
||||||
import pytz
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.components.weather import Forecast
|
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
|
||||||
from homeassistant.components.zone import Zone
|
|
||||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator,
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
UpdateFailed)
|
from homeassistant.helpers.update_coordinator import (
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
TimestampDataUpdateCoordinator, UpdateFailed)
|
||||||
|
from homeassistant.util import dt
|
||||||
|
from homeassistant.util.dt import utcnow
|
||||||
|
from irm_kmi_api.api import IrmKmiApiClientHa, IrmKmiApiError
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
from irm_kmi_api.rain_graph import RainGraph
|
||||||
|
|
||||||
from .api import IrmKmiApiClient, IrmKmiApiError
|
from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN, IRM_KMI_NAME
|
||||||
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
|
from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP
|
||||||
from .const import OUT_OF_BENELUX
|
from .const import OUT_OF_BENELUX, USER_AGENT
|
||||||
from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast,
|
from .data import ProcessedCoordinatorData
|
||||||
ProcessedCoordinatorData, RadarAnimationData)
|
from .utils import disable_from_config, get_config_value, preferred_language
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiCoordinator(DataUpdateCoordinator):
|
class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
|
||||||
"""Coordinator to update data from IRM KMI"""
|
"""Coordinator to update data from IRM KMI"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, zone: Zone):
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
_LOGGER,
|
_LOGGER,
|
||||||
|
config_entry=entry,
|
||||||
# Name of the data. For logging purposes.
|
# Name of the data. For logging purposes.
|
||||||
name="IRM KMI weather",
|
name="IRM KMI weather",
|
||||||
# Polling interval. Will only be polled if there are subscribers.
|
# Polling interval. Will only be polled if there are subscribers.
|
||||||
update_interval=timedelta(minutes=7),
|
update_interval=timedelta(minutes=7),
|
||||||
)
|
)
|
||||||
self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass))
|
self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP)
|
||||||
self._zone = zone
|
self._zone = get_config_value(entry, CONF_ZONE)
|
||||||
|
self._dark_mode = get_config_value(entry, CONF_DARK_MODE)
|
||||||
|
self._style = get_config_value(entry, CONF_STYLE)
|
||||||
|
self.shared_device_info = DeviceInfo(
|
||||||
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
|
identifiers={(DOMAIN, entry.entry_id)},
|
||||||
|
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, self.config_entry)),
|
||||||
|
name=f"{entry.title}"
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> ProcessedCoordinatorData:
|
async def _async_update_data(self) -> ProcessedCoordinatorData:
|
||||||
"""Fetch data from API endpoint.
|
"""Fetch data from API endpoint.
|
||||||
|
@ -47,284 +57,93 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
|
||||||
This is the place to pre-process the data to lookup tables
|
This is the place to pre-process the data to lookup tables
|
||||||
so entities can quickly look up their data.
|
so entities can quickly look up their data.
|
||||||
"""
|
"""
|
||||||
|
# When integration is set up, set the logging level of the irm_kmi_api package to the same level to help debugging
|
||||||
|
logging.getLogger('irm_kmi_api').setLevel(_LOGGER.getEffectiveLevel())
|
||||||
|
|
||||||
|
self._api.expire_cache()
|
||||||
if (zone := self.hass.states.get(self._zone)) is None:
|
if (zone := self.hass.states.get(self._zone)) is None:
|
||||||
raise UpdateFailed(f"Zone '{self._zone}' not found")
|
raise UpdateFailed(f"Zone '{self._zone}' not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||||
# handled by the data update coordinator.
|
# handled by the data update coordinator.
|
||||||
async with async_timeout.timeout(10):
|
async with async_timeout.timeout(60):
|
||||||
api_data = await self._api_client.get_forecasts_coord(
|
await self._api.refresh_forecasts_coord(
|
||||||
{'lat': zone.attributes[ATTR_LATITUDE],
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
'long': zone.attributes[ATTR_LONGITUDE]}
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
)
|
)
|
||||||
_LOGGER.debug(f"Observation for {api_data.get('cityName', '')}: {api_data.get('obs', '{}')}")
|
|
||||||
|
|
||||||
except IrmKmiApiError as err:
|
except IrmKmiApiError as err:
|
||||||
raise UpdateFailed(f"Error communicating with API: {err}")
|
if self.last_update_success_time is not None \
|
||||||
|
and self.last_update_success_time - utcnow() < 2.5 * self.update_interval:
|
||||||
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
_LOGGER.warning(f"Error communicating with API for general forecast: {err}. Keeping the old data.")
|
||||||
raise UpdateFailed(f"Zone '{self._zone}' is out of Benelux and forecast is only available in the Benelux")
|
return self.data
|
||||||
|
|
||||||
return await self.process_api_data(api_data)
|
|
||||||
|
|
||||||
async def _async_animation_data(self, api_data: dict) -> RadarAnimationData:
|
|
||||||
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
|
|
||||||
Frames from the API are merged with the background map and the location marker to create each frame."""
|
|
||||||
animation_data = api_data.get('animation', {}).get('sequence')
|
|
||||||
localisation_layer_url = api_data.get('animation', {}).get('localisationLayer')
|
|
||||||
country = api_data.get('country', '')
|
|
||||||
|
|
||||||
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
|
|
||||||
return RadarAnimationData()
|
|
||||||
|
|
||||||
try:
|
|
||||||
images_from_api = await self.download_images_from_api(animation_data, country, localisation_layer_url)
|
|
||||||
except IrmKmiApiError:
|
|
||||||
_LOGGER.warning(f"Could not get images for weather radar")
|
|
||||||
return RadarAnimationData()
|
|
||||||
|
|
||||||
localisation = Image.open(BytesIO(images_from_api[0])).convert('RGBA')
|
|
||||||
images_from_api = images_from_api[1:]
|
|
||||||
|
|
||||||
radar_animation = await self.merge_frames_from_api(animation_data, country, images_from_api, localisation)
|
|
||||||
# TODO support translation here
|
|
||||||
radar_animation['hint'] = api_data.get('animation', {}).get('sequenceHint', {}).get('en')
|
|
||||||
return radar_animation
|
|
||||||
|
|
||||||
async def process_api_data(self, api_data: dict) -> ProcessedCoordinatorData:
|
|
||||||
"""From the API data, create the object that will be used in the entities"""
|
|
||||||
return ProcessedCoordinatorData(
|
|
||||||
current_weather=IrmKmiCoordinator.current_weather_from_data(api_data),
|
|
||||||
daily_forecast=IrmKmiCoordinator.daily_list_to_forecast(api_data.get('for', {}).get('daily')),
|
|
||||||
hourly_forecast=IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
|
|
||||||
animation=await self._async_animation_data(api_data=api_data)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def download_images_from_api(self,
|
|
||||||
animation_data: dict,
|
|
||||||
country: str,
|
|
||||||
localisation_layer_url: str) -> tuple[Any]:
|
|
||||||
"""Download a batch of images to create the radar frames."""
|
|
||||||
coroutines = list()
|
|
||||||
coroutines.append(
|
|
||||||
self._api_client.get_image(localisation_layer_url,
|
|
||||||
params={'th': 'd' if country == 'NL' else 'n'}))
|
|
||||||
|
|
||||||
for frame in animation_data:
|
|
||||||
if frame.get('uri', None) is not None:
|
|
||||||
coroutines.append(self._api_client.get_image(frame.get('uri')))
|
|
||||||
async with async_timeout.timeout(20):
|
|
||||||
images_from_api = await asyncio.gather(*coroutines)
|
|
||||||
|
|
||||||
_LOGGER.debug(f"Just downloaded {len(images_from_api)} images")
|
|
||||||
return images_from_api
|
|
||||||
|
|
||||||
async def merge_frames_from_api(self,
|
|
||||||
animation_data: List[dict],
|
|
||||||
country: str,
|
|
||||||
images_from_api: Tuple[bytes],
|
|
||||||
localisation_layer: Image
|
|
||||||
) -> RadarAnimationData:
|
|
||||||
"""Merge three layers to create one frame of the radar: the basemap, the clouds and the location marker.
|
|
||||||
Adds text in the top right to specify the timestamp of each image."""
|
|
||||||
background: Image
|
|
||||||
fill_color: tuple
|
|
||||||
|
|
||||||
if country == 'NL':
|
|
||||||
background = Image.open("custom_components/irm_kmi/resources/nl.png").convert('RGBA')
|
|
||||||
fill_color = (0, 0, 0)
|
|
||||||
else:
|
else:
|
||||||
background = Image.open("custom_components/irm_kmi/resources/be_bw.png").convert('RGBA')
|
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
|
||||||
fill_color = (255, 255, 255)
|
f"Last success time is: {self.last_update_success_time}")
|
||||||
|
|
||||||
most_recent_frame = None
|
if self._api.get_city() in OUT_OF_BENELUX:
|
||||||
tz = pytz.timezone(self.hass.config.time_zone)
|
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
|
||||||
current_time = datetime.now(tz=tz)
|
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
|
||||||
sequence: List[AnimationFrameData] = list()
|
f"this")
|
||||||
|
disable_from_config(self.hass, self.config_entry)
|
||||||
|
|
||||||
for (idx, sequence_element) in enumerate(animation_data):
|
issue_registry.async_create_issue(
|
||||||
frame = images_from_api[idx]
|
self.hass,
|
||||||
layer = Image.open(BytesIO(frame)).convert('RGBA')
|
DOMAIN,
|
||||||
temp = Image.alpha_composite(background, layer)
|
"zone_moved",
|
||||||
temp = Image.alpha_composite(temp, localisation_layer)
|
is_fixable=True,
|
||||||
|
severity=issue_registry.IssueSeverity.ERROR,
|
||||||
draw = ImageDraw.Draw(temp)
|
translation_key='zone_moved',
|
||||||
font = ImageFont.truetype("custom_components/irm_kmi/resources/roboto_medium.ttf", 16)
|
data={'config_entry_id': self.config_entry.entry_id, 'zone': self._zone},
|
||||||
time_image = (datetime.fromisoformat(sequence_element.get('time'))
|
translation_placeholders={'zone': self._zone}
|
||||||
.astimezone(tz=tz))
|
|
||||||
|
|
||||||
time_str = time_image.isoformat(sep=' ', timespec='minutes')
|
|
||||||
|
|
||||||
draw.text((4, 4), time_str, fill_color, font=font)
|
|
||||||
|
|
||||||
bytes_img = BytesIO()
|
|
||||||
temp.save(bytes_img, 'png', compress_level=8)
|
|
||||||
|
|
||||||
sequence.append(
|
|
||||||
AnimationFrameData(
|
|
||||||
time=time_image,
|
|
||||||
image=bytes_img.getvalue()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
return ProcessedCoordinatorData()
|
||||||
|
|
||||||
if most_recent_frame is None and current_time < time_image:
|
return await self.process_api_data()
|
||||||
recent_idx = idx - 1 if idx > 0 else idx
|
|
||||||
most_recent_frame = sequence[recent_idx].get('image', None)
|
|
||||||
_LOGGER.debug(f"Most recent frame is at {sequence[recent_idx].get('time')}")
|
|
||||||
|
|
||||||
background.close()
|
async def async_refresh(self) -> None:
|
||||||
most_recent_frame = most_recent_frame if most_recent_frame is not None else sequence[-1].get('image')
|
"""Refresh data and log errors."""
|
||||||
|
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
|
||||||
|
|
||||||
return RadarAnimationData(
|
async def process_api_data(self) -> ProcessedCoordinatorData:
|
||||||
sequence=sequence,
|
"""From the API data, create the object that will be used in the entities"""
|
||||||
most_recent_image=most_recent_frame
|
tz = await dt.async_get_time_zone('Europe/Brussels')
|
||||||
)
|
lang = preferred_language(self.hass, self.config_entry)
|
||||||
|
try:
|
||||||
@staticmethod
|
pollen = await self._api.get_pollen()
|
||||||
def current_weather_from_data(api_data: dict) -> CurrentWeatherData:
|
except IrmKmiApiError as err:
|
||||||
"""Parse the API data to build a CurrentWeatherData."""
|
_LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.")
|
||||||
# Process data to get current hour forecast
|
pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \
|
||||||
now_hourly = None
|
if self.data is not None else PollenParser.get_unavailable_data()
|
||||||
hourly_forecast_data = api_data.get('for', {}).get('hourly')
|
|
||||||
if not (hourly_forecast_data is None
|
|
||||||
or not isinstance(hourly_forecast_data, list)
|
|
||||||
or len(hourly_forecast_data) == 0):
|
|
||||||
|
|
||||||
for current in hourly_forecast_data[:2]:
|
|
||||||
if datetime.now().strftime('%H') == current['hour']:
|
|
||||||
now_hourly = current
|
|
||||||
break
|
|
||||||
# Get UV index
|
|
||||||
module_data = api_data.get('module', None)
|
|
||||||
uv_index = None
|
|
||||||
if not (module_data is None or not isinstance(module_data, list)):
|
|
||||||
for module in module_data:
|
|
||||||
if module.get('type', None) == 'uv':
|
|
||||||
uv_index = module.get('data', {}).get('levelValue')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pressure = float(now_hourly.get('pressure', None)) if now_hourly is not None else None
|
radar_animation = self._api.get_animation_data(tz, lang, self._style, self._dark_mode)
|
||||||
except TypeError:
|
animation = await RainGraph(radar_animation,
|
||||||
pressure = None
|
country=self._api.get_country(),
|
||||||
|
style=self._style,
|
||||||
|
tz=tz,
|
||||||
|
dark_mode=self._dark_mode,
|
||||||
|
api_client=self._api
|
||||||
|
).build()
|
||||||
|
except ValueError:
|
||||||
|
animation = None
|
||||||
|
|
||||||
try:
|
|
||||||
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
|
|
||||||
except TypeError:
|
|
||||||
wind_speed = None
|
|
||||||
|
|
||||||
try:
|
# Make 'condition_evol' in a str instead of enum variant
|
||||||
wind_gust_speed = float(now_hourly.get('windPeakSpeedKm', None)) if now_hourly is not None else None
|
daily_forecast = [
|
||||||
except TypeError:
|
{**d, "condition_evol": d["condition_evol"].value}
|
||||||
wind_gust_speed = None
|
if "condition_evol" in d and hasattr(d["condition_evol"], "value")
|
||||||
|
else d
|
||||||
|
for d in self._api.get_daily_forecast(tz, lang)
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
return ProcessedCoordinatorData(
|
||||||
temperature = float(api_data.get('obs', {}).get('temp'))
|
current_weather=self._api.get_current_weather(tz),
|
||||||
except TypeError:
|
daily_forecast=daily_forecast,
|
||||||
temperature = None
|
hourly_forecast=self._api.get_hourly_forecast(tz),
|
||||||
|
radar_forecast=self._api.get_radar_forecast(),
|
||||||
current_weather = CurrentWeatherData(
|
animation=animation,
|
||||||
condition=CDT_MAP.get((api_data.get('obs', {}).get('ww'), api_data.get('obs', {}).get('dayNight')), None),
|
warnings=self._api.get_warnings(lang),
|
||||||
temperature=temperature,
|
pollen=pollen,
|
||||||
wind_speed=wind_speed,
|
country=self._api.get_country()
|
||||||
wind_gust_speed=wind_gust_speed,
|
|
||||||
wind_bearing=now_hourly.get('windDirectionText', {}).get('en') if now_hourly is not None else None,
|
|
||||||
pressure=pressure,
|
|
||||||
uv_index=uv_index
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if api_data.get('country', '') == 'NL':
|
|
||||||
current_weather['wind_speed'] = api_data.get('obs', {}).get('windSpeedKm')
|
|
||||||
current_weather['wind_bearing'] = api_data.get('obs', {}).get('windDirectionText', {}).get('en')
|
|
||||||
|
|
||||||
return current_weather
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
|
|
||||||
"""Parse data from the API to create a list of hourly forecasts"""
|
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
forecasts = list()
|
|
||||||
day = datetime.now()
|
|
||||||
|
|
||||||
for f in data:
|
|
||||||
if 'dateShow' in f:
|
|
||||||
day = day + timedelta(days=1)
|
|
||||||
|
|
||||||
hour = f.get('hour', None)
|
|
||||||
if hour is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
precipitation_probability = None
|
|
||||||
if f.get('precipChance', None) is not None:
|
|
||||||
precipitation_probability = int(f.get('precipChance'))
|
|
||||||
|
|
||||||
ww = None
|
|
||||||
if f.get('ww', None) is not None:
|
|
||||||
ww = int(f.get('ww'))
|
|
||||||
|
|
||||||
forecast = Forecast(
|
|
||||||
datetime=day.strftime(f'%Y-%m-%dT{hour}:00:00'),
|
|
||||||
condition=CDT_MAP.get((ww, f.get('dayNight', None)), None),
|
|
||||||
native_precipitation=f.get('precipQuantity', None),
|
|
||||||
native_temperature=f.get('temp', None),
|
|
||||||
native_templow=None,
|
|
||||||
native_wind_gust_speed=f.get('windPeakSpeedKm', None),
|
|
||||||
native_wind_speed=f.get('windSpeedKm', None),
|
|
||||||
precipitation_probability=precipitation_probability,
|
|
||||||
wind_bearing=f.get('windDirectionText', {}).get('en'),
|
|
||||||
native_pressure=f.get('pressure', None),
|
|
||||||
is_daytime=f.get('dayNight', None) == 'd'
|
|
||||||
)
|
|
||||||
|
|
||||||
forecasts.append(forecast)
|
|
||||||
|
|
||||||
return forecasts
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def daily_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
|
|
||||||
"""Parse data from the API to create a list of daily forecasts"""
|
|
||||||
if data is None or not isinstance(data, list) or len(data) == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
forecasts = list()
|
|
||||||
n_days = 0
|
|
||||||
|
|
||||||
for (idx, f) in enumerate(data):
|
|
||||||
precipitation = None
|
|
||||||
if f.get('precipQuantity', None) is not None:
|
|
||||||
try:
|
|
||||||
precipitation = float(f.get('precipQuantity'))
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
native_wind_gust_speed = None
|
|
||||||
if f.get('wind', {}).get('peakSpeed') is not None:
|
|
||||||
try:
|
|
||||||
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
is_daytime = f.get('dayNight', None) == 'd'
|
|
||||||
|
|
||||||
forecast = IrmKmiForecast(
|
|
||||||
datetime=(datetime.now() + timedelta(days=n_days)).strftime('%Y-%m-%d')
|
|
||||||
if is_daytime else datetime.now().strftime('%Y-%m-%d'),
|
|
||||||
condition=CDT_MAP.get((f.get('ww1', None), f.get('dayNight', None)), None),
|
|
||||||
native_precipitation=precipitation,
|
|
||||||
native_temperature=f.get('tempMax', None),
|
|
||||||
native_templow=f.get('tempMin', None),
|
|
||||||
native_wind_gust_speed=native_wind_gust_speed,
|
|
||||||
native_wind_speed=f.get('wind', {}).get('speed'),
|
|
||||||
precipitation_probability=f.get('precipChance', None),
|
|
||||||
wind_bearing=f.get('wind', {}).get('dirText', {}).get('en'),
|
|
||||||
is_daytime=is_daytime,
|
|
||||||
text_fr=f.get('text', {}).get('fr'),
|
|
||||||
text_nl=f.get('text', {}).get('nl')
|
|
||||||
)
|
|
||||||
forecasts.append(forecast)
|
|
||||||
if is_daytime or idx == 0:
|
|
||||||
n_days += 1
|
|
||||||
|
|
||||||
return forecasts
|
|
||||||
|
|
|
@ -1,40 +1,8 @@
|
||||||
"""Data classes for IRM KMI integration"""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, TypedDict
|
from typing import List, TypedDict
|
||||||
|
|
||||||
from homeassistant.components.weather import Forecast
|
from homeassistant.components.weather import Forecast
|
||||||
|
from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
|
||||||
|
from irm_kmi_api.rain_graph import RainGraph
|
||||||
class IrmKmiForecast(Forecast):
|
|
||||||
"""Forecast class with additional attributes for IRM KMI"""
|
|
||||||
|
|
||||||
# TODO: add condition_2 as well and evolution to match data from the API?
|
|
||||||
text_fr: str | None
|
|
||||||
text_nl: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentWeatherData(TypedDict, total=False):
|
|
||||||
"""Class to hold the currently observable weather at a given location"""
|
|
||||||
condition: str | None
|
|
||||||
temperature: float | None
|
|
||||||
wind_speed: float | None
|
|
||||||
wind_gust_speed: float | None
|
|
||||||
wind_bearing: float | str | None
|
|
||||||
uv_index: float | None
|
|
||||||
pressure: float | None
|
|
||||||
|
|
||||||
|
|
||||||
class AnimationFrameData(TypedDict, total=False):
|
|
||||||
"""Holds one single frame of the radar camera, along with the timestamp of the frame"""
|
|
||||||
time: datetime | None
|
|
||||||
image: bytes | None
|
|
||||||
|
|
||||||
|
|
||||||
class RadarAnimationData(TypedDict, total=False):
|
|
||||||
"""Holds frames and additional data for the animation to be rendered"""
|
|
||||||
sequence: List[AnimationFrameData] | None
|
|
||||||
most_recent_image: bytes | None
|
|
||||||
hint: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedCoordinatorData(TypedDict, total=False):
|
class ProcessedCoordinatorData(TypedDict, total=False):
|
||||||
|
@ -42,4 +10,8 @@ class ProcessedCoordinatorData(TypedDict, total=False):
|
||||||
current_weather: CurrentWeatherData
|
current_weather: CurrentWeatherData
|
||||||
hourly_forecast: List[Forecast] | None
|
hourly_forecast: List[Forecast] | None
|
||||||
daily_forecast: List[IrmKmiForecast] | None
|
daily_forecast: List[IrmKmiForecast] | None
|
||||||
animation: RadarAnimationData
|
radar_forecast: List[Forecast] | None
|
||||||
|
animation: RainGraph | None
|
||||||
|
warnings: List[WarningData]
|
||||||
|
pollen: dict
|
||||||
|
country: str
|
||||||
|
|
5
custom_components/irm_kmi/icons.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"get_forecasts_radar": "mdi:weather-cloudy-clock"
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,11 +3,13 @@
|
||||||
"name": "IRM KMI Weather Belgium",
|
"name": "IRM KMI Weather Belgium",
|
||||||
"codeowners": ["@jdejaegh"],
|
"codeowners": ["@jdejaegh"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": ["zone"],
|
||||||
"documentation": "https://github.com/jdejaegh/irm-kmi-ha/",
|
"documentation": "https://github.com/jdejaegh/irm-kmi-ha/",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
|
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
|
||||||
"requirements": [],
|
"requirements": [
|
||||||
"version": "0.1.4-beta"
|
"irm-kmi-api==0.2.0"
|
||||||
|
],
|
||||||
|
"version": "0.3.2"
|
||||||
}
|
}
|
93
custom_components/irm_kmi/repairs.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.repairs import RepairsFlow
|
||||||
|
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||||
|
from irm_kmi_api.api import IrmKmiApiClient
|
||||||
|
|
||||||
|
from . import async_reload_entry
|
||||||
|
from .const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE, REPAIR_OPT_MOVE,
|
||||||
|
REPAIR_OPTIONS, REPAIR_SOLUTION, USER_AGENT)
|
||||||
|
from .utils import modify_from_config
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfBeneluxRepairFlow(RepairsFlow):
|
||||||
|
"""Handler for an issue fixing flow."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self._data: dict = data
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> data_entry_flow.FlowResult:
|
||||||
|
"""Handle the first step of a fix flow."""
|
||||||
|
|
||||||
|
return await (self.async_step_confirm())
|
||||||
|
|
||||||
|
async def async_step_confirm(
|
||||||
|
self, user_input: dict[str, str] | None = None
|
||||||
|
) -> data_entry_flow.FlowResult:
|
||||||
|
"""Handle the confirm step of a fix flow."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
config_entry = self.hass.config_entries.async_get_entry(self._data['config_entry_id'])
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
if user_input[REPAIR_SOLUTION] == REPAIR_OPT_MOVE:
|
||||||
|
if (zone := self.hass.states.get(self._data['zone'])) is None:
|
||||||
|
errors[REPAIR_SOLUTION] = "zone_not_exist"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
api_data = {}
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(10):
|
||||||
|
api_data = await IrmKmiApiClient(
|
||||||
|
session=async_get_clientsession(self.hass),
|
||||||
|
user_agent=USER_AGENT
|
||||||
|
).get_forecasts_coord(
|
||||||
|
{'lat': zone.attributes[ATTR_LATITUDE],
|
||||||
|
'long': zone.attributes[ATTR_LONGITUDE]}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
errors[REPAIR_SOLUTION] = 'api_error'
|
||||||
|
|
||||||
|
if api_data.get('cityName', None) in OUT_OF_BENELUX:
|
||||||
|
errors[REPAIR_SOLUTION] = 'out_of_benelux'
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
modify_from_config(self.hass, self._data['config_entry_id'], enable=True)
|
||||||
|
await async_reload_entry(self.hass, config_entry)
|
||||||
|
|
||||||
|
elif user_input[REPAIR_SOLUTION] == REPAIR_OPT_DELETE:
|
||||||
|
await self.hass.config_entries.async_remove(self._data['config_entry_id'])
|
||||||
|
else:
|
||||||
|
errors[REPAIR_SOLUTION] = "invalid_choice"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="confirm",
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={'zone': self._data['zone']},
|
||||||
|
data_schema=vol.Schema({
|
||||||
|
vol.Required(REPAIR_SOLUTION, default=REPAIR_OPT_MOVE):
|
||||||
|
SelectSelector(SelectSelectorConfig(options=REPAIR_OPTIONS,
|
||||||
|
translation_key=REPAIR_SOLUTION)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_create_fix_flow(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
_issue_id: str,
|
||||||
|
data: dict[str, str | int | float | None] | None,
|
||||||
|
) -> OutOfBeneluxRepairFlow:
|
||||||
|
"""Create flow."""
|
||||||
|
return OutOfBeneluxRepairFlow(data)
|
Before Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 667 KiB |
Before Width: | Height: | Size: 74 KiB |
230
custom_components/irm_kmi/sensor.py
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
"""Sensor for pollen from the IRM KMI"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from homeassistant.components import sensor
|
||||||
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import dt
|
||||||
|
from irm_kmi_api.const import POLLEN_NAMES
|
||||||
|
from irm_kmi_api.data import IrmKmiForecast, IrmKmiRadarForecast
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
|
||||||
|
from . import DOMAIN, IrmKmiCoordinator
|
||||||
|
from .const import (CURRENT_WEATHER_SENSOR_CLASS, CURRENT_WEATHER_SENSOR_ICON,
|
||||||
|
CURRENT_WEATHER_SENSOR_UNITS, CURRENT_WEATHER_SENSORS,
|
||||||
|
POLLEN_TO_ICON_MAP)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||||
|
"""Set up the sensor platform"""
|
||||||
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
async_add_entities([IrmKmiPollen(coordinator, entry, pollen.lower()) for pollen in POLLEN_NAMES])
|
||||||
|
async_add_entities([IrmKmiCurrentWeather(coordinator, entry, name) for name in CURRENT_WEATHER_SENSORS])
|
||||||
|
async_add_entities([IrmKmiNextWarning(coordinator, entry),
|
||||||
|
IrmKmiCurrentRainfall(coordinator, entry)])
|
||||||
|
|
||||||
|
if coordinator.data.get('country') != 'NL':
|
||||||
|
async_add_entities([IrmKmiNextSunMove(coordinator, entry, move) for move in ['sunset', 'sunrise']])
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiPollen(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Representation of a pollen sensor"""
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_device_class = SensorDeviceClass.ENUM
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
pollen: str
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
SensorEntity.__init__(self)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}-pollen-{pollen}"
|
||||||
|
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_{pollen}_level")
|
||||||
|
self._attr_options = PollenParser.get_option_values()
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
self._pollen = pollen
|
||||||
|
self._attr_translation_key = f"pollen_{pollen}"
|
||||||
|
self._attr_icon = POLLEN_TO_ICON_MAP[pollen]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.coordinator.data.get('pollen', {}).get(self._pollen, None)
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiNextWarning(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Representation of the next weather warning"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
SensorEntity.__init__(self)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}-next-warning"
|
||||||
|
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_next_warning")
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
self._attr_translation_key = f"next_warning"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> datetime | None:
|
||||||
|
"""Return the timestamp for the start of the next warning. Is None when no future warning are available"""
|
||||||
|
if self.coordinator.data.get('warnings') is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = dt.now()
|
||||||
|
earliest_next = None
|
||||||
|
for item in self.coordinator.data.get('warnings'):
|
||||||
|
if now < item.get('starts_at'):
|
||||||
|
if earliest_next is None:
|
||||||
|
earliest_next = item.get('starts_at')
|
||||||
|
else:
|
||||||
|
earliest_next = min(earliest_next, item.get('starts_at'))
|
||||||
|
|
||||||
|
return earliest_next
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""Return the attributes related to all the future warnings."""
|
||||||
|
now = dt.now()
|
||||||
|
attrs = {"next_warnings": [w for w in self.coordinator.data.get('warnings', []) if now < w.get('starts_at')]}
|
||||||
|
|
||||||
|
attrs["next_warnings_friendly_names"] = ", ".join(
|
||||||
|
[warning['friendly_name'] for warning in attrs['next_warnings'] if warning['friendly_name'] != ''])
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiNextSunMove(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Representation of the next sunrise or sunset"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
move: str) -> None:
|
||||||
|
assert move in ['sunset', 'sunrise']
|
||||||
|
super().__init__(coordinator)
|
||||||
|
SensorEntity.__init__(self)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}-next-{move}"
|
||||||
|
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_next_{move}")
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
self._attr_translation_key = f"next_{move}"
|
||||||
|
self._move: str = move
|
||||||
|
self._attr_icon = 'mdi:weather-sunset-down' if move == 'sunset' else 'mdi:weather-sunset-up'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> datetime | None:
|
||||||
|
"""Return the timestamp for the next sunrise or sunset"""
|
||||||
|
now = dt.now()
|
||||||
|
data: list[IrmKmiForecast] = self.coordinator.data.get('daily_forecast')
|
||||||
|
|
||||||
|
upcoming = [datetime.fromisoformat(f.get(self._move)) for f in data
|
||||||
|
if f.get(self._move) is not None and datetime.fromisoformat(f.get(self._move)) >= now]
|
||||||
|
|
||||||
|
if len(upcoming) > 0:
|
||||||
|
return upcoming[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiCurrentWeather(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Representation of a current weather sensor"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
sensor_name: str) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
SensorEntity.__init__(self)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}-current-{sensor_name}"
|
||||||
|
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_{sensor_name}")
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
self._attr_translation_key = f"current_{sensor_name}"
|
||||||
|
self._sensor_name: str = sensor_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
"""Return the current value of the sensor"""
|
||||||
|
return self.coordinator.data.get('current_weather', {}).get(self._sensor_name, None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
return CURRENT_WEATHER_SENSOR_UNITS[self._sensor_name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_class(self) -> SensorDeviceClass | None:
|
||||||
|
return CURRENT_WEATHER_SENSOR_CLASS[self._sensor_name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str | None:
|
||||||
|
return CURRENT_WEATHER_SENSOR_ICON[self._sensor_name]
|
||||||
|
|
||||||
|
|
||||||
|
class IrmKmiCurrentRainfall(CoordinatorEntity, SensorEntity):
|
||||||
|
"""Representation of a current rainfall sensor"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
coordinator: IrmKmiCoordinator,
|
||||||
|
entry: ConfigEntry) -> None:
|
||||||
|
super().__init__(coordinator)
|
||||||
|
SensorEntity.__init__(self)
|
||||||
|
self._attr_unique_id = f"{entry.entry_id}-current-rainfall"
|
||||||
|
self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"{str(entry.title).lower()}_current_rainfall")
|
||||||
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
|
self._attr_translation_key = "current_rainfall"
|
||||||
|
self._attr_icon = 'mdi:weather-pouring'
|
||||||
|
|
||||||
|
def _current_forecast(self) -> IrmKmiRadarForecast | None:
|
||||||
|
now = dt.now()
|
||||||
|
forecasts = self.coordinator.data.get('radar_forecast', None)
|
||||||
|
|
||||||
|
if forecasts is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prev = forecasts[0]
|
||||||
|
for f in forecasts:
|
||||||
|
if datetime.fromisoformat(f.get('datetime')) > now:
|
||||||
|
return prev
|
||||||
|
prev = f
|
||||||
|
|
||||||
|
return forecasts[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> float | None:
|
||||||
|
"""Return the current value of the sensor"""
|
||||||
|
current = self._current_forecast()
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return current.get('native_precipitation', None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self) -> str | None:
|
||||||
|
current = self._current_forecast()
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return current.get('unit', None)
|
11
custom_components/irm_kmi/services.yaml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
get_forecasts_radar:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: irm_kmi
|
||||||
|
domain: weather
|
||||||
|
fields:
|
||||||
|
include_past_forecasts:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
221
custom_components/irm_kmi/translations/en.json
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
{
|
||||||
|
"title": "Royal Meteorological Institute of Belgium",
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "The weather for this zone is already configured",
|
||||||
|
"unknown": "Unknown error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Configuration",
|
||||||
|
"data": {
|
||||||
|
"zone": "Zone",
|
||||||
|
"style": "Style of the radar",
|
||||||
|
"dark_mode": "Radar dark mode",
|
||||||
|
"use_deprecated_forecast_attribute": "Use the deprecated forecat attribute",
|
||||||
|
"language_override": "Language"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} is out of Benelux. Pick a zone in Benelux.",
|
||||||
|
"api_error": "Could not get data from the API",
|
||||||
|
"zone_not_exist": "{zone} does not exist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"style": {
|
||||||
|
"options": {
|
||||||
|
"standard_style": "Standard",
|
||||||
|
"contrast_style": "High contrast",
|
||||||
|
"yellow_red_style": "Yellow-Red",
|
||||||
|
"satellite_style": "Satellite map"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use_deprecated_forecast_attribute": {
|
||||||
|
"options": {
|
||||||
|
"do_not_use_deprecated_forecast": "Do not use (recommended)",
|
||||||
|
"daily_in_deprecated_forecast": "Use for daily forecast",
|
||||||
|
"twice_daily_in_deprecated_forecast": "Use for twice daily forecast",
|
||||||
|
"hourly_in_deprecated_forecast": "Use for hourly forecast"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repair_solution": {
|
||||||
|
"options": {
|
||||||
|
"repair_option_move": "I moved the zone in Benelux",
|
||||||
|
"repair_option_delete": "Delete that config entry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_override": {
|
||||||
|
"options": {
|
||||||
|
"none": "Follow Home Assistant server language",
|
||||||
|
"fr": "French",
|
||||||
|
"nl": "Dutch",
|
||||||
|
"de": "German",
|
||||||
|
"en": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Options",
|
||||||
|
"data": {
|
||||||
|
"style": "Style of the radar",
|
||||||
|
"dark_mode": "Radar dark mode",
|
||||||
|
"use_deprecated_forecast_attribute": "Use the deprecated forecat attribute",
|
||||||
|
"language_override": "Language"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"zone_moved": {
|
||||||
|
"title": "{zone} is outside of Benelux",
|
||||||
|
"fix_flow": {
|
||||||
|
"step": {
|
||||||
|
"confirm": {
|
||||||
|
"title": "Repair: {zone} is outside of Benelux",
|
||||||
|
"description": "This integration can only get data for location in the Benelux. Move the zone or delete this configuration entry."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} is out of Benelux. Move it inside Benelux first.",
|
||||||
|
"api_error": "Could not get data from the API",
|
||||||
|
"zone_not_exist": "{zone} does not exist",
|
||||||
|
"invalid_choice": "The choice is not valid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"next_warning": {
|
||||||
|
"name": "Next warning"
|
||||||
|
},
|
||||||
|
"next_sunrise": {
|
||||||
|
"name": "Next sunrise"
|
||||||
|
},
|
||||||
|
"next_sunset": {
|
||||||
|
"name": "Next sunset"
|
||||||
|
},
|
||||||
|
"pollen_alder": {
|
||||||
|
"name": "Alder pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_ash": {
|
||||||
|
"name": "Ash pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_birch": {
|
||||||
|
"name": "Birch pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_grasses": {
|
||||||
|
"name": "Grass pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_hazel": {
|
||||||
|
"name": "Hazel pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_mugwort": {
|
||||||
|
"name": "Mugwort pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_oak": {
|
||||||
|
"name": "Oak pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Active",
|
||||||
|
"green": "Green",
|
||||||
|
"yellow": "Yellow",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Red",
|
||||||
|
"purple": "Purple",
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"current_temperature": {
|
||||||
|
"name": "Temperature"
|
||||||
|
},
|
||||||
|
"current_wind_speed": {
|
||||||
|
"name": "Wind speed"
|
||||||
|
},
|
||||||
|
"current_wind_gust_speed": {
|
||||||
|
"name": "Wind gust speed"
|
||||||
|
},
|
||||||
|
"current_wind_bearing": {
|
||||||
|
"name": "Wind bearing"
|
||||||
|
},
|
||||||
|
"current_uv_index": {
|
||||||
|
"name": "UV index"
|
||||||
|
},
|
||||||
|
"current_pressure": {
|
||||||
|
"name": "Atmospheric pressure"
|
||||||
|
},
|
||||||
|
"current_rainfall": {
|
||||||
|
"name": "Rainfall"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecasts_radar": {
|
||||||
|
"name": "Get forecast from the radar",
|
||||||
|
"description": "Get weather forecast from the radar. Only precipitation is available.",
|
||||||
|
"fields": {
|
||||||
|
"include_past_forecasts": {
|
||||||
|
"name": "Include past forecasts",
|
||||||
|
"description": "Also return forecasts for that are in the past."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
221
custom_components/irm_kmi/translations/fr.json
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
{
|
||||||
|
"title": "Institut Royal Météorologique de Belgique",
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Les prévisions météo pour cette zone sont déjà configurées",
|
||||||
|
"unknown": "Erreur inconnue"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Configuration",
|
||||||
|
"data": {
|
||||||
|
"zone": "Zone",
|
||||||
|
"style": "Style du radar",
|
||||||
|
"dark_mode": "Radar en mode sombre",
|
||||||
|
"use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)",
|
||||||
|
"language_override": "Langue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} est hors du Benelux. Choisissez une zone dans le Benelux.",
|
||||||
|
"api_error": "Impossible d'obtenir les données depuis l'API",
|
||||||
|
"zone_not_exist": "{zone} n'existe pas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"style": {
|
||||||
|
"options": {
|
||||||
|
"standard_style": "Standard",
|
||||||
|
"contrast_style": "Contraste élevé",
|
||||||
|
"yellow_red_style": "Jaune-Rouge",
|
||||||
|
"satellite_style": "Carte satellite"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use_deprecated_forecast_attribute": {
|
||||||
|
"options": {
|
||||||
|
"do_not_use_deprecated_forecast": "Ne pas utiliser (recommandé)",
|
||||||
|
"daily_in_deprecated_forecast": "Utiliser pour les prévisions quotidiennes",
|
||||||
|
"twice_daily_in_deprecated_forecast": "Utiliser pour les prévisions biquotidiennes",
|
||||||
|
"hourly_in_deprecated_forecast": "Utiliser pour les prévisions horaires"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repair_solution": {
|
||||||
|
"options": {
|
||||||
|
"repair_option_move": "J'ai déplacé la zone dans le Benelux",
|
||||||
|
"repair_option_delete": "Supprimer cette configuration"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_override": {
|
||||||
|
"options": {
|
||||||
|
"none": "Langue du serveur Home Assistant",
|
||||||
|
"fr": "Français",
|
||||||
|
"nl": "Néerlandais",
|
||||||
|
"de": "Allemand",
|
||||||
|
"en": "Anglais"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Options",
|
||||||
|
"data": {
|
||||||
|
"style": "Style du radar",
|
||||||
|
"dark_mode": "Radar en mode sombre",
|
||||||
|
"use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)",
|
||||||
|
"language_override": "Langue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"zone_moved": {
|
||||||
|
"title": "{zone} est hors du Benelux",
|
||||||
|
"fix_flow": {
|
||||||
|
"step": {
|
||||||
|
"confirm": {
|
||||||
|
"title": "Correction: {zone} est hors du Benelux",
|
||||||
|
"description": "Cette intégration ne fournit des données que pour le Benelux. Déplacer la zone ou supprimer la configuration."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} est hors du Benelux. Commencez par déplacer la zone dans le Benelux.",
|
||||||
|
"api_error": "Impossible d'obtenir les données depuis l'API",
|
||||||
|
"zone_not_exist": "{zone} n'existe pas",
|
||||||
|
"invalid_choice": "Choix non valide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"next_warning": {
|
||||||
|
"name": "Prochain avertissement"
|
||||||
|
},
|
||||||
|
"next_sunrise": {
|
||||||
|
"name": "Prochain lever de soleil"
|
||||||
|
},
|
||||||
|
"next_sunset": {
|
||||||
|
"name": "Prochain coucher de soleil"
|
||||||
|
},
|
||||||
|
"pollen_alder": {
|
||||||
|
"name": "Pollen d'aulne",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_ash": {
|
||||||
|
"name": "Pollen de frêne",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_birch": {
|
||||||
|
"name": "Pollen de bouleau",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_grasses": {
|
||||||
|
"name": "Pollen de graminées",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_hazel": {
|
||||||
|
"name": "Pollen de noisetier",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_mugwort": {
|
||||||
|
"name": "Pollen d'armoise",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_oak": {
|
||||||
|
"name": "Pollen de chêne",
|
||||||
|
"state": {
|
||||||
|
"active": "Actif",
|
||||||
|
"green": "Vert",
|
||||||
|
"yellow": "Jaune",
|
||||||
|
"orange": "Orange",
|
||||||
|
"red": "Rouge",
|
||||||
|
"purple": "Violet",
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"current_temperature": {
|
||||||
|
"name": "Température"
|
||||||
|
},
|
||||||
|
"current_wind_speed": {
|
||||||
|
"name": "Vitesse du vent"
|
||||||
|
},
|
||||||
|
"current_wind_gust_speed": {
|
||||||
|
"name": "Vitesse des rafales de vent"
|
||||||
|
},
|
||||||
|
"current_wind_bearing": {
|
||||||
|
"name": "Direction du vent"
|
||||||
|
},
|
||||||
|
"current_uv_index": {
|
||||||
|
"name": "Index UV"
|
||||||
|
},
|
||||||
|
"current_pressure": {
|
||||||
|
"name": "Pression atmosphérique"
|
||||||
|
},
|
||||||
|
"current_rainfall": {
|
||||||
|
"name": "Précipitation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecasts_radar": {
|
||||||
|
"name": "Obtenir les prévisions du radar",
|
||||||
|
"description": "Obtenez les prévisions météorologiques depuis le radar. Seules les précipitations sont disponibles.",
|
||||||
|
"fields": {
|
||||||
|
"include_past_forecasts": {
|
||||||
|
"name": "Inclure les prévisions passées",
|
||||||
|
"description": "Retourne également les prévisions qui sont dans le passé."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
221
custom_components/irm_kmi/translations/nl.json
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
{
|
||||||
|
"title": "Koninklijk Meteorologisch Instituut van België",
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "De weersvoorspellingen voor deze zone zijn al geconfigureerd",
|
||||||
|
"unknown": "Onbekende fout"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Instellingen",
|
||||||
|
"data": {
|
||||||
|
"zone": "Zone",
|
||||||
|
"style": "Radarstijl",
|
||||||
|
"dark_mode": "Radar in donkere modus",
|
||||||
|
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)",
|
||||||
|
"language_override": "Taal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} ligt buiten de Benelux. Kies een zone in de Benelux.",
|
||||||
|
"api_error": "Kon geen gegevens van de API krijgen",
|
||||||
|
"zone_not_exist": "{zone} bestaat niet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"style": {
|
||||||
|
"options": {
|
||||||
|
"standard_style": "Standaard",
|
||||||
|
"contrast_style": "Hoog contrast",
|
||||||
|
"yellow_red_style": "Geel-Rood",
|
||||||
|
"satellite_style": "Satellietkaart"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use_deprecated_forecast_attribute": {
|
||||||
|
"options": {
|
||||||
|
"do_not_use_deprecated_forecast": "Niet gebruiken (aanbevolen)",
|
||||||
|
"daily_in_deprecated_forecast": "Gebruik voor dagelijkse voorspellingen",
|
||||||
|
"twice_daily_in_deprecated_forecast": "Gebruik voor tweemaal daags voorspellingen",
|
||||||
|
"hourly_in_deprecated_forecast": "Gebruik voor uurlijkse voorspellingen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repair_solution": {
|
||||||
|
"options": {
|
||||||
|
"repair_option_move": "Ik heb de zone verplaats naar de Benelux",
|
||||||
|
"repair_option_delete": "Deze configuratie verwijderen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_override": {
|
||||||
|
"options": {
|
||||||
|
"none": "Zelfde als Home Assistant server taal",
|
||||||
|
"fr": "Frans",
|
||||||
|
"nl": "Nederlands",
|
||||||
|
"de": "Duits",
|
||||||
|
"en": "Engels"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Opties",
|
||||||
|
"data": {
|
||||||
|
"style": "Radarstijl",
|
||||||
|
"dark_mode": "Radar in donkere modus",
|
||||||
|
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)",
|
||||||
|
"language_override": "Taal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"zone_moved": {
|
||||||
|
"title": "{zone} ligt buiten de Benelux",
|
||||||
|
"fix_flow": {
|
||||||
|
"step": {
|
||||||
|
"confirm": {
|
||||||
|
"title": "Reparatie: {zone} ligt buiten de Benelux",
|
||||||
|
"description": "Deze integratie levert alleen gegevens op voor de Benelux. Verplaats de zone of verwijder de configuratie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} ligt buiten de Benelux. Kies een zone in de Benelux.",
|
||||||
|
"api_error": "Kon geen gegevens van de API krijgen",
|
||||||
|
"zone_not_exist": "{zone} bestaat niet",
|
||||||
|
"invalid_choice": "Ongeldige keuze"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"next_warning": {
|
||||||
|
"name": "Volgende waarschuwing"
|
||||||
|
},
|
||||||
|
"next_sunrise": {
|
||||||
|
"name": "Volgende zonsopkomst"
|
||||||
|
},
|
||||||
|
"next_sunset": {
|
||||||
|
"name": "Volgende zonsondergang"
|
||||||
|
},
|
||||||
|
"pollen_alder": {
|
||||||
|
"name": "Elzenpollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_ash": {
|
||||||
|
"name": "Essen pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_birch": {
|
||||||
|
"name": "Berken pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_grasses": {
|
||||||
|
"name": "Graspollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_hazel": {
|
||||||
|
"name": "Hazelaar pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_mugwort": {
|
||||||
|
"name": "Alsem pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_oak": {
|
||||||
|
"name": "Eiken pollen",
|
||||||
|
"state": {
|
||||||
|
"active": "Actief",
|
||||||
|
"green": "Groen",
|
||||||
|
"yellow": "Geel",
|
||||||
|
"orange": "Oranje",
|
||||||
|
"red": "Rood",
|
||||||
|
"purple": "Paars",
|
||||||
|
"none": "Geen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"current_temperature": {
|
||||||
|
"name": "Temperatuur"
|
||||||
|
},
|
||||||
|
"current_wind_speed": {
|
||||||
|
"name": "Windsnelheid"
|
||||||
|
},
|
||||||
|
"current_wind_gust_speed": {
|
||||||
|
"name": "Snelheid windvlaag"
|
||||||
|
},
|
||||||
|
"current_wind_bearing": {
|
||||||
|
"name": "Windrichting"
|
||||||
|
},
|
||||||
|
"current_uv_index": {
|
||||||
|
"name": "UV-index"
|
||||||
|
},
|
||||||
|
"current_pressure": {
|
||||||
|
"name": "Luchtdruk"
|
||||||
|
},
|
||||||
|
"current_rainfall": {
|
||||||
|
"name": "Neerslag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecasts_radar": {
|
||||||
|
"name": "Get forecast from the radar",
|
||||||
|
"description": "Weersverwachting van radar ophalen. Alleen neerslag is beschikbaar.",
|
||||||
|
"fields": {
|
||||||
|
"include_past_forecasts": {
|
||||||
|
"name": "Verleden weersvoorspellingen opnemen",
|
||||||
|
"description": "Geeft ook weersvoorspellingen uit het verleden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
221
custom_components/irm_kmi/translations/pt.json
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
{
|
||||||
|
"title": "Instituto Real Meteorológico da Bélgica",
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "O clima para esta zona já está configurado",
|
||||||
|
"unknown": "Erro desconhecido"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Configuração",
|
||||||
|
"data": {
|
||||||
|
"zone": "Zona",
|
||||||
|
"style": "Estilo do radar",
|
||||||
|
"dark_mode": "Modo escuro do radar",
|
||||||
|
"use_deprecated_forecast_attribute": "Usar o atributo de previsão descontinuado",
|
||||||
|
"language_override": "Idioma"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} está fora do Benelux. Escolha uma zona no Benelux.",
|
||||||
|
"api_error": "Não foi possível obter dados da API",
|
||||||
|
"zone_not_exist": "{zone} não existe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"style": {
|
||||||
|
"options": {
|
||||||
|
"standard_style": "Padrão",
|
||||||
|
"contrast_style": "Alto contraste",
|
||||||
|
"yellow_red_style": "Amarelo-Vermelho",
|
||||||
|
"satellite_style": "Mapa de satélite"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use_deprecated_forecast_attribute": {
|
||||||
|
"options": {
|
||||||
|
"do_not_use_deprecated_forecast": "Não usar (recomendado)",
|
||||||
|
"daily_in_deprecated_forecast": "Usar para previsão diária",
|
||||||
|
"twice_daily_in_deprecated_forecast": "Usar para previsão duas vezes ao dia",
|
||||||
|
"hourly_in_deprecated_forecast": "Usar para previsão horária"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"repair_solution": {
|
||||||
|
"options": {
|
||||||
|
"repair_option_move": "Mudei a zona para o Benelux",
|
||||||
|
"repair_option_delete": "Apagar essa entrada de configuração"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language_override": {
|
||||||
|
"options": {
|
||||||
|
"none": "Seguir o idioma do servidor do Home Assistant",
|
||||||
|
"fr": "Francês",
|
||||||
|
"nl": "Neerlandês",
|
||||||
|
"de": "Alemão",
|
||||||
|
"en": "Inglês"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "Opções",
|
||||||
|
"data": {
|
||||||
|
"style": "Estilo do radar",
|
||||||
|
"dark_mode": "Modo escuro do radar",
|
||||||
|
"use_deprecated_forecast_attribute": "Usar o atributo de previsão descontinuado",
|
||||||
|
"language_override": "Idioma"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"zone_moved": {
|
||||||
|
"title": "{zone} está fora do Benelux",
|
||||||
|
"fix_flow": {
|
||||||
|
"step": {
|
||||||
|
"confirm": {
|
||||||
|
"title": "Reparação: {zone} está fora do Benelux",
|
||||||
|
"description": "Esta integração só pode obter dados para locais no Benelux. Mova a zona ou apague esta entrada de configuração."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"out_of_benelux": "{zone} está fora do Benelux. Mova-a para dentro do Benelux primeiro.",
|
||||||
|
"api_error": "Não foi possível obter dados da API",
|
||||||
|
"zone_not_exist": "{zone} não existe",
|
||||||
|
"invalid_choice": "A escolha não é válida"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"next_warning": {
|
||||||
|
"name": "Próximo aviso"
|
||||||
|
},
|
||||||
|
"next_sunrise": {
|
||||||
|
"name": "Próximo nascer do sol"
|
||||||
|
},
|
||||||
|
"next_sunset": {
|
||||||
|
"name": "Próximo pôr do sol"
|
||||||
|
},
|
||||||
|
"pollen_alder": {
|
||||||
|
"name": "Pólen de amieiro",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_ash": {
|
||||||
|
"name": "Pólen de freixo",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_birch": {
|
||||||
|
"name": "Pólen de bétula",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_grasses": {
|
||||||
|
"name": "Pólen de gramíneas",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_hazel": {
|
||||||
|
"name": "Pólen de aveleira",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_mugwort": {
|
||||||
|
"name": "Pólen de artemísia",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pollen_oak": {
|
||||||
|
"name": "Pólen de carvalho",
|
||||||
|
"state": {
|
||||||
|
"active": "Ativo",
|
||||||
|
"green": "Verde",
|
||||||
|
"yellow": "Amarelo",
|
||||||
|
"orange": "Laranja",
|
||||||
|
"red": "Vermelho",
|
||||||
|
"purple": "Roxo",
|
||||||
|
"none": "Nenhum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"current_temperature": {
|
||||||
|
"name": "Temperatura"
|
||||||
|
},
|
||||||
|
"current_wind_speed": {
|
||||||
|
"name": "Velocidade do vento"
|
||||||
|
},
|
||||||
|
"current_wind_gust_speed": {
|
||||||
|
"name": "Velocidade da rajada de vento"
|
||||||
|
},
|
||||||
|
"current_wind_bearing": {
|
||||||
|
"name": "Direção do vento"
|
||||||
|
},
|
||||||
|
"current_uv_index": {
|
||||||
|
"name": "Índice UV"
|
||||||
|
},
|
||||||
|
"current_pressure": {
|
||||||
|
"name": "Pressão atmosférica"
|
||||||
|
},
|
||||||
|
"current_rainfall": {
|
||||||
|
"name": "Precipitação"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"get_forecasts_radar": {
|
||||||
|
"name": "Obter previsão do radar",
|
||||||
|
"description": "Obter previsão do tempo do radar. Apenas precipitação está disponível.",
|
||||||
|
"fields": {
|
||||||
|
"include_past_forecasts": {
|
||||||
|
"name": "Incluir previsões passadas",
|
||||||
|
"description": "Também retornar previsões que estão no passado."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
custom_components/irm_kmi/utils.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry
|
||||||
|
|
||||||
|
from .const import CONF_LANGUAGE_OVERRIDE, LANGS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def disable_from_config(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||||
|
modify_from_config(hass, config_entry.entry_id, False)
|
||||||
|
|
||||||
|
|
||||||
|
def enable_from_config(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||||
|
modify_from_config(hass, config_entry.entry_id, True)
|
||||||
|
|
||||||
|
|
||||||
|
def modify_from_config(hass: HomeAssistant, config_entry_id: str, enable: bool):
|
||||||
|
dr = device_registry.async_get(hass)
|
||||||
|
devices = device_registry.async_entries_for_config_entry(dr, config_entry_id)
|
||||||
|
_LOGGER.info(f"Trying to {'enable' if enable else 'disable'} {config_entry_id}: {len(devices)} device(s)")
|
||||||
|
for device in devices:
|
||||||
|
dr.async_update_device(device_id=device.id,
|
||||||
|
disabled_by=None if enable else device_registry.DeviceEntryDisabler.INTEGRATION)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_value(config_entry: ConfigEntry, key: str) -> Any:
|
||||||
|
if config_entry.options and key in config_entry.options:
|
||||||
|
return config_entry.options[key]
|
||||||
|
return config_entry.data[key]
|
||||||
|
|
||||||
|
|
||||||
|
def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry) -> str:
|
||||||
|
if get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE) == 'none':
|
||||||
|
return hass.config.language if hass.config.language in LANGS else 'en'
|
||||||
|
|
||||||
|
return get_config_value(config_entry, CONF_LANGUAGE_OVERRIDE)
|
||||||
|
|
||||||
|
|
|
@ -1,34 +1,55 @@
|
||||||
"""Support for IRM KMI weather."""
|
"""Support for IRM KMI weather."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
from homeassistant.components.weather import (Forecast, WeatherEntity,
|
from homeassistant.components.weather import (Forecast, WeatherEntity,
|
||||||
WeatherEntityFeature)
|
WeatherEntityFeature)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure,
|
from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure,
|
||||||
UnitOfSpeed, UnitOfTemperature)
|
UnitOfSpeed, UnitOfTemperature)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers import entity_platform
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
from homeassistant.util import dt
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import CONF_USE_DEPRECATED_FORECAST, DOMAIN
|
||||||
|
from .const import (OPTION_DEPRECATED_FORECAST_DAILY,
|
||||||
|
OPTION_DEPRECATED_FORECAST_HOURLY,
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
|
||||||
from .coordinator import IrmKmiCoordinator
|
from .coordinator import IrmKmiCoordinator
|
||||||
|
from .utils import get_config_value
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||||
"""Set up the weather entry."""
|
"""Set up the weather entry."""
|
||||||
|
add_services()
|
||||||
|
|
||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||||
async_add_entities(
|
async_add_entities([IrmKmiWeather(coordinator, entry)])
|
||||||
[IrmKmiWeather(coordinator, entry)]
|
|
||||||
|
|
||||||
|
def add_services() -> None:
|
||||||
|
platform = entity_platform.async_get_current_platform()
|
||||||
|
|
||||||
|
platform.async_register_entity_service(
|
||||||
|
"get_forecasts_radar",
|
||||||
|
cv.make_entity_service_schema({
|
||||||
|
vol.Optional("include_past_forecasts"): vol.Boolean()
|
||||||
|
}),
|
||||||
|
IrmKmiWeather.get_forecasts_radar_service.__name__,
|
||||||
|
supports_response=SupportsResponse.ONLY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
|
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
coordinator: IrmKmiCoordinator,
|
coordinator: IrmKmiCoordinator,
|
||||||
|
@ -38,12 +59,13 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
WeatherEntity.__init__(self)
|
WeatherEntity.__init__(self)
|
||||||
self._name = entry.title
|
self._name = entry.title
|
||||||
self._attr_unique_id = entry.entry_id
|
self._attr_unique_id = entry.entry_id
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = coordinator.shared_device_info
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
self._deprecated_forecast_as = get_config_value(entry, CONF_USE_DEPRECATED_FORECAST)
|
||||||
identifiers={(DOMAIN, entry.entry_id)},
|
|
||||||
manufacturer="IRM KMI",
|
if self._deprecated_forecast_as != OPTION_DEPRECATED_FORECAST_NOT_USED:
|
||||||
name=entry.title
|
_LOGGER.warning(f"You are using the forecast attribute for {entry.title} weather. Home Assistant deleted "
|
||||||
)
|
f"that attribute in 2024.4. Consider using the service weather.get_forecasts instead "
|
||||||
|
f"as the attribute will be delete from this integration in a future release.")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_features(self) -> WeatherEntityFeature:
|
def supported_features(self) -> WeatherEntityFeature:
|
||||||
|
@ -59,11 +81,11 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def condition(self) -> str | None:
|
def condition(self) -> str | None:
|
||||||
return self.coordinator.data.get('current_weather').get('condition')
|
return self.coordinator.data.get('current_weather', {}).get('condition')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_temperature(self) -> float | None:
|
def native_temperature(self) -> float | None:
|
||||||
return self.coordinator.data.get('current_weather').get('temperature')
|
return self.coordinator.data.get('current_weather', {}).get('temperature')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_temperature_unit(self) -> str | None:
|
def native_temperature_unit(self) -> str | None:
|
||||||
|
@ -75,15 +97,15 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_wind_speed(self) -> float | None:
|
def native_wind_speed(self) -> float | None:
|
||||||
return self.coordinator.data.get('current_weather').get('wind_speed')
|
return self.coordinator.data.get('current_weather', {}).get('wind_speed')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_wind_gust_speed(self) -> float | None:
|
def native_wind_gust_speed(self) -> float | None:
|
||||||
return self.coordinator.data.get('current_weather').get('wind_gust_speed')
|
return self.coordinator.data.get('current_weather', {}).get('wind_gust_speed')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wind_bearing(self) -> float | str | None:
|
def wind_bearing(self) -> float | str | None:
|
||||||
return self.coordinator.data.get('current_weather').get('wind_bearing')
|
return self.coordinator.data.get('current_weather', {}).get('wind_bearing')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_precipitation_unit(self) -> str | None:
|
def native_precipitation_unit(self) -> str | None:
|
||||||
|
@ -91,7 +113,7 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_pressure(self) -> float | None:
|
def native_pressure(self) -> float | None:
|
||||||
return self.coordinator.data.get('current_weather').get('pressure')
|
return self.coordinator.data.get('current_weather', {}).get('pressure')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_pressure_unit(self) -> str | None:
|
def native_pressure_unit(self) -> str | None:
|
||||||
|
@ -99,16 +121,72 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uv_index(self) -> float | None:
|
def uv_index(self) -> float | None:
|
||||||
return self.coordinator.data.get('current_weather').get('uv_index')
|
return self.coordinator.data.get('current_weather', {}).get('uv_index')
|
||||||
|
|
||||||
async def async_forecast_twice_daily(self) -> List[Forecast] | None:
|
async def async_forecast_twice_daily(self) -> List[Forecast] | None:
|
||||||
return self.coordinator.data.get('daily_forecast')
|
return self.coordinator.data.get('daily_forecast')
|
||||||
|
|
||||||
async def async_forecast_daily(self) -> list[Forecast] | None:
|
async def async_forecast_daily(self) -> list[Forecast] | None:
|
||||||
data: list[Forecast] = self.coordinator.data.get('daily_forecast')
|
return self.daily_forecast()
|
||||||
if not isinstance(data, list):
|
|
||||||
return None
|
|
||||||
return [f for f in data if f.get('is_daytime')]
|
|
||||||
|
|
||||||
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
||||||
return self.coordinator.data.get('hourly_forecast')
|
return self.coordinator.data.get('hourly_forecast')
|
||||||
|
|
||||||
|
def daily_forecast(self) -> list[Forecast] | None:
|
||||||
|
data: list[Forecast] = self.coordinator.data.get('daily_forecast')
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return None
|
||||||
|
if len(data) > 1 and not data[0].get('is_daytime') and data[1].get('native_templow') is None:
|
||||||
|
data[1]['native_templow'] = data[0].get('native_templow')
|
||||||
|
if data[1]['native_templow'] > data[1]['native_temperature']:
|
||||||
|
(data[1]['native_templow'], data[1]['native_temperature']) = \
|
||||||
|
(data[1]['native_temperature'], data[1]['native_templow'])
|
||||||
|
|
||||||
|
if len(data) > 0 and not data[0].get('is_daytime'):
|
||||||
|
return data
|
||||||
|
if len(data) > 1 and data[0].get('native_templow') is None and not data[1].get('is_daytime'):
|
||||||
|
data[0]['native_templow'] = data[1].get('native_templow')
|
||||||
|
if data[0]['native_templow'] > data[0]['native_temperature']:
|
||||||
|
(data[0]['native_templow'], data[0]['native_temperature']) = \
|
||||||
|
(data[0]['native_temperature'], data[0]['native_templow'])
|
||||||
|
|
||||||
|
return [f for f in data if f.get('is_daytime')]
|
||||||
|
|
||||||
|
def get_forecasts_radar_service(self, include_past_forecasts: bool = False) -> List[Forecast] | None:
|
||||||
|
"""
|
||||||
|
Forecast service based on data from the radar. Only contains datetime and precipitation amount.
|
||||||
|
The result always include the current 10 minutes interval, even if include_past_forecast is false.
|
||||||
|
:param include_past_forecasts: whether to include data points that are in the past
|
||||||
|
:return: ordered list of forecasts
|
||||||
|
"""
|
||||||
|
now = dt.now()
|
||||||
|
now = now.replace(minute=(now.minute // 10) * 10, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# TODO adapt the return value to match the weather.get_forecasts in next breaking change release
|
||||||
|
# return { 'forecast': [...] }
|
||||||
|
return [f for f in self.coordinator.data.get('radar_forecast')
|
||||||
|
if include_past_forecasts or datetime.fromisoformat(f.get('datetime')) >= now]
|
||||||
|
|
||||||
|
# TODO remove on next breaking changes
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""Here to keep the DEPRECATED forecast attribute.
|
||||||
|
This attribute is deprecated by Home Assistant by still implemented for compatibility
|
||||||
|
with older components. Newer components should use the service weather.get_forecasts instead.
|
||||||
|
"""
|
||||||
|
data: List[Forecast] = list()
|
||||||
|
if self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_NOT_USED:
|
||||||
|
return {}
|
||||||
|
elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_HOURLY:
|
||||||
|
data = self.coordinator.data.get('hourly_forecast')
|
||||||
|
elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_DAILY:
|
||||||
|
data = self.daily_forecast()
|
||||||
|
elif self._deprecated_forecast_as == OPTION_DEPRECATED_FORECAST_TWICE_DAILY:
|
||||||
|
data = self.coordinator.data.get('daily_forecast')
|
||||||
|
|
||||||
|
for forecast in data:
|
||||||
|
for k in list(forecast.keys()):
|
||||||
|
if k.startswith('native_'):
|
||||||
|
forecast[k[7:]] = forecast[k]
|
||||||
|
|
||||||
|
return {'forecast': data}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "IRM KMI Belgian weather",
|
"name": "IRM KMI Belgian weather",
|
||||||
"country": ["BE", "NL", "LU"],
|
"country": ["BE", "NL", "LU"],
|
||||||
"render_readme": true
|
"render_readme": true,
|
||||||
|
"homeassistant": "2024.6.0"
|
||||||
}
|
}
|
BIN
img/camera_dark.png
Normal file
After Width: | Height: | Size: 141 KiB |
BIN
img/camera_light.png
Normal file
After Width: | Height: | Size: 97 KiB |
BIN
img/camera_sat.png
Normal file
After Width: | Height: | Size: 770 KiB |
BIN
img/forecast.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
img/monday.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
img/pollens.png
Normal file
After Width: | Height: | Size: 85 KiB |
9
img/radar_example.svg
Normal file
After Width: | Height: | Size: 471 KiB |
BIN
img/sensors.png
Normal file
After Width: | Height: | Size: 32 KiB |
18
pyproject.toml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
[tool.bumpver]
|
||||||
|
current_version = "0.3.2"
|
||||||
|
version_pattern = "MAJOR.MINOR.PATCH"
|
||||||
|
commit_message = "bump version {old_version} -> {new_version}"
|
||||||
|
tag_message = "{new_version}"
|
||||||
|
tag_scope = "default"
|
||||||
|
pre_commit_hook = ""
|
||||||
|
post_commit_hook = ""
|
||||||
|
commit = true
|
||||||
|
tag = true
|
||||||
|
push = true
|
||||||
|
|
||||||
|
[tool.bumpver.file_patterns]
|
||||||
|
"pyproject.toml" = [
|
||||||
|
'current_version = "{version}"',
|
||||||
|
]
|
||||||
|
"custom_components/irm_kmi/manifest.json" = ['"version": "{version}"']
|
||||||
|
"custom_components/irm_kmi/const.py" = ["'github.com/jdejaegh/irm-kmi-ha {version}'"]
|
|
@ -1,6 +1,4 @@
|
||||||
aiohttp==3.9.1
|
aiohttp>=3.11.13
|
||||||
async_timeout==4.0.3
|
homeassistant==2025.6.1
|
||||||
homeassistant==2023.12.3
|
voluptuous==0.15.2
|
||||||
voluptuous==0.13.1
|
irm-kmi-api==0.2.0
|
||||||
Pillow==10.1.0
|
|
||||||
pytz==2023.3.post1
|
|
|
@ -1,4 +1,6 @@
|
||||||
homeassistant==2023.12.3
|
homeassistant==2025.6.1
|
||||||
pytest==7.4.3
|
pytest_homeassistant_custom_component==0.13.252
|
||||||
pytest_homeassistant_custom_component==0.13.85
|
pytest
|
||||||
freezegun==1.2.2
|
freezegun
|
||||||
|
isort
|
||||||
|
bumpver
|
|
@ -1,6 +1,5 @@
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
norecursedirs = .git
|
norecursedirs = .git
|
||||||
addopts =
|
addopts = -s -v
|
||||||
--cov=custom_components
|
|
||||||
asyncio_mode = auto
|
asyncio_mode = auto
|
|
@ -2,16 +2,34 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Generator
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Generator
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from homeassistant.const import CONF_ZONE
|
from homeassistant.const import CONF_ZONE
|
||||||
|
from irm_kmi_api.api import (IrmKmiApiClientHa, IrmKmiApiError,
|
||||||
|
IrmKmiApiParametersError)
|
||||||
|
from irm_kmi_api.data import AnimationFrameData, RadarAnimationData
|
||||||
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||||
load_fixture)
|
load_fixture)
|
||||||
|
|
||||||
from custom_components.irm_kmi.api import IrmKmiApiParametersError
|
from custom_components.irm_kmi import OPTION_STYLE_STD
|
||||||
from custom_components.irm_kmi.const import DOMAIN
|
from custom_components.irm_kmi.const import (
|
||||||
|
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST, DOMAIN, IRM_KMI_TO_HA_CONDITION_MAP,
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_data(fixture: str) -> dict:
|
||||||
|
return json.loads(load_fixture(fixture))
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_with_data(fixture: str) -> IrmKmiApiClientHa:
|
||||||
|
api = IrmKmiApiClientHa(session=MagicMock(), user_agent='', cdt_map=IRM_KMI_TO_HA_CONDITION_MAP)
|
||||||
|
api._api_data = get_api_data(fixture)
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
@ -25,7 +43,26 @@ def mock_config_entry() -> MockConfigEntry:
|
||||||
return MockConfigEntry(
|
return MockConfigEntry(
|
||||||
title="Home",
|
title="Home",
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={CONF_ZONE: "zone.home"},
|
data={CONF_ZONE: "zone.home",
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: True,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
CONF_LANGUAGE_OVERRIDE: 'none'},
|
||||||
|
unique_id="zone.home",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry_with_deprecated() -> MockConfigEntry:
|
||||||
|
"""Return the default mocked config entry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
title="Home",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={CONF_ZONE: "zone.home",
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: True,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_TWICE_DAILY,
|
||||||
|
CONF_LANGUAGE_OVERRIDE: 'none'},
|
||||||
unique_id="zone.home",
|
unique_id="zone.home",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -39,6 +76,38 @@ def mock_setup_entry() -> Generator[None, None, None]:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_forecast_in_benelux():
|
||||||
|
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux"""
|
||||||
|
with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
|
||||||
|
return_value={'cityName': 'Brussels'}):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_forecast_out_benelux():
|
||||||
|
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux"""
|
||||||
|
with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
|
||||||
|
return_value={'cityName': "Outside the Benelux (Brussels)"}):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_forecast_api_error():
|
||||||
|
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error"""
|
||||||
|
with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
|
||||||
|
side_effet=IrmKmiApiError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_get_forecast_api_error_repair():
|
||||||
|
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error"""
|
||||||
|
with patch("custom_components.irm_kmi.repairs.IrmKmiApiClient.get_forecasts_coord",
|
||||||
|
side_effet=IrmKmiApiError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||||
"""Return a mocked IrmKmi api client."""
|
"""Return a mocked IrmKmi api client."""
|
||||||
|
@ -46,7 +115,22 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
forecast = json.loads(load_fixture(fixture))
|
||||||
with patch(
|
with patch(
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||||
|
) as irm_kmi_api_mock:
|
||||||
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
|
irm_kmi.get_forecasts_coord.return_value = forecast
|
||||||
|
irm_kmi.get_radar_forecast.return_value = {}
|
||||||
|
yield irm_kmi
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_irm_kmi_api_repair_in_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||||
|
"""Return a mocked IrmKmi api client."""
|
||||||
|
fixture: str = "forecast.json"
|
||||||
|
|
||||||
|
forecast = json.loads(load_fixture(fixture))
|
||||||
|
with patch(
|
||||||
|
"custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True
|
||||||
) as irm_kmi_api_mock:
|
) as irm_kmi_api_mock:
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
irm_kmi.get_forecasts_coord.return_value = forecast
|
||||||
|
@ -54,14 +138,13 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def mock_irm_kmi_api_out_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||||
"""Return a mocked IrmKmi api client."""
|
"""Return a mocked IrmKmi api client."""
|
||||||
fixture: str = "forecast_out_of_benelux.json"
|
fixture: str = "forecast_out_of_benelux.json"
|
||||||
|
|
||||||
forecast = json.loads(load_fixture(fixture))
|
forecast = json.loads(load_fixture(fixture))
|
||||||
print(type(forecast))
|
|
||||||
with patch(
|
with patch(
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
"custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True
|
||||||
) as irm_kmi_api_mock:
|
) as irm_kmi_api_mock:
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
irm_kmi.get_forecasts_coord.return_value = forecast
|
irm_kmi.get_forecasts_coord.return_value = forecast
|
||||||
|
@ -72,46 +155,34 @@ def mock_irm_kmi_api_out_benelux(request: pytest.FixtureRequest) -> Generator[No
|
||||||
def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
def mock_exception_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||||
"""Return a mocked IrmKmi api client."""
|
"""Return a mocked IrmKmi api client."""
|
||||||
with patch(
|
with patch(
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
"custom_components.irm_kmi.coordinator.IrmKmiApiClientHa", autospec=True
|
||||||
) as irm_kmi_api_mock:
|
) as irm_kmi_api_mock:
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
irm_kmi = irm_kmi_api_mock.return_value
|
||||||
irm_kmi.get_forecasts_coord.side_effect = IrmKmiApiParametersError
|
irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiParametersError
|
||||||
yield irm_kmi
|
yield irm_kmi
|
||||||
|
|
||||||
|
def get_radar_animation_data() -> RadarAnimationData:
|
||||||
|
with open("tests/fixtures/clouds_be.png", "rb") as file:
|
||||||
|
image_data = file.read()
|
||||||
|
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
|
||||||
|
location = file.read()
|
||||||
|
|
||||||
@pytest.fixture()
|
sequence = [
|
||||||
def mock_image_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
AnimationFrameData(
|
||||||
"""Return a mocked IrmKmi api client."""
|
time=datetime.fromisoformat("2023-12-26T18:30:00+00:00") + timedelta(minutes=10 * i),
|
||||||
|
image=image_data,
|
||||||
|
value=2,
|
||||||
|
position=.5,
|
||||||
|
position_lower=.4,
|
||||||
|
position_higher=.6
|
||||||
|
)
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
|
||||||
async def patched(url: str, params: dict | None = None) -> bytes:
|
return RadarAnimationData(
|
||||||
if "cdn.knmi.nl" in url:
|
sequence=sequence,
|
||||||
file_name = "tests/fixtures/clouds_nl.png"
|
most_recent_image_idx=2,
|
||||||
elif "app.meteo.be/services/appv4/?s=getIncaImage" in url:
|
hint="Testing SVG camera",
|
||||||
file_name = "tests/fixtures/clouds_be.png"
|
unit="mm/10min",
|
||||||
elif "getLocalizationLayerBE" in url:
|
location=location
|
||||||
file_name = "tests/fixtures/loc_layer_be_n.png"
|
)
|
||||||
elif "getLocalizationLayerNL" in url:
|
|
||||||
file_name = "tests/fixtures/loc_layer_nl_d.png"
|
|
||||||
else:
|
|
||||||
raise ValueError("Not a valid parameter for the mock")
|
|
||||||
|
|
||||||
with open(file_name, "rb") as file:
|
|
||||||
return file.read()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
|
|
||||||
) as irm_kmi_api_mock:
|
|
||||||
irm_kmi = irm_kmi_api_mock.return_value
|
|
||||||
irm_kmi.get_image.side_effect = patched
|
|
||||||
yield irm_kmi
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_coordinator(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
|
||||||
"""Return a mocked coordinator."""
|
|
||||||
with patch(
|
|
||||||
"custom_components.irm_kmi.IrmKmiCoordinator", autospec=True
|
|
||||||
) as coordinator_mock:
|
|
||||||
coord = coordinator_mock.return_value
|
|
||||||
coord._async_animation_data.return_value = {'animation': None}
|
|
||||||
yield coord
|
|
||||||
|
|
1668
tests/fixtures/be_forecast_warning.json
vendored
Normal file
13
tests/fixtures/forecast.json
vendored
|
@ -66,7 +66,8 @@
|
||||||
"dayNight": "d",
|
"dayNight": "d",
|
||||||
"text": {
|
"text": {
|
||||||
"nl": "Foo",
|
"nl": "Foo",
|
||||||
"fr": "Bar"
|
"fr": "Bar",
|
||||||
|
"en": "Hey!"
|
||||||
},
|
},
|
||||||
"dawnRiseSeconds": "31440",
|
"dawnRiseSeconds": "31440",
|
||||||
"dawnSetSeconds": "60180",
|
"dawnSetSeconds": "60180",
|
||||||
|
@ -1407,7 +1408,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-26T17:40:00+01:00",
|
"time": "2023-12-26T17:40:00+01:00",
|
||||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261650&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261650&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
||||||
"value": 0,
|
"value": 0.1,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
@ -1415,7 +1416,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-26T17:50:00+01:00",
|
"time": "2023-12-26T17:50:00+01:00",
|
||||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261700&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261700&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
||||||
"value": 0,
|
"value": 0.01,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
@ -1423,7 +1424,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-26T18:00:00+01:00",
|
"time": "2023-12-26T18:00:00+01:00",
|
||||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261710&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261710&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
||||||
"value": 0,
|
"value": 0.12,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
@ -1431,7 +1432,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-26T18:10:00+01:00",
|
"time": "2023-12-26T18:10:00+01:00",
|
||||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261720&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261720&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
||||||
"value": 0,
|
"value": 1.2,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
@ -1439,7 +1440,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-26T18:20:00+01:00",
|
"time": "2023-12-26T18:20:00+01:00",
|
||||||
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261730&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261730&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
|
||||||
"value": 0,
|
"value": 2,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
|
1676
tests/fixtures/forecast_ams_no_ww.json
vendored
Normal file
4
tests/fixtures/forecast_nl.json
vendored
|
@ -6,7 +6,7 @@
|
||||||
"municipality_code": "0995",
|
"municipality_code": "0995",
|
||||||
"temp": 11,
|
"temp": 11,
|
||||||
"windSpeedKm": 40,
|
"windSpeedKm": 40,
|
||||||
"timestamp": "2023-12-28T14:20:00+00:00",
|
"timestamp": "2023-12-28T14:30:00+00:00",
|
||||||
"windDirection": 45,
|
"windDirection": 45,
|
||||||
"municipality": "Lelystad",
|
"municipality": "Lelystad",
|
||||||
"windDirectionText": {
|
"windDirectionText": {
|
||||||
|
@ -1337,7 +1337,7 @@
|
||||||
{
|
{
|
||||||
"time": "2023-12-28T14:25:00+00:00",
|
"time": "2023-12-28T14:25:00+00:00",
|
||||||
"uri": "https:\/\/cdn.knmi.nl\/knmi\/map\/page\/weer\/actueel-weer\/neerslagradar\/weerapp\/RAD_NL25_PCP_CM_202312281425_640.png",
|
"uri": "https:\/\/cdn.knmi.nl\/knmi\/map\/page\/weer\/actueel-weer\/neerslagradar\/weerapp\/RAD_NL25_PCP_CM_202312281425_640.png",
|
||||||
"value": 0,
|
"value": 0.15,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
"positionLower": 0,
|
"positionLower": 0,
|
||||||
"positionHigher": 0
|
"positionHigher": 0
|
||||||
|
|
1532
tests/fixtures/forecast_with_rain_on_radar.json
vendored
Normal file
712
tests/fixtures/getWarnings-all-countries.json
vendored
Normal file
|
@ -0,0 +1,712 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"icon_country": "BE",
|
||||||
|
"warningType": {
|
||||||
|
"id": "2",
|
||||||
|
"name": {
|
||||||
|
"nl": "Gladheid",
|
||||||
|
"fr": "Conditions glissantes",
|
||||||
|
"de": "Gl\u00e4tte",
|
||||||
|
"en": "Ice or snow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"nl": "Vanochtend vriest het op de meeste plaatsen. Na de neerslag van gisteren zijn de wegen nog voldoende vochtig en ligt er nog plaatselijk sneeuw die kan bevriezen en eveneens voor gladde plekken zorgen.\n",
|
||||||
|
"fr": "Ce matin, le gel concernera la plupart des r\u00e9gions. Le risque de plaque de glace sera assez \u00e9lev\u00e9 avec de nombreuses routes glissantes.\n"
|
||||||
|
},
|
||||||
|
"legendUri": {
|
||||||
|
"nl": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=nl",
|
||||||
|
"fr": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=fr",
|
||||||
|
"de": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=de",
|
||||||
|
"en": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=2&l=en"
|
||||||
|
},
|
||||||
|
"region": [
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"nl": "Brussel",
|
||||||
|
"fr": "Bruxelles",
|
||||||
|
"en": "Brussels",
|
||||||
|
"de": "Br\u00fcssel"
|
||||||
|
},
|
||||||
|
"district": [
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6446,
|
||||||
|
"name": {
|
||||||
|
"nl": "Brussel",
|
||||||
|
"fr": "Bruxelles",
|
||||||
|
"en": "Brussels",
|
||||||
|
"de": "Br\u00fcssel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"nl": "Vlaanderen",
|
||||||
|
"fr": "Flandre",
|
||||||
|
"en": "Flanders",
|
||||||
|
"de": "Flandern"
|
||||||
|
},
|
||||||
|
"district": [
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6404,
|
||||||
|
"name": {
|
||||||
|
"nl": "Kust",
|
||||||
|
"fr": "C\u00f4te",
|
||||||
|
"en": "Coast",
|
||||||
|
"de": "K\u00fcste"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6407,
|
||||||
|
"name": {
|
||||||
|
"nl": "West-Vlaanderen",
|
||||||
|
"fr": "Flandre-Occidentale",
|
||||||
|
"en": "West Flanders",
|
||||||
|
"de": "Westflandern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6431,
|
||||||
|
"name": {
|
||||||
|
"nl": "Oost-Vlaanderen",
|
||||||
|
"fr": "Flandre-Orientale",
|
||||||
|
"en": "East Flanders",
|
||||||
|
"de": "Ostflandern"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6450,
|
||||||
|
"name": {
|
||||||
|
"nl": "Antwerpen",
|
||||||
|
"fr": "Anvers",
|
||||||
|
"en": "Antwerp",
|
||||||
|
"de": "Antwerpen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6479,
|
||||||
|
"name": {
|
||||||
|
"nl": "Limburg",
|
||||||
|
"fr": "Limbourg",
|
||||||
|
"en": "Limburg",
|
||||||
|
"de": "Limburg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6446,
|
||||||
|
"name": {
|
||||||
|
"nl": "Vlaams-Brabant",
|
||||||
|
"fr": "Brabant flamand",
|
||||||
|
"en": "Flemish Brabant",
|
||||||
|
"de": "Fl\u00e4misch-Brabant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"nl": "Walloni\u00eb",
|
||||||
|
"fr": "Wallonie",
|
||||||
|
"en": "Wallonia",
|
||||||
|
"de": "Wallonien"
|
||||||
|
},
|
||||||
|
"district": [
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6478,
|
||||||
|
"name": {
|
||||||
|
"nl": "Luik",
|
||||||
|
"fr": "Li\u00e8ge",
|
||||||
|
"en": "Liege",
|
||||||
|
"de": "L\u00fcttich"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6432,
|
||||||
|
"name": {
|
||||||
|
"nl": "Henegouwen",
|
||||||
|
"fr": "Hainaut",
|
||||||
|
"en": "Hainaut",
|
||||||
|
"de": "Hennegau"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6456,
|
||||||
|
"name": {
|
||||||
|
"nl": "Namen",
|
||||||
|
"fr": "Namur",
|
||||||
|
"en": "Namur",
|
||||||
|
"de": "Nam\u00fcr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6476,
|
||||||
|
"name": {
|
||||||
|
"nl": "Luxemburg",
|
||||||
|
"fr": "Luxembourg",
|
||||||
|
"en": "Luxembourg",
|
||||||
|
"de": "Luxemburg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 6446,
|
||||||
|
"name": {
|
||||||
|
"nl": "Waals-Brabant",
|
||||||
|
"fr": "Brabant wallon",
|
||||||
|
"en": "Walloon Brabant",
|
||||||
|
"de": "Wallonisch-Brabant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T05:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_country": "NL",
|
||||||
|
"warningType": {
|
||||||
|
"id": "2",
|
||||||
|
"name": {
|
||||||
|
"nl": "Sneeuw en gladheid",
|
||||||
|
"fr": "Neige ou verglas",
|
||||||
|
"en": "Ice or snow",
|
||||||
|
"de": "Gl\u00e4tte"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fr": "Vanochtend op veel plaatsen glad\n\nVanochtend is het op veel plaatsen glad door bevriezing van natte weggedeelten. In het noorden ook door een enkele (winterse) bui met mogelijk wat ijzel. Vanaf ongeveer 10 uur verdwijnt de gladheid.\n\nEr is kans op ongelukken door gladde bruggen, wegen, fietspaden en voetpaden.\n\n \n\n Meer details \nTot ca. 10 uur is het op veel plaatsen glad door bevriezing (verraderlijk) glad. In het noorden komen er ook enkele (winterse) buien voor met een kleine kans op ijzel (regen op een bevroren ondergrond). Na ongeveer 10 uur verdwijnt de gladheid.\nUitgifte: 10\/01\/2025 07:52 uur LT",
|
||||||
|
"nl": "Vanochtend op veel plaatsen glad\n\nVanochtend is het op veel plaatsen glad door bevriezing van natte weggedeelten. In het noorden ook door een enkele (winterse) bui met mogelijk wat ijzel. Vanaf ongeveer 10 uur verdwijnt de gladheid.\n\nEr is kans op ongelukken door gladde bruggen, wegen, fietspaden en voetpaden.\n\n \n\n Meer details \nTot ca. 10 uur is het op veel plaatsen glad door bevriezing (verraderlijk) glad. In het noorden komen er ook enkele (winterse) buien voor met een kleine kans op ijzel (regen op een bevroren ondergrond). Na ongeveer 10 uur verdwijnt de gladheid.\nUitgifte: 10\/01\/2025 07:52 uur LT"
|
||||||
|
},
|
||||||
|
"legendUri": {
|
||||||
|
"nl": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||||
|
"fr": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||||
|
"de": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen",
|
||||||
|
"en": "https:\/\/www.knmi.nl\/nederland-nu\/weer\/waarschuwingen"
|
||||||
|
},
|
||||||
|
"region": [
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"nl": "Nederland",
|
||||||
|
"fr": "Pays-Bas",
|
||||||
|
"en": "Netherlands",
|
||||||
|
"de": "Niederlande"
|
||||||
|
},
|
||||||
|
"district": [
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200009",
|
||||||
|
"name": {
|
||||||
|
"nl": "Drenthe",
|
||||||
|
"fr": "Drenthe",
|
||||||
|
"en": "Drenthe",
|
||||||
|
"de": "Drenthe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200001",
|
||||||
|
"name": {
|
||||||
|
"nl": "Flevoland",
|
||||||
|
"fr": "Flevoland",
|
||||||
|
"en": "Flevoland",
|
||||||
|
"de": "Flevoland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200011",
|
||||||
|
"name": {
|
||||||
|
"nl": "Friesland",
|
||||||
|
"fr": "Friesland",
|
||||||
|
"en": "Friesland",
|
||||||
|
"de": "Friesland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200005",
|
||||||
|
"name": {
|
||||||
|
"nl": "Gelderland",
|
||||||
|
"fr": "Gelderland",
|
||||||
|
"en": "Gelderland",
|
||||||
|
"de": "Gelderland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200010",
|
||||||
|
"name": {
|
||||||
|
"nl": "Groningen",
|
||||||
|
"fr": "Groningen",
|
||||||
|
"en": "Groningen",
|
||||||
|
"de": "Groningen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 200014,
|
||||||
|
"name": {
|
||||||
|
"nl": "IJsselmeergebied",
|
||||||
|
"fr": "IJsselmeergebied",
|
||||||
|
"en": "IJsselmeergebied",
|
||||||
|
"de": "IJsselmeergebied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": 0,
|
||||||
|
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||||
|
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200007",
|
||||||
|
"name": {
|
||||||
|
"nl": "Limburg",
|
||||||
|
"fr": "Limburg",
|
||||||
|
"en": "Limburg",
|
||||||
|
"de": "Limburg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T11:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T17:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200004",
|
||||||
|
"name": {
|
||||||
|
"nl": "Noord-Brabant",
|
||||||
|
"fr": "Noord-Brabant",
|
||||||
|
"en": "Noord-Brabant",
|
||||||
|
"de": "Noord-Brabant"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200000",
|
||||||
|
"name": {
|
||||||
|
"nl": "Noord-Holland",
|
||||||
|
"fr": "Noord-Holland",
|
||||||
|
"en": "Noord-Holland",
|
||||||
|
"de": "Noord-Holland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200008",
|
||||||
|
"name": {
|
||||||
|
"nl": "Overijssel",
|
||||||
|
"fr": "Overijssel",
|
||||||
|
"en": "Overijssel",
|
||||||
|
"de": "Overijssel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200002",
|
||||||
|
"name": {
|
||||||
|
"nl": "Utrecht",
|
||||||
|
"fr": "Utrecht",
|
||||||
|
"en": "Utrecht",
|
||||||
|
"de": "Utrecht"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 200012,
|
||||||
|
"name": {
|
||||||
|
"nl": "Waddeneilanden",
|
||||||
|
"fr": "Waddeneilanden",
|
||||||
|
"en": "Waddeneilanden",
|
||||||
|
"de": "Waddeneilanden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": 0,
|
||||||
|
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||||
|
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": 200013,
|
||||||
|
"name": {
|
||||||
|
"nl": "Waddenzee",
|
||||||
|
"fr": "Waddenzee",
|
||||||
|
"en": "Waddenzee",
|
||||||
|
"de": "Waddenzee"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": 0,
|
||||||
|
"fromTimestamp": "2024-12-31T00:00:00+01:00",
|
||||||
|
"toTimestamp": "2025-01-03T00:00:00+01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200006",
|
||||||
|
"name": {
|
||||||
|
"nl": "Zeeland",
|
||||||
|
"fr": "Zeeland",
|
||||||
|
"en": "Zeeland",
|
||||||
|
"de": "Zeeland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "200003",
|
||||||
|
"name": {
|
||||||
|
"nl": "Zuid-Holland",
|
||||||
|
"fr": "Zuid-Holland",
|
||||||
|
"en": "Zuid-Holland",
|
||||||
|
"de": "Zuid-Holland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T06:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T10:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-10T22:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_country": "LU",
|
||||||
|
"warningType": {
|
||||||
|
"id": "15",
|
||||||
|
"name": {
|
||||||
|
"nl": "Overstroming",
|
||||||
|
"fr": "Crue",
|
||||||
|
"en": "Flood",
|
||||||
|
"de": "Hochwasser"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fr": "vendredi 00:00 \u00e0\u00a0vendredi 17:59, pour le nord du pays: Crue mineure pouvant entra\u00eener des inondations et dommages locaux. Vigilance particuli\u00e8re dans le cas d\u2019activit\u00e9s saisonni\u00e8res et\/ou expos\u00e9es. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) vendredi 00:00 \u00e0\u00a0samedi 19:59, pour le sud du pays: Crue mineure pouvant entra\u00eener des inondations et dommages locaux. Vigilance particuli\u00e8re dans le cas d\u2019activit\u00e9s saisonni\u00e8res et\/ou expos\u00e9es. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) samedi 20:00 \u00e0\u00a0dimanche 06:59, pour le sud du pays: Situation m\u00e9t\u00e9orologique indiquant un risque \u00e9ventuel de crue. Soyez vigilant. Plus d'informations sur www.inondations.lu. (* \u00e9mis par l'Administration de la Gestion de l'Eau) (MeteoLux - www.meteolux.lu)",
|
||||||
|
"nl": "",
|
||||||
|
"de": "Freitag 00:00 bis\u00a0Freitag 17:59, f\u00fcr den Norden des Landes: Geringes Hochwasser, welches lokal zu \u00dcberschwemmungen und Sch\u00e4den f\u00fchren kann. Bei Aktivit\u00e4ten in betroffenen Gebieten ist Vorsicht geboten. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) Freitag 00:00 bis\u00a0Samstag 19:59, f\u00fcr den S\u00fcden des Landes: Geringes Hochwasser, welches lokal zu \u00dcberschwemmungen und Sch\u00e4den f\u00fchren kann. Bei Aktivit\u00e4ten in betroffenen Gebieten ist Vorsicht geboten. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) Samstag 20:00 bis\u00a0Sonntag 06:59, f\u00fcr den S\u00fcden des Landes: Die meteorologische Situation deutet auf eine m\u00f6gliche \u00dcberschwemmungsgefahr hin. Bleiben Sie wachsam. Mehr Informationen auf www.inondations.lu. (* ausgegeben vom Wasserwirtschaftsamt) (MeteoLux - www.meteolux.lu)"
|
||||||
|
},
|
||||||
|
"legendUri": {
|
||||||
|
"nl": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=nl",
|
||||||
|
"fr": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=fr",
|
||||||
|
"de": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=de",
|
||||||
|
"en": "https:\/\/app.meteo.be\/services\/appv4\/?s=getWarningLegend&k=39fd3df3c7e9ba2bd6dabddf23c7a352&wa=15&l=en"
|
||||||
|
},
|
||||||
|
"region": [
|
||||||
|
{
|
||||||
|
"name": {
|
||||||
|
"nl": "Groothertogdom Luxemburg",
|
||||||
|
"fr": "Grand-Duch\u00e9 de Luxembourg",
|
||||||
|
"en": "Grand Duchy of Luxembourg",
|
||||||
|
"de": "Gro\u00dfherzogtum Luxemburg"
|
||||||
|
},
|
||||||
|
"district": [
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "6590",
|
||||||
|
"name": {
|
||||||
|
"nl": "Luxemburg Zuid",
|
||||||
|
"fr": "Luxembourg Sud",
|
||||||
|
"en": "Luxembourg South",
|
||||||
|
"de": "Luxemburg S\u00fcd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "1",
|
||||||
|
"fromTimestamp": "2025-01-11T19:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-12T06:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "2",
|
||||||
|
"fromTimestamp": "2025-01-09T23:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-11T19:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"region": {
|
||||||
|
"code": "6585",
|
||||||
|
"name": {
|
||||||
|
"nl": "Luxemburg Noord",
|
||||||
|
"fr": "Luxembourg Nord",
|
||||||
|
"en": "Luxembourg North",
|
||||||
|
"de": "Luxemburg Nord"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intervals": [
|
||||||
|
{
|
||||||
|
"level": "2",
|
||||||
|
"fromTimestamp": "2025-01-09T23:00:00+00:00",
|
||||||
|
"toTimestamp": "2025-01-10T17:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
1635
tests/fixtures/high_low_temp.json
vendored
Normal file
BIN
tests/fixtures/loc_layer_nl.png
vendored
Normal file
After Width: | Height: | Size: 1.8 KiB |
1632
tests/fixtures/midnight-bug-31-05-2024T00-13.json
vendored
Normal file
56
tests/fixtures/new_two_pollens.svg
vendored
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="35 88 129.0 50.0" version="1.1" id="svg740"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs id="defs737"/>
|
||||||
|
<g id="layer1">
|
||||||
|
<rect id="rectangle-white" x="35" y="88" width="129.0" height="50.0" rx="5" fill="white" fill-opacity="0.15"/>
|
||||||
|
<g id="g1495" transform="translate(30.342966,94.25)">
|
||||||
|
<g id="layer1-2">
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
|
||||||
|
rx="1.6348698" ry="1.5258785"/>
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
|
||||||
|
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
|
||||||
|
height="3.5655735" x="12.923257" y="1.401318"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
|
||||||
|
height="2.5535111" x="15.866023" y="2.36669"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"></tspan></text>
|
||||||
|
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
|
||||||
|
x="35.451504" y="105.5"/>
|
||||||
|
<g transform="translate(36.0,120.0) scale(0.4,0.4)">
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.833 30.1555C141.065 22.499 137.677 15.3011 132.188 9.81192C125.906 3.52946 117.385 6.7078e-07 108.5 0C99.6153 -6.7078e-07 91.0944 3.52945 84.8119 9.81192C79.3228 15.3011 75.9352 22.499 75.1673 30.1555C74.9826 31.9964 76.4998 33.5 78.35 33.5V33.5C80.2002 33.5 81.6784 31.9943 81.909 30.1586C82.6472 24.2828 85.3177 18.7814 89.5495 14.5495C94.5755 9.52356 101.392 6.7 108.5 6.7C115.608 6.7 122.424 9.52356 127.45 14.5495C131.682 18.7814 134.353 24.2828 135.091 30.1586C135.322 31.9943 136.8 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#70AC48" id="path12394"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.242 24.271 139.101 18.6259 135.602 13.8092C131.443 8.0858 125.58 3.82575 118.852 1.63961C112.123 -0.546536 104.876 -0.546535 98.1475 1.63961C91.4192 3.82575 85.5558 8.0858 81.3975 13.8092L86.8179 17.7474C90.1445 13.1686 94.8353 9.7606 100.218 8.01168C105.6 6.26277 111.399 6.26277 116.781 8.01168C122.164 9.7606 126.855 13.1686 130.181 17.7474C132.849 21.4187 134.529 25.6916 135.09 30.1586C135.321 31.9943 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#FED966" id="path12396"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.418 26.0285 140.24 22.0042 138.348 18.2913C135.948 13.5809 132.467 9.50535 128.19 6.39793C123.913 3.29052 118.962 1.23946 113.74 0.412444C108.519 -0.414569 103.175 0.00594664 98.1475 1.63961L100.218 8.01169C104.24 6.70476 108.515 6.36834 112.692 7.02995C116.869 7.69157 120.831 9.33242 124.252 11.8183C127.674 14.3043 130.458 17.5647 132.379 21.3331C133.79 24.1034 134.705 27.0904 135.09 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#EE7D31" id="path12398"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9965 141.832 30.1555C141.242 24.271 139.101 18.626 135.602 13.8092C131.443 8.08584 125.58 3.82579 118.852 1.63965L116.781 8.01173C122.164 9.76064 126.855 13.1687 130.181 17.7474C132.849 21.4188 134.529 25.6917 135.091 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#C00000" id="path12400"/>
|
||||||
|
<path d="M138.65 33.4999C140.5 33.4999 142.017 31.9963 141.833 30.1554C141.242 24.2709 139.102 18.6258 135.602 13.8091L130.182 17.7472C132.849 21.4186 134.53 25.6915 135.091 30.1585C135.322 31.9942 136.8 33.4999 138.65 33.4999V33.4999Z"
|
||||||
|
fill="#70309F" id="path12402"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.833 30.1555C242.065 22.499 238.677 15.3011 233.188 9.81192C226.906 3.52946 218.385 6.7078e-07 209.5 0C200.615 -6.7078e-07 192.094 3.52945 185.812 9.81192C180.323 15.3011 176.935 22.499 176.167 30.1555C175.983 31.9964 177.5 33.5 179.35 33.5V33.5C181.2 33.5 182.678 31.9943 182.909 30.1586C183.647 24.2828 186.318 18.7814 190.55 14.5495C195.576 9.52356 202.392 6.7 209.5 6.7C216.608 6.7 223.424 9.52356 228.45 14.5495C232.682 18.7814 235.353 24.2828 236.091 30.1586C236.322 31.9943 237.8 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12404"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.242 24.271 240.101 18.6259 236.602 13.8092C232.443 8.0858 226.58 3.82575 219.852 1.63961C213.123 -0.546536 205.876 -0.546535 199.147 1.63961C192.419 3.82575 186.556 8.0858 182.397 13.8092L187.818 17.7474C191.145 13.1686 195.835 9.7606 201.218 8.01168C206.6 6.26277 212.399 6.26277 217.781 8.01168C223.164 9.7606 227.855 13.1686 231.181 17.7474C233.849 21.4187 235.529 25.6916 236.09 30.1586C236.321 31.9943 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12406"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.418 26.0285 241.24 22.0042 239.348 18.2913C236.948 13.5809 233.467 9.50535 229.19 6.39793C224.913 3.29052 219.962 1.23946 214.74 0.412444C209.519 -0.414569 204.175 0.00594664 199.147 1.63961L201.218 8.01169C205.24 6.70476 209.515 6.36834 213.692 7.02995C217.869 7.69157 221.831 9.33242 225.252 11.8183C228.674 14.3043 231.458 17.5647 233.379 21.3331C234.79 24.1034 235.705 27.0904 236.09 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12408"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9965 242.832 30.1555C242.242 24.271 240.101 18.626 236.602 13.8092C232.443 8.08584 226.58 3.82579 219.852 1.63965L217.781 8.01173C223.164 9.76064 227.855 13.1687 231.181 17.7474C233.849 21.4188 235.529 25.6917 236.091 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12410"/>
|
||||||
|
<path d="M239.65 33.4999C241.5 33.4999 243.017 31.9963 242.833 30.1554C242.242 24.2709 240.102 18.6258 236.602 13.8091L231.182 17.7472C233.849 21.4186 235.53 25.6915 236.091 30.1585C236.322 31.9942 237.8 33.4999 239.65 33.4999V33.4999Z"
|
||||||
|
fill="#cccccc" id="path12412"/>
|
||||||
|
<text xml:space="preserve" id="text12907"><tspan id="tspan12905" x="109.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> high</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13637"><tspan id="tspan13635" x="209.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> active</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13641"><tspan id="tspan13639" x="109.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Grasses</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13645"><tspan id="tspan13643" x="209.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Mugwort</tspan></text>
|
||||||
|
<circle style="fill:#ffffff" id="cursor1" cx="126.92745019492043" cy="9.024981671564106" r="4.0"/>
|
||||||
|
<circle style="fill:#ffffff" id="cursor2" cx="-99999999" cy="-99999999" r="4.0"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 7.8 KiB |
1625
tests/fixtures/no-midnight-bug-31-05-2024T01-55.json
vendored
Normal file
42
tests/fixtures/pollen.svg
vendored
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="35 88 129.0 50.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs id="defs737"/>
|
||||||
|
<g id="layer1">
|
||||||
|
<rect id="rectangle-white" x="35" y="88" width="129.0" height="50.0" rx="5" fill="white" fill-opacity="0.15"/>
|
||||||
|
<g id="g1495" transform="translate(30.342966,94.25)">
|
||||||
|
<g id="layer1-2">
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
|
||||||
|
rx="1.6348698" ry="1.5258785"/>
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
|
||||||
|
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
|
||||||
|
height="3.5655735" x="12.923257" y="1.401318"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
|
||||||
|
height="2.5535111" x="15.866023" y="2.36669"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"/></text>
|
||||||
|
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
|
||||||
|
x="35.451504" y="105.5"/>
|
||||||
|
<g transform="translate(36.0,120.0) scale(0.4,0.4)">
|
||||||
|
<path d="M188.65 33.5C190.5 33.5 192.017 31.9964 191.833 30.1555C191.065 22.499 187.677 15.3011 182.188 9.81192C175.906 3.52946 167.385 6.7078e-07 158.5 0C149.615 -6.7078e-07 141.094 3.52945 134.812 9.81192C129.323 15.3011 125.935 22.499 125.167 30.1555C124.983 31.9964 126.5 33.5 128.35 33.5V33.5C130.2 33.5 131.678 31.9943 131.909 30.1586C132.647 24.2828 135.318 18.7814 139.55 14.5495C144.576 9.52356 151.392 6.7 158.5 6.7C165.608 6.7 172.424 9.52356 177.45 14.5495C181.682 18.7814 184.353 24.2828 185.091 30.1586C185.322 31.9943 186.8 33.5 188.65 33.5V33.5Z"
|
||||||
|
fill="#70AC48" id="path1982"/>
|
||||||
|
<path d="M188.65 33.5C190.5 33.5 192.017 31.9964 191.832 30.1555C191.242 24.271 189.101 18.6259 185.602 13.8092C181.443 8.0858 175.58 3.82575 168.852 1.63961C162.123 -0.546536 154.876 -0.546535 148.147 1.63961C141.419 3.82575 135.556 8.0858 131.397 13.8092L136.818 17.7474C140.145 13.1686 144.835 9.7606 150.218 8.01168C155.6 6.26277 161.399 6.26277 166.781 8.01168C172.164 9.7606 176.855 13.1686 180.181 17.7474C182.849 21.4187 184.529 25.6916 185.09 30.1586C185.321 31.9943 186.799 33.5 188.65 33.5V33.5Z"
|
||||||
|
fill="#FED966" id="path1984"/>
|
||||||
|
<path d="M188.65 33.5C190.5 33.5 192.017 31.9964 191.832 30.1555C191.418 26.0285 190.24 22.0042 188.348 18.2913C185.948 13.5809 182.467 9.50535 178.19 6.39793C173.913 3.29052 168.962 1.23946 163.74 0.412444C158.519 -0.414569 153.175 0.00594664 148.147 1.63961L150.218 8.01169C154.24 6.70476 158.515 6.36834 162.692 7.02995C166.869 7.69157 170.831 9.33242 174.252 11.8183C177.674 14.3043 180.458 17.5647 182.379 21.3331C183.79 24.1034 184.705 27.0904 185.09 30.1587C185.321 31.9944 186.799 33.5 188.65 33.5V33.5Z"
|
||||||
|
fill="#EE7D31" id="path1986"/>
|
||||||
|
<path d="M188.65 33.5C190.5 33.5 192.017 31.9965 191.832 30.1555C191.242 24.271 189.101 18.626 185.602 13.8092C181.443 8.08584 175.58 3.82579 168.852 1.63965L166.781 8.01173C172.164 9.76064 176.855 13.1687 180.181 17.7474C182.849 21.4188 184.529 25.6917 185.091 30.1587C185.321 31.9944 186.799 33.5 188.65 33.5V33.5Z"
|
||||||
|
fill="#C00000" id="path1988"/>
|
||||||
|
<path d="M188.65 33.4999C190.5 33.4999 192.017 31.9963 191.833 30.1554C191.242 24.2709 189.102 18.6258 185.602 13.8091L180.182 17.7472C182.849 21.4186 184.53 25.6915 185.091 30.1585C185.322 31.9942 186.8 33.4999 188.65 33.4999V33.4999Z"
|
||||||
|
fill="#70309F" id="path1990"/>
|
||||||
|
<text xml:space="preserve" id="text4352"><tspan id="tspan4350" x="158.5" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc;fill-opacity:1"> very high</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text4458"><tspan id="tspan4456" x="158.5" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff;fill-opacity:1"> Grasses</tspan></text>
|
||||||
|
<circle style="fill:#ffffff" id="cursor1" cx="187.50722374700217" cy="24.274981671564106" r="4.0"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 4.9 KiB |
55
tests/fixtures/pollens-2025.svg
vendored
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg viewBox="35 88 129.0 50.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs id="defs737"/>
|
||||||
|
<g id="layer1">
|
||||||
|
<rect id="rectangle-white" x="35" y="88" width="129.0" height="50.0" rx="5" fill="grey" fill-opacity="1"/>
|
||||||
|
<g id="g1495" transform="translate(30.342966,94.25)">
|
||||||
|
<g id="layer1-2">
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path268" cx="13.312511" cy="0.0"
|
||||||
|
rx="1.6348698" ry="1.5258785"/>
|
||||||
|
<ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path272" cx="16.208567"
|
||||||
|
cy="1.463598" rx="1.1366237" ry="1.1366239"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect326" width="0.79407966"
|
||||||
|
height="3.5655735" x="12.923257" y="1.401318"/>
|
||||||
|
<rect style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="rect328" width="0.68508834"
|
||||||
|
height="2.5535111" x="15.866023" y="2.36669"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="54.476421" y="98.75" id="text228"><tspan id="tspan226" style="fill:#ffffff;stroke-width:0.264583" x="54.476421" y="98.75">Active pollen</tspan></text>
|
||||||
|
<text xml:space="preserve"
|
||||||
|
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
|
||||||
|
x="139.37601" y="98.75" id="text334"><tspan id="tspan332" style="stroke-width:0.264583" x="139.37601" y="98.75"/></text>
|
||||||
|
<rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145"
|
||||||
|
x="35.451504" y="105.5"/>
|
||||||
|
<g transform="translate(36.0,120.0) scale(0.4,0.4)">
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.833 30.1555C141.065 22.499 137.677 15.3011 132.188 9.81192C125.906 3.52946 117.385 6.7078e-07 108.5 0C99.6153 -6.7078e-07 91.0944 3.52945 84.8119 9.81192C79.3228 15.3011 75.9352 22.499 75.1673 30.1555C74.9826 31.9964 76.4998 33.5 78.35 33.5V33.5C80.2002 33.5 81.6784 31.9943 81.909 30.1586C82.6472 24.2828 85.3177 18.7814 89.5495 14.5495C94.5755 9.52356 101.392 6.7 108.5 6.7C115.608 6.7 122.424 9.52356 127.45 14.5495C131.682 18.7814 134.353 24.2828 135.091 30.1586C135.322 31.9943 136.8 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#70AC48" id="path12394"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.242 24.271 139.101 18.6259 135.602 13.8092C131.443 8.0858 125.58 3.82575 118.852 1.63961C112.123 -0.546536 104.876 -0.546535 98.1475 1.63961C91.4192 3.82575 85.5558 8.0858 81.3975 13.8092L86.8179 17.7474C90.1445 13.1686 94.8353 9.7606 100.218 8.01168C105.6 6.26277 111.399 6.26277 116.781 8.01168C122.164 9.7606 126.855 13.1686 130.181 17.7474C132.849 21.4187 134.529 25.6916 135.09 30.1586C135.321 31.9943 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#FED966" id="path12396"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9964 141.832 30.1555C141.418 26.0285 140.24 22.0042 138.348 18.2913C135.948 13.5809 132.467 9.50535 128.19 6.39793C123.913 3.29052 118.962 1.23946 113.74 0.412444C108.519 -0.414569 103.175 0.00594664 98.1475 1.63961L100.218 8.01169C104.24 6.70476 108.515 6.36834 112.692 7.02995C116.869 7.69157 120.831 9.33242 124.252 11.8183C127.674 14.3043 130.458 17.5647 132.379 21.3331C133.79 24.1034 134.705 27.0904 135.09 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#EE7D31" id="path12398"/>
|
||||||
|
<path d="M138.65 33.5C140.5 33.5 142.017 31.9965 141.832 30.1555C141.242 24.271 139.101 18.626 135.602 13.8092C131.443 8.08584 125.58 3.82579 118.852 1.63965L116.781 8.01173C122.164 9.76064 126.855 13.1687 130.181 17.7474C132.849 21.4188 134.529 25.6917 135.091 30.1587C135.321 31.9944 136.799 33.5 138.65 33.5V33.5Z"
|
||||||
|
fill="#C00000" id="path12400"/>
|
||||||
|
<path d="M138.65 33.4999C140.5 33.4999 142.017 31.9963 141.833 30.1554C141.242 24.2709 139.102 18.6258 135.602 13.8091L130.182 17.7472C132.849 21.4186 134.53 25.6915 135.091 30.1585C135.322 31.9942 136.8 33.4999 138.65 33.4999V33.4999Z"
|
||||||
|
fill="#70309F" id="path12402"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.833 30.1555C242.065 22.499 238.677 15.3011 233.188 9.81192C226.906 3.52946 218.385 6.7078e-07 209.5 0C200.615 -6.7078e-07 192.094 3.52945 185.812 9.81192C180.323 15.3011 176.935 22.499 176.167 30.1555C175.983 31.9964 177.5 33.5 179.35 33.5V33.5C181.2 33.5 182.678 31.9943 182.909 30.1586C183.647 24.2828 186.318 18.7814 190.55 14.5495C195.576 9.52356 202.392 6.7 209.5 6.7C216.608 6.7 223.424 9.52356 228.45 14.5495C232.682 18.7814 235.353 24.2828 236.091 30.1586C236.322 31.9943 237.8 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12404"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.242 24.271 240.101 18.6259 236.602 13.8092C232.443 8.0858 226.58 3.82575 219.852 1.63961C213.123 -0.546536 205.876 -0.546535 199.147 1.63961C192.419 3.82575 186.556 8.0858 182.397 13.8092L187.818 17.7474C191.145 13.1686 195.835 9.7606 201.218 8.01168C206.6 6.26277 212.399 6.26277 217.781 8.01168C223.164 9.7606 227.855 13.1686 231.181 17.7474C233.849 21.4187 235.529 25.6916 236.09 30.1586C236.321 31.9943 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12406"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9964 242.832 30.1555C242.418 26.0285 241.24 22.0042 239.348 18.2913C236.948 13.5809 233.467 9.50535 229.19 6.39793C224.913 3.29052 219.962 1.23946 214.74 0.412444C209.519 -0.414569 204.175 0.00594664 199.147 1.63961L201.218 8.01169C205.24 6.70476 209.515 6.36834 213.692 7.02995C217.869 7.69157 221.831 9.33242 225.252 11.8183C228.674 14.3043 231.458 17.5647 233.379 21.3331C234.79 24.1034 235.705 27.0904 236.09 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12408"/>
|
||||||
|
<path d="M239.65 33.5C241.5 33.5 243.017 31.9965 242.832 30.1555C242.242 24.271 240.101 18.626 236.602 13.8092C232.443 8.08584 226.58 3.82579 219.852 1.63965L217.781 8.01173C223.164 9.76064 227.855 13.1687 231.181 17.7474C233.849 21.4188 235.529 25.6917 236.091 30.1587C236.321 31.9944 237.799 33.5 239.65 33.5V33.5Z"
|
||||||
|
fill="#cccccc" id="path12410"/>
|
||||||
|
<path d="M239.65 33.4999C241.5 33.4999 243.017 31.9963 242.833 30.1554C242.242 24.2709 240.102 18.6258 236.602 13.8091L231.182 17.7472C233.849 21.4186 235.53 25.6915 236.091 30.1585C236.322 31.9942 237.8 33.4999 239.65 33.4999V33.4999Z"
|
||||||
|
fill="#cccccc" id="path12412"/>
|
||||||
|
<text xml:space="preserve" id="text12907"><tspan id="tspan12905" x="109.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> active</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13637"><tspan id="tspan13635" x="209.0" y="33.7" style="font-family:Arial,Helvetica,sans-serif;font-size:10px;text-align:center;text-anchor:middle;fill:#cccccc"> active</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13641"><tspan id="tspan13639" x="109.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Alder</tspan></text>
|
||||||
|
<text xml:space="preserve" id="text13645"><tspan id="tspan13643" x="209.0" y="-15.0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;text-align:center;text-anchor:middle;fill:#ffffff"> Hazel</tspan></text>
|
||||||
|
<circle style="fill:#ffffff" id="cursor1" cx="79.99277625299781" cy="24.274981671564106" r="4.0"/>
|
||||||
|
<circle style="fill:#ffffff" id="cursor2" cx="-99999999" cy="-99999999" r="4.0"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 7.8 KiB |
|
@ -7,13 +7,20 @@ from homeassistant.config_entries import SOURCE_USER
|
||||||
from homeassistant.const import CONF_ZONE
|
from homeassistant.const import CONF_ZONE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from irm_kmi_api.const import OPTION_STYLE_SATELLITE, OPTION_STYLE_STD
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi.const import DOMAIN
|
from custom_components.irm_kmi import async_migrate_entry
|
||||||
|
from custom_components.irm_kmi.const import (
|
||||||
|
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||||
|
|
||||||
|
|
||||||
async def test_full_user_flow(
|
async def test_full_user_flow(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_setup_entry: MagicMock,
|
mock_setup_entry: MagicMock,
|
||||||
|
mock_get_forecast_in_benelux: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the full user configuration flow."""
|
"""Test the full user configuration flow."""
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
@ -25,9 +32,121 @@ async def test_full_user_flow(
|
||||||
|
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input={CONF_ZONE: ENTITY_ID_HOME},
|
user_input={CONF_ZONE: ENTITY_ID_HOME,
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: False},
|
||||||
|
)
|
||||||
|
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2.get("title") == "test home"
|
||||||
|
assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME,
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: False,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
CONF_LANGUAGE_OVERRIDE: 'none'}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_out_benelux_zone(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: MagicMock,
|
||||||
|
mock_get_forecast_out_benelux: MagicMock
|
||||||
|
) -> None:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
assert result2.get("title") == "IRM KMI"
|
result["flow_id"],
|
||||||
assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME}
|
user_input={CONF_ZONE: ENTITY_ID_HOME,
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2.get("type") == FlowResultType.FORM
|
||||||
|
assert result2.get("step_id") == "user"
|
||||||
|
assert CONF_ZONE in result2.get('errors')
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_with_api_error(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_setup_entry: MagicMock,
|
||||||
|
mock_get_forecast_api_error: MagicMock
|
||||||
|
) -> None:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_ZONE: ENTITY_ID_HOME,
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2.get("type") == FlowResultType.FORM
|
||||||
|
assert result2.get("step_id") == "user"
|
||||||
|
assert 'base' in result2.get('errors')
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_flow_unknown_zone(hass: HomeAssistant) -> None:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_ZONE: "zone.what",
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2.get("type") == FlowResultType.FORM
|
||||||
|
assert result2.get("step_id") == "user"
|
||||||
|
assert CONF_ZONE in result2.get('errors')
|
||||||
|
|
||||||
|
|
||||||
|
async def test_option_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert not mock_config_entry.options
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id, data=None)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
CONF_STYLE: OPTION_STYLE_SATELLITE,
|
||||||
|
CONF_DARK_MODE: True,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_STYLE: OPTION_STYLE_SATELLITE,
|
||||||
|
CONF_DARK_MODE: True,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
CONF_LANGUAGE_OVERRIDE: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_entry_migration(hass: HomeAssistant) -> None:
|
||||||
|
"""Ensure that config entry migration takes the configuration to the latest version"""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
title="Home",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={CONF_ZONE: "zone.home"},
|
||||||
|
unique_id="zone.home",
|
||||||
|
version=1
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
await async_migrate_entry(hass, entry)
|
||||||
|
result_entry = hass.config_entries.async_get_entry(entry_id=entry.entry_id)
|
||||||
|
|
||||||
|
assert result_entry.version == CONFIG_FLOW_VERSION
|
||||||
|
|
|
@ -1,175 +1,111 @@
|
||||||
import json
|
from datetime import timedelta
|
||||||
from datetime import datetime
|
|
||||||
from io import BytesIO
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytz
|
from homeassistant.components.weather import ATTR_CONDITION_CLOUDY
|
||||||
from freezegun import freeze_time
|
|
||||||
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
|
|
||||||
ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
ATTR_CONDITION_RAINY, Forecast)
|
|
||||||
from homeassistant.components.zone import Zone
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from irm_kmi_api.data import CurrentWeatherData, IrmKmiRadarForecast
|
||||||
from pytest_homeassistant_custom_component.common import load_fixture
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
|
from custom_components.irm_kmi.coordinator import IrmKmiCoordinator
|
||||||
from custom_components.irm_kmi.data import CurrentWeatherData, IrmKmiForecast
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
|
from tests.conftest import get_api_data, get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
def get_api_data(fixture: str) -> dict:
|
async def test_jules_forgot_to_revert_update_interval_before_pushing(
|
||||||
return json.loads(load_fixture(fixture))
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert timedelta(minutes=5) <= coordinator.update_interval
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00'))
|
async def test_refresh_succeed_even_when_pollen_and_radar_fail(
|
||||||
def test_current_weather_be() -> None:
|
hass: HomeAssistant,
|
||||||
api_data = get_api_data("forecast.json")
|
mock_config_entry: MockConfigEntry,
|
||||||
result = IrmKmiCoordinator.current_weather_from_data(api_data)
|
):
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
coordinator._api._api_data = get_api_data("forecast.json")
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
result = await coordinator.process_api_data()
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
|
||||||
temperature=7,
|
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||||
wind_speed=5,
|
|
||||||
wind_gust_speed=None,
|
assert result.get('animation').get_hint() == "No rain forecasted shortly"
|
||||||
wind_bearing='WSW',
|
|
||||||
pressure=1020,
|
assert result.get('pollen') == PollenParser.get_unavailable_data()
|
||||||
uv_index=.7
|
|
||||||
|
existing_data = ProcessedCoordinatorData(
|
||||||
|
current_weather=CurrentWeatherData(),
|
||||||
|
daily_forecast=[],
|
||||||
|
hourly_forecast=[],
|
||||||
|
animation=None,
|
||||||
|
warnings=[],
|
||||||
|
pollen={'foo': 'bar'}
|
||||||
)
|
)
|
||||||
|
coordinator.data = existing_data
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
assert result == expected
|
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
|
||||||
|
|
||||||
|
assert result.get('animation').get_hint() == "No rain forecasted shortly"
|
||||||
|
|
||||||
|
assert result.get('pollen') == {'foo': 'bar'}
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00"))
|
def test_radar_forecast() -> None:
|
||||||
def test_current_weather_nl() -> None:
|
api = get_api_with_data("forecast.json")
|
||||||
api_data = get_api_data("forecast_nl.json")
|
result = api.get_radar_forecast()
|
||||||
result = IrmKmiCoordinator.current_weather_from_data(api_data)
|
|
||||||
|
|
||||||
expected = CurrentWeatherData(
|
expected = [
|
||||||
condition=ATTR_CONDITION_CLOUDY,
|
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
temperature=11,
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
wind_speed=40,
|
IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
wind_gust_speed=None,
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
wind_bearing='SW',
|
IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
pressure=1008,
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
uv_index=1
|
IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
)
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min')
|
||||||
|
]
|
||||||
|
|
||||||
assert expected == result
|
assert expected == result
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00.028724'))
|
def test_radar_forecast_rain_interval() -> None:
|
||||||
def test_daily_forecast() -> None:
|
api = get_api_with_data('forecast_with_rain_on_radar.json')
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
|
result = api.get_radar_forecast()
|
||||||
result = IrmKmiCoordinator.daily_list_to_forecast(api_data)
|
|
||||||
|
|
||||||
assert isinstance(result, list)
|
_12 = IrmKmiRadarForecast(
|
||||||
assert len(result) == 8
|
datetime='2024-05-30T18:00:00+02:00',
|
||||||
|
native_precipitation=0.89,
|
||||||
expected = IrmKmiForecast(
|
might_rain=True,
|
||||||
datetime='2023-12-27',
|
rain_forecast_max=1.12,
|
||||||
condition=ATTR_CONDITION_PARTLYCLOUDY,
|
rain_forecast_min=0.50,
|
||||||
native_precipitation=0,
|
unit='mm/10min'
|
||||||
native_temperature=9,
|
|
||||||
native_templow=4,
|
|
||||||
native_wind_gust_speed=50,
|
|
||||||
native_wind_speed=20,
|
|
||||||
precipitation_probability=0,
|
|
||||||
wind_bearing='S',
|
|
||||||
is_daytime=True,
|
|
||||||
text_fr='Bar',
|
|
||||||
text_nl='Foo'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result[1] == expected
|
_13 = IrmKmiRadarForecast(
|
||||||
|
datetime="2024-05-30T18:10:00+02:00",
|
||||||
|
native_precipitation=0.83,
|
||||||
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00.028724'))
|
might_rain=True,
|
||||||
def test_hourly_forecast() -> None:
|
rain_forecast_max=1.09,
|
||||||
api_data = get_api_data("forecast.json").get('for', {}).get('hourly')
|
rain_forecast_min=0.64,
|
||||||
result = IrmKmiCoordinator.hourly_list_to_forecast(api_data)
|
unit='mm/10min'
|
||||||
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 49
|
|
||||||
|
|
||||||
expected = Forecast(
|
|
||||||
datetime='2023-12-27T02:00:00',
|
|
||||||
condition=ATTR_CONDITION_RAINY,
|
|
||||||
native_precipitation=.98,
|
|
||||||
native_temperature=8,
|
|
||||||
native_templow=None,
|
|
||||||
native_wind_gust_speed=None,
|
|
||||||
native_wind_speed=15,
|
|
||||||
precipitation_probability=70,
|
|
||||||
wind_bearing='S',
|
|
||||||
native_pressure=1020,
|
|
||||||
is_daytime=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result[8] == expected
|
assert result[12] == _12
|
||||||
|
assert result[13] == _13
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00"))
|
|
||||||
async def test_get_image_nl(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_image_irm_kmi_api: AsyncMock) -> None:
|
|
||||||
api_data = get_api_data("forecast_nl.json")
|
|
||||||
coordinator = IrmKmiCoordinator(hass, Zone({}))
|
|
||||||
|
|
||||||
result = await coordinator._async_animation_data(api_data)
|
|
||||||
|
|
||||||
# Construct the expected image for the most recent one
|
|
||||||
tz = pytz.timezone(hass.config.time_zone)
|
|
||||||
background = Image.open("custom_components/irm_kmi/resources/nl.png").convert('RGBA')
|
|
||||||
layer = Image.open("tests/fixtures/clouds_nl.png").convert('RGBA')
|
|
||||||
localisation = Image.open("tests/fixtures/loc_layer_nl_d.png").convert('RGBA')
|
|
||||||
temp = Image.alpha_composite(background, layer)
|
|
||||||
expected = Image.alpha_composite(temp, localisation)
|
|
||||||
draw = ImageDraw.Draw(expected)
|
|
||||||
font = ImageFont.truetype("custom_components/irm_kmi/resources/roboto_medium.ttf", 16)
|
|
||||||
time_image = (datetime.fromisoformat("2023-12-28T14:25:00+00:00")
|
|
||||||
.astimezone(tz=tz))
|
|
||||||
time_str = time_image.isoformat(sep=' ', timespec='minutes')
|
|
||||||
draw.text((4, 4), time_str, (0, 0, 0), font=font)
|
|
||||||
|
|
||||||
result_image = Image.open(BytesIO(result['sequence'][-1]['image'])).convert('RGBA')
|
|
||||||
|
|
||||||
assert list(result_image.getdata()) == list(expected.getdata())
|
|
||||||
|
|
||||||
thumb_image = Image.open(BytesIO(result['most_recent_image'])).convert('RGBA')
|
|
||||||
assert list(thumb_image.getdata()) == list(expected.getdata())
|
|
||||||
|
|
||||||
assert result['hint'] == "No rain forecasted shortly"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time(datetime.fromisoformat("2023-12-26T18:31:00+01:00"))
|
|
||||||
async def test_get_image_be(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
mock_image_irm_kmi_api: AsyncMock,
|
|
||||||
) -> None:
|
|
||||||
api_data = get_api_data("forecast.json")
|
|
||||||
coordinator = IrmKmiCoordinator(hass, Zone({}))
|
|
||||||
|
|
||||||
result = await coordinator._async_animation_data(api_data)
|
|
||||||
|
|
||||||
# Construct the expected image for the most recent one
|
|
||||||
tz = pytz.timezone(hass.config.time_zone)
|
|
||||||
background = Image.open("custom_components/irm_kmi/resources/be_bw.png").convert('RGBA')
|
|
||||||
layer = Image.open("tests/fixtures/clouds_be.png").convert('RGBA')
|
|
||||||
localisation = Image.open("tests/fixtures/loc_layer_be_n.png").convert('RGBA')
|
|
||||||
temp = Image.alpha_composite(background, layer)
|
|
||||||
expected = Image.alpha_composite(temp, localisation)
|
|
||||||
draw = ImageDraw.Draw(expected)
|
|
||||||
font = ImageFont.truetype("custom_components/irm_kmi/resources/roboto_medium.ttf", 16)
|
|
||||||
time_image = (datetime.fromisoformat("2023-12-26T18:30:00+01:00")
|
|
||||||
.astimezone(tz=tz))
|
|
||||||
time_str = time_image.isoformat(sep=' ', timespec='minutes')
|
|
||||||
draw.text((4, 4), time_str, (255, 255, 255), font=font)
|
|
||||||
|
|
||||||
result_image = Image.open(BytesIO(result['sequence'][9]['image'])).convert('RGBA')
|
|
||||||
|
|
||||||
assert list(result_image.getdata()) == list(expected.getdata())
|
|
||||||
|
|
||||||
thumb_image = Image.open(BytesIO(result['most_recent_image'])).convert('RGBA')
|
|
||||||
assert list(thumb_image.getdata()) == list(expected.getdata())
|
|
||||||
|
|
||||||
assert result['hint'] == "No rain forecasted shortly"
|
|
||||||
|
|
64
tests/test_current_weather_sensors.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import inspect
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.data import CurrentWeatherData
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
|
from custom_components.irm_kmi.const import (CURRENT_WEATHER_SENSOR_CLASS,
|
||||||
|
CURRENT_WEATHER_SENSOR_UNITS,
|
||||||
|
CURRENT_WEATHER_SENSORS)
|
||||||
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
|
from custom_components.irm_kmi.sensor import IrmKmiCurrentRainfall
|
||||||
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensors_in_current_weather_data():
|
||||||
|
weather_data_keys = inspect.get_annotations(CurrentWeatherData).keys()
|
||||||
|
|
||||||
|
for sensor in CURRENT_WEATHER_SENSORS:
|
||||||
|
assert sensor in weather_data_keys
|
||||||
|
|
||||||
|
def test_sensors_have_unit():
|
||||||
|
weather_sensor_units_keys = CURRENT_WEATHER_SENSOR_UNITS.keys()
|
||||||
|
|
||||||
|
for sensor in CURRENT_WEATHER_SENSORS:
|
||||||
|
assert sensor in weather_sensor_units_keys
|
||||||
|
|
||||||
|
def test_sensors_have_class():
|
||||||
|
weather_sensor_class_keys = CURRENT_WEATHER_SENSOR_CLASS.keys()
|
||||||
|
|
||||||
|
for sensor in CURRENT_WEATHER_SENSORS:
|
||||||
|
assert sensor in weather_sensor_class_keys
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expected,filename",
|
||||||
|
[
|
||||||
|
('mm/h', 'forecast_ams_no_ww.json'),
|
||||||
|
('mm/10min', 'forecast_out_of_benelux.json'),
|
||||||
|
('mm/10min', 'forecast_with_rain_on_radar.json'),
|
||||||
|
])
|
||||||
|
async def test_current_rainfall_unit(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
expected,
|
||||||
|
filename
|
||||||
|
) -> None:
|
||||||
|
hass.config.time_zone = 'Europe/Brussels'
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api = get_api_with_data(filename)
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
|
||||||
|
coordinator.data = ProcessedCoordinatorData(
|
||||||
|
current_weather=api.get_current_weather(tz),
|
||||||
|
hourly_forecast=api.get_hourly_forecast(tz),
|
||||||
|
radar_forecast=api.get_radar_forecast(),
|
||||||
|
country=api.get_country()
|
||||||
|
)
|
||||||
|
|
||||||
|
s = IrmKmiCurrentRainfall(coordinator, mock_config_entry)
|
||||||
|
|
||||||
|
assert s.native_unit_of_measurement == expected
|
|
@ -8,14 +8,17 @@ from homeassistant.const import CONF_ZONE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
from custom_components.irm_kmi.const import DOMAIN
|
from custom_components.irm_kmi import OPTION_STYLE_STD, async_migrate_entry
|
||||||
|
from custom_components.irm_kmi.const import (
|
||||||
|
CONF_DARK_MODE, CONF_LANGUAGE_OVERRIDE, CONF_STYLE,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST, CONFIG_FLOW_VERSION, DOMAIN,
|
||||||
|
OPTION_DEPRECATED_FORECAST_NOT_USED)
|
||||||
|
|
||||||
|
|
||||||
async def test_load_unload_config_entry(
|
async def test_load_unload_config_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
mock_irm_kmi_api: AsyncMock,
|
mock_irm_kmi_api: AsyncMock,
|
||||||
mock_coordinator: AsyncMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the IRM KMI configuration entry loading/unloading."""
|
"""Test the IRM KMI configuration entry loading/unloading."""
|
||||||
hass.states.async_set(
|
hass.states.async_set(
|
||||||
|
@ -53,7 +56,7 @@ async def test_config_entry_not_ready(
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert mock_exception_irm_kmi_api.get_forecasts_coord.call_count == 1
|
assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1
|
||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,27 +79,27 @@ async def test_config_entry_zone_removed(
|
||||||
assert "Zone 'zone.castle' not found" in caplog.text
|
assert "Zone 'zone.castle' not found" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
async def test_zone_out_of_benelux(
|
async def test_config_entry_migration(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
mock_irm_kmi_api_out_benelux: AsyncMock
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the IRM KMI when configuration zone is out of Benelux"""
|
"""Test the IRM KMI configuration entry not ready."""
|
||||||
mock_config_entry = MockConfigEntry(
|
mock_config_entry = MockConfigEntry(
|
||||||
title="London",
|
title="My Castle",
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
data={CONF_ZONE: "zone.london"},
|
data={CONF_ZONE: "zone.castle"},
|
||||||
unique_id="zone.london",
|
unique_id="zone.castle",
|
||||||
)
|
)
|
||||||
hass.states.async_set(
|
|
||||||
"zone.london",
|
|
||||||
0,
|
|
||||||
{"latitude": 51.5072, "longitude": 0.1276},
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
success = await async_migrate_entry(hass, mock_config_entry)
|
||||||
assert "Zone 'zone.london' is out of Benelux" in caplog.text
|
assert success
|
||||||
|
|
||||||
|
assert mock_config_entry.data == {
|
||||||
|
CONF_ZONE: "zone.castle",
|
||||||
|
CONF_STYLE: OPTION_STYLE_STD,
|
||||||
|
CONF_DARK_MODE: True,
|
||||||
|
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
|
||||||
|
CONF_LANGUAGE_OVERRIDE: 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert mock_config_entry.version == CONFIG_FLOW_VERSION
|
||||||
|
|
26
tests/test_pollen.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.api import IrmKmiApiError
|
||||||
|
from irm_kmi_api.pollen import PollenParser
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pollen_error_leads_to_unavailable_on_first_call(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
|
||||||
|
api.get_svg = AsyncMock()
|
||||||
|
api.get_svg.side_effect = IrmKmiApiError
|
||||||
|
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
expected = PollenParser.get_unavailable_data()
|
||||||
|
assert result['pollen'] == expected
|
166
tests/test_repairs.py
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from homeassistant.helpers import issue_registry
|
||||||
|
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||||
|
load_fixture)
|
||||||
|
|
||||||
|
from custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
|
||||||
|
from custom_components.irm_kmi.const import (REPAIR_OPT_DELETE,
|
||||||
|
REPAIR_OPT_MOVE, REPAIR_SOLUTION)
|
||||||
|
from custom_components.irm_kmi.repairs import (OutOfBeneluxRepairFlow,
|
||||||
|
async_create_fix_flow)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_repair_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> OutOfBeneluxRepairFlow:
|
||||||
|
hass.states.async_set(
|
||||||
|
"zone.home",
|
||||||
|
0,
|
||||||
|
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||||
|
)
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
fixture: str = "forecast_out_of_benelux.json"
|
||||||
|
forecast = json.loads(load_fixture(fixture))
|
||||||
|
coordinator._api.get_forecasts_coord = AsyncMock(return_value=forecast)
|
||||||
|
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
ir = issue_registry.async_get(hass)
|
||||||
|
issue = ir.async_get_issue(DOMAIN, "zone_moved")
|
||||||
|
repair_flow = await async_create_fix_flow(hass, issue.issue_id, issue.data)
|
||||||
|
repair_flow.hass = hass
|
||||||
|
return repair_flow
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_triggers_when_out_of_benelux(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
hass.states.async_set(
|
||||||
|
"zone.home",
|
||||||
|
0,
|
||||||
|
{"latitude": 50.738681639, "longitude": 4.054077148},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
coordinator._api.get_forecasts_coord = AsyncMock(return_value=json.loads(load_fixture("forecast_out_of_benelux.json")))
|
||||||
|
|
||||||
|
await coordinator._async_update_data()
|
||||||
|
|
||||||
|
ir = issue_registry.async_get(hass)
|
||||||
|
|
||||||
|
issue = ir.async_get_issue(DOMAIN, "zone_moved")
|
||||||
|
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.data == {'config_entry_id': mock_config_entry.entry_id, 'zone': "zone.home"}
|
||||||
|
assert issue.translation_key == "zone_moved"
|
||||||
|
assert issue.is_fixable
|
||||||
|
assert issue.translation_placeholders == {'zone': "zone.home"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_flow(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
result = await repair_flow.async_step_init()
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
assert result['errors'] == {}
|
||||||
|
assert result['description_placeholders'] == {"zone": "zone.home"}
|
||||||
|
|
||||||
|
user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE}
|
||||||
|
|
||||||
|
result = await repair_flow.async_step_confirm(user_input)
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result['title'] == ""
|
||||||
|
assert result['data'] == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_flow_invalid_choice(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_irm_kmi_api_repair_in_benelux: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
result = await repair_flow.async_step_init()
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
user_input = {REPAIR_SOLUTION: "whut?"}
|
||||||
|
|
||||||
|
result = await repair_flow.async_step_confirm(user_input)
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
assert REPAIR_SOLUTION in result['errors']
|
||||||
|
assert result['errors'][REPAIR_SOLUTION] == 'invalid_choice'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_flow_api_error(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_get_forecast_api_error_repair: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
result = await repair_flow.async_step_init()
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE}
|
||||||
|
|
||||||
|
result = await repair_flow.async_step_confirm(user_input)
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
assert REPAIR_SOLUTION in result['errors']
|
||||||
|
assert result['errors'][REPAIR_SOLUTION] == 'api_error'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_flow_out_of_benelux(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_irm_kmi_api_repair_out_of_benelux: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
result = await repair_flow.async_step_init()
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
user_input = {REPAIR_SOLUTION: REPAIR_OPT_MOVE}
|
||||||
|
|
||||||
|
result = await repair_flow.async_step_confirm(user_input)
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
assert REPAIR_SOLUTION in result['errors']
|
||||||
|
assert result['errors'][REPAIR_SOLUTION] == 'out_of_benelux'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_flow_delete_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
repair_flow = await get_repair_flow(hass, mock_config_entry)
|
||||||
|
result = await repair_flow.async_step_init()
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.FORM
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert hass.config_entries.async_entries(DOMAIN)[0].entry_id == mock_config_entry.entry_id
|
||||||
|
|
||||||
|
user_input = {REPAIR_SOLUTION: REPAIR_OPT_DELETE}
|
||||||
|
result = await repair_flow.async_step_confirm(user_input)
|
||||||
|
|
||||||
|
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||||
|
assert result['title'] == ""
|
||||||
|
assert result['data'] == {}
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
220
tests/test_sensors.py
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.irm_kmi import IrmKmiCoordinator
|
||||||
|
from custom_components.irm_kmi.binary_sensor import IrmKmiWarning
|
||||||
|
from custom_components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE
|
||||||
|
from custom_components.irm_kmi.sensor import (IrmKmiNextSunMove,
|
||||||
|
IrmKmiNextWarning)
|
||||||
|
from tests.conftest import get_api_with_data, get_radar_animation_data
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
|
||||||
|
async def test_warning_data(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
result = api.get_warnings('en')
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result}
|
||||||
|
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
assert warning.is_on
|
||||||
|
assert len(warning.extra_state_attributes['warnings']) == 2
|
||||||
|
|
||||||
|
for w in warning.extra_state_attributes['warnings']:
|
||||||
|
assert w['is_active']
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['active_warnings_friendly_names'] == "Fog, Ice or snow"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:55:00+01:00'))
|
||||||
|
async def test_warning_data_unknown_lang(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
|
warning = IrmKmiWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
assert warning.is_on
|
||||||
|
assert len(warning.extra_state_attributes['warnings']) == 2
|
||||||
|
|
||||||
|
for w in warning.extra_state_attributes['warnings']:
|
||||||
|
assert w['is_active']
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['active_warnings_friendly_names'] == "Fog, Ice or snow"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2024-01-11T20:00:00+01:00'))
|
||||||
|
async def test_next_warning_when_data_available(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
await hass.config_entries.async_add(mock_config_entry)
|
||||||
|
hass.config_entries.async_update_entry(mock_config_entry, data=mock_config_entry.data | {CONF_LANGUAGE_OVERRIDE: 'de'})
|
||||||
|
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
warning._attr_translation_key = None
|
||||||
|
|
||||||
|
assert warning.state == "2024-01-12T06:00:00+00:00"
|
||||||
|
assert len(warning.extra_state_attributes['next_warnings']) == 2
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['next_warnings_friendly_names'] == "Nebel, Glätte"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:30:00+01:00'))
|
||||||
|
async def test_next_warning_none_when_only_active_warnings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
api = get_api_with_data("be_forecast_warning.json")
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': result['warnings']}
|
||||||
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
warning._attr_translation_key = None
|
||||||
|
|
||||||
|
assert warning.state is None
|
||||||
|
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['next_warnings_friendly_names'] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2024-01-12T07:30:00+01:00'))
|
||||||
|
async def test_next_warning_none_when_no_warnings(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
coordinator.data = {'warnings': []}
|
||||||
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
warning._attr_translation_key = None
|
||||||
|
|
||||||
|
assert warning.state is None
|
||||||
|
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['next_warnings_friendly_names'] == ""
|
||||||
|
|
||||||
|
coordinator.data = dict()
|
||||||
|
warning = IrmKmiNextWarning(coordinator, mock_config_entry)
|
||||||
|
warning.hass = hass
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextWarning'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
warning._attr_translation_key = None
|
||||||
|
|
||||||
|
assert warning.state is None
|
||||||
|
assert len(warning.extra_state_attributes['next_warnings']) == 0
|
||||||
|
|
||||||
|
assert warning.extra_state_attributes['next_warnings_friendly_names'] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00+01:00'))
|
||||||
|
async def test_next_sunrise_sunset(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
api = get_api_with_data("forecast.json")
|
||||||
|
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||||
|
|
||||||
|
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||||
|
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextSunMove'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
sunrise._attr_translation_key = None
|
||||||
|
sunset._attr_translation_key = None
|
||||||
|
|
||||||
|
assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00')
|
||||||
|
assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-27T16:43:00+01:00')
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat('2023-12-26T15:30:00+01:00'))
|
||||||
|
async def test_next_sunrise_sunset_bis(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
api = get_api_with_data("forecast.json")
|
||||||
|
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
api.get_pollen = AsyncMock()
|
||||||
|
api.get_animation_data = MagicMock(return_value=get_radar_animation_data())
|
||||||
|
coordinator._api = api
|
||||||
|
|
||||||
|
result = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
coordinator.data = {'daily_forecast': result['daily_forecast']}
|
||||||
|
|
||||||
|
sunset = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunset')
|
||||||
|
sunrise = IrmKmiNextSunMove(coordinator, mock_config_entry, 'sunrise')
|
||||||
|
|
||||||
|
# This somehow fixes the following error that popped since 2024.12.0
|
||||||
|
# ValueError: Entity <class 'custom_components.irm_kmi.sensor.IrmKmiNextSunMove'> cannot have a translation key for
|
||||||
|
# unit of measurement before being added to the entity platform
|
||||||
|
sunrise._attr_translation_key = None
|
||||||
|
sunset._attr_translation_key = None
|
||||||
|
|
||||||
|
assert datetime.fromisoformat(sunrise.state) == datetime.fromisoformat('2023-12-27T08:44:00+01:00')
|
||||||
|
assert datetime.fromisoformat(sunset.state) == datetime.fromisoformat('2023-12-26T16:42:00+01:00')
|
165
tests/test_weather.py
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from homeassistant.components.weather import Forecast
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from irm_kmi_api.data import IrmKmiRadarForecast
|
||||||
|
from pytest_homeassistant_custom_component.common import (MockConfigEntry,
|
||||||
|
load_fixture)
|
||||||
|
|
||||||
|
from custom_components.irm_kmi import IrmKmiCoordinator, IrmKmiWeather
|
||||||
|
from custom_components.irm_kmi.data import ProcessedCoordinatorData
|
||||||
|
from tests.conftest import get_api_with_data
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00+01:00"))
|
||||||
|
async def test_weather_nl(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
forecast = json.loads(load_fixture("forecast_nl.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
|
result = await weather.async_forecast_daily()
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 7
|
||||||
|
|
||||||
|
# When getting daily forecast, the min temperature of the current day
|
||||||
|
# should be the min temperature of the coming night
|
||||||
|
assert result[0]['native_templow'] == 9
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00"))
|
||||||
|
async def test_weather_higher_temp_at_night(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
forecast = json.loads(load_fixture("high_low_temp.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
|
result: List[Forecast] = await weather.async_forecast_daily()
|
||||||
|
|
||||||
|
for f in result:
|
||||||
|
if f['native_temperature'] is not None and f['native_templow'] is not None:
|
||||||
|
assert f['native_temperature'] >= f['native_templow']
|
||||||
|
|
||||||
|
result: List[Forecast] = await weather.async_forecast_twice_daily()
|
||||||
|
|
||||||
|
for f in result:
|
||||||
|
if f['native_temperature'] is not None and f['native_templow'] is not None:
|
||||||
|
assert f['native_temperature'] >= f['native_templow']
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00"))
|
||||||
|
async def test_forecast_attribute_same_as_service_call(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry_with_deprecated: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
|
||||||
|
forecast = json.loads(load_fixture("forecast.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
|
||||||
|
weather = IrmKmiWeather(coordinator, mock_config_entry_with_deprecated)
|
||||||
|
|
||||||
|
result_service: List[Forecast] = await weather.async_forecast_twice_daily()
|
||||||
|
result_forecast: List[Forecast] = weather.extra_state_attributes['forecast']
|
||||||
|
|
||||||
|
assert result_service == result_forecast
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time(datetime.fromisoformat("2023-12-26T17:58:03+01:00"))
|
||||||
|
async def test_radar_forecast_service(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
):
|
||||||
|
hass.config.time_zone = 'Europe/Brussels'
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
|
||||||
|
coordinator._api = get_api_with_data("forecast.json")
|
||||||
|
|
||||||
|
coordinator.data = ProcessedCoordinatorData(
|
||||||
|
radar_forecast=coordinator._api.get_radar_forecast()
|
||||||
|
)
|
||||||
|
|
||||||
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
|
|
||||||
|
result_service: List[Forecast] = weather.get_forecasts_radar_service(False)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:00:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:10:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:20:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:40:00+01:00", native_precipitation=0.1, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T17:50:00+01:00", native_precipitation=0.01, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:00:00+01:00", native_precipitation=0.12, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:10:00+01:00", native_precipitation=1.2, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:20:00+01:00", native_precipitation=2, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:30:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min'),
|
||||||
|
IrmKmiRadarForecast(datetime="2023-12-26T18:40:00+01:00", native_precipitation=0, might_rain=False,
|
||||||
|
rain_forecast_max=0, rain_forecast_min=0, unit='mm/10min')
|
||||||
|
]
|
||||||
|
|
||||||
|
assert result_service == expected[5:]
|
||||||
|
|
||||||
|
result_service: List[Forecast] = weather.get_forecasts_radar_service(True)
|
||||||
|
|
||||||
|
assert result_service == expected
|
||||||
|
|
||||||
|
def is_serializable(x):
|
||||||
|
try:
|
||||||
|
json.dumps(x)
|
||||||
|
return True
|
||||||
|
except (TypeError, OverflowError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def all_serializable(elements: list[Forecast]):
|
||||||
|
for element in elements:
|
||||||
|
for v in element.values():
|
||||||
|
assert is_serializable(v)
|
||||||
|
|
||||||
|
async def test_forecast_types_are_serializable(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
|
||||||
|
forecast = json.loads(load_fixture("forecast.json"))
|
||||||
|
coordinator._api._api_data = forecast
|
||||||
|
|
||||||
|
coordinator.data = await coordinator.process_api_data()
|
||||||
|
weather = IrmKmiWeather(coordinator, mock_config_entry)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_daily()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_twice_daily()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = await weather.async_forecast_hourly()
|
||||||
|
all_serializable(result)
|
||||||
|
|
||||||
|
result = weather.get_forecasts_radar_service(True)
|
||||||
|
all_serializable(result)
|