Compare commits

..

266 commits

Author SHA1 Message Date
52be58a9ef
Add heat warning in README 2025-06-22 10:25:09 +02:00
f1b18fe29a
bump version 0.3.1 -> 0.3.2 2025-06-18 20:32:36 +02:00
77e94d802b
Merge pull request #90 from jdejaegh/heat
Support heat warning (previsouly shown as `unknown`)
2025-06-18 20:32:02 +02:00
76a670427b
Update dependencies
Fix #89
2025-06-18 20:28:55 +02:00
866b1f3fa0
bump version 0.3.0 -> 0.3.1 2025-05-13 19:09:11 +02:00
914dd75d7b
Merge pull request #86 from jdejaegh/fix_regression
Fix regression in 0.3.0: make all elements from Forecast serializable
2025-05-13 19:03:42 +02:00
5c320b57fb
Fix regression: make all elements from Forecast serializable
Fix #85
2025-05-13 18:49:40 +02:00
68bcb8aeb4
bump version 0.2.32 -> 0.3.0 2025-05-03 21:53:20 +02:00
702f687a8d
Set logging level of dependency 2025-05-03 20:18:01 +02:00
d5a687fff5
Merge pull request #82 from jdejaegh/refactor
Refactor: move API code to its own pacakge on PyPI
2025-05-03 18:48:05 +02:00
9e178378fc
Update requirements 2025-05-03 18:38:11 +02:00
ef5d3ad126
Set debug level of irm-kmi-api to follow the integration 2025-05-03 18:30:31 +02:00
d0d542c3fe
Update dependencies 2025-05-03 17:58:52 +02:00
fd8aa3029f
Remove API code and test: use the irm-kmi-api PyPI package instead 2025-05-03 17:42:45 +02:00
fb43a882f8
Separate concerns: rain graph and api 2025-05-03 15:07:19 +02:00
57cce48c5f
Remove async await where not needed 2025-05-03 13:43:27 +02:00
7951bafefb
Remove unused parameters and imports 2025-05-02 20:37:49 +02:00
f0a1853f67
Linting 2025-05-02 20:29:28 +02:00
2707950ad9
Remove unused parameter 2025-05-02 20:20:40 +02:00
1a33b3b594
Use relative import paths 2025-05-02 20:20:21 +02:00
6476f0e57a
Move API related code to a sub-module + update tests 2025-05-02 19:31:45 +02:00
5932884c7a
Update requirements to match HA 2025.3.1 2025-03-08 10:51:54 +01:00
1e35e24c15
Update year 2025-03-08 10:45:49 +01:00
f729d59d9f
Fix UV index sensor showing as bar chart instead of graph in history 2025-03-03 18:33:03 +01:00
16a5399edb
bump version 0.2.31 -> 0.2.32 2025-02-24 21:39:21 +01:00
36bfe49ce2
Merge pull request #78 from jdejaegh/inline-images
Inline background images in base64
2025-02-24 21:38:38 +01:00
16a1991063
Inline background images in base64 2025-02-24 21:36:07 +01:00
be30c160f4
bump version 0.2.30 -> 0.2.31 2025-02-24 20:24:46 +01:00
9064326860
Merge pull request #77 from jdejaegh/inline-font
Use inlined base64 representation of the font
2025-02-24 20:22:44 +01:00
ea23f0da2c
Use inlined base64 representation of the font instead of opening the file 2025-02-24 20:13:57 +01:00
7f9cca4960
Fix typo 2025-02-22 21:38:53 +01:00
fee2a10f5e
bump version 0.2.29 -> 0.2.30 2025-02-22 20:47:35 +01:00
18040eb577
Merge pull request #76 from jdejaegh/72-add-more-normal-sensors
Add more normal sensors
2025-02-22 20:46:05 +01:00
be0a7425d4
Add rainfall sensor and tests 2025-02-22 20:39:15 +01:00
1844d02639
Add translation strings 2025-02-22 15:52:20 +01:00
ca98e12e88
Add icons and translation keys 2025-02-22 15:52:00 +01:00
fb59936c79
WIP start adding support for sensors for current weather 2025-02-21 20:47:22 +01:00
fbab30e33f
Update release title in workflow 2025-02-20 19:14:13 +01:00
7e75e4f184
bump version 0.2.28 -> 0.2.29 2025-02-20 19:11:32 +01:00
5d93102ada
Make workflow reusable 2025-02-20 19:11:18 +01:00
0776cff6d6
Merge pull request #74 from jdejaegh/opti
Reduce bandwidth consumption of the integration
2025-02-20 09:58:03 +01:00
93bda52ac8
Implement client caching based on ETag header 2025-02-16 20:40:44 +01:00
48fca3197f
Only fetch radar images when the camera is viewed by the user 2025-02-16 18:36:06 +01:00
196d4cc178
Configure bumpver and release workflow 2025-02-16 13:25:49 +01:00
225a853b27
Update requirements to match HA 2025.2.4 and Python 3.13 2025-02-16 12:16:54 +01:00
3ef90ba688
Version bump 2025-02-07 21:49:10 +01:00
0a64e7eec2
Remove print 2025-02-07 21:48:46 +01:00
117c2d5030
Version bump 2025-01-30 18:42:04 +01:00
67a8647f7b
Merge pull request #71 from dcbr/patch-debug
Delete print statement
2025-01-30 18:38:41 +01:00
52812487f9
Delete debug line 2025-01-30 17:35:20 +00:00
dcbr
3947273ef7
Change print to debug logging 2025-01-30 09:14:51 +01:00
b78c8a6779
Version bump 2025-01-29 21:35:26 +01:00
72e9d5dc99
Merge pull request #70 from jdejaegh/69-pollen-data
Fix pollen data parsing
2025-01-29 21:34:30 +01:00
3957eac952
Update pollen image in README 2025-01-29 21:32:11 +01:00
fdec55e021
Update pollen detection after SVG change 2025-01-29 21:29:26 +01:00
b666f7dd10
Update requirements to match HA 2025.1.4 2025-01-29 18:43:20 +01:00
082770f480
Add example of output for getWarnings 2025-01-10 10:43:08 +01:00
6661bac5ad
Add radar image example 2025-01-09 15:39:35 +01:00
9f06486512
Version bump
Some checks failed
Run Python tests / Run tests (3.12) (push) Successful in 1m21s
Validate / validate-hacs (push) Failing after 42s
Validate / validate-hassfest (push) Failing after 36s
2024-12-29 18:47:59 +01:00
3ede45af43
Merge pull request #66 from jdejaegh/62-set-option-flow-config_entry-explicitly
Rename internal variable of IrmKmiOptionFlow to avoid name clash
2024-12-29 18:39:57 +01:00
34cb9e1bb5
Merge pull request #65 from jdejaegh/64-async_config_entry_first_refresh
Fix 'irm_kmi' uses `async_config_entry_first_refresh`, which is only supported for coordinators with a config entry
2024-12-29 18:38:09 +01:00
0de029b30b
Rename internal variable of IrmKmiOptionFlow to avoid name clash 2024-12-29 18:34:19 +01:00
4978a92385
Fix typo 2024-12-29 18:17:17 +01:00
1254ae7157
Use hass.config_entries.async_reload to reload config entry 2024-12-29 18:06:08 +01:00
91d46dcb6c
Put config entry in coordinator 2024-12-29 18:04:24 +01:00
fe412dfec3
Update requirements to match HA 2024.12.5
Some checks failed
Run Python tests / Run tests (3.12) (push) Successful in 1m8s
Validate / validate-hacs (push) Failing after 20s
Validate / validate-hassfest (push) Failing after 32s
2024-12-29 17:25:40 +01:00
41fd90bf63
Version bump 2024-12-05 23:12:43 +01:00
cf7519e7db
Update requirements 2024-12-05 23:12:20 +01:00
972ab3f9e6
Version bump 2024-11-09 15:06:43 +01:00
bd22b62eef
Update requirements and tests to match 2024.11.1 2024-11-09 15:04:35 +01:00
89db47f653
Version bump 2024-11-09 11:25:40 +01:00
e378486d74
Merge pull request #56 from ViPeR5000/main
Create pt.json
2024-11-09 11:22:39 +01:00
Rui Melo
c862e184d4
Create pt.json 2024-11-08 19:10:42 +00:00
b78d671bd2
Version bump 2024-10-27 18:30:41 +01:00
f9af56547a
Add test for pollen and fix pollen active
Fix #51
2024-10-27 18:18:35 +01:00
1c396fa247
Version bump 2024-10-27 11:42:36 +01:00
ec84b405de
Update dependencies 2024-10-27 11:40:15 +01:00
09de4fbaa7
Version bump 2024-06-29 15:32:41 +02:00
91ff65c19d
Merge pull request #52 from jdejaegh/fix_pollen_data
Fix pollen data parsing
2024-06-29 15:24:55 +02:00
4af3d3dcbd
Update README 2024-06-29 15:21:30 +02:00
d16a08f647
Fix pollen parsing 2024-06-29 15:16:55 +02:00
3404e1649d
Version bump 2024-06-23 14:06:13 +02:00
65e31b700d
Merge pull request #50 from jdejaegh/serialize_daily_forecast
Serialize daily forecast
2024-06-23 13:50:25 +02:00
62e9e5fb9f
Fix edge case in current weather 2024-06-23 13:40:50 +02:00
5f53d16ce2
Fix daily forecast sunset sunrise type
Fix #49
2024-06-23 12:43:45 +02:00
2a5f122dae
Update requirements to match HA 2024.6.4 2024-06-23 12:07:03 +02:00
50a7a677fb
Update requirements to match HA 2024.6.3 2024-06-16 18:50:14 +02:00
5e7b01face
Version bump 2024-06-09 21:09:29 +02:00
da512c5f37
Merge pull request #48 from jdejaegh/next_sunrise_sunset
Add next sunset sunrise sensors
2024-06-09 20:43:26 +02:00
f1e7c267e6
Add next sunset sunrise sensors 2024-06-09 20:37:14 +02:00
0059b2f78f
Fetch sunrise and sunset time for daily forecast 2024-06-09 17:53:27 +02:00
48ad275cf9
Merge pull request #47 from jdejaegh/fix_nl_weather
Fix current condition and twice daily forecast datetime for NL
2024-06-09 17:01:02 +02:00
f66b202b66
Fix current condition and twice daily forecast datetime for NL 2024-06-09 16:47:54 +02:00
7e153e2b12
Version bump 2024-06-08 15:56:34 +02:00
afd62cce95
Update requirements to match HA 2024.6.0 2024-06-08 15:54:32 +02:00
eb036f9d05
Merge pull request #46 from jdejaegh/daily_midnight_bug
Fix midnight bug for daily forecast
2024-06-08 15:52:03 +02:00
671cc26031
Try to fix #38 for daily forecast 2024-06-08 14:33:13 +02:00
152fa9768e
Update requirements to match HA 2024.6.0 2024-06-06 21:02:20 +02:00
e716172a93
Set minimum version of Home Assistant required 2024-06-01 21:33:51 +02:00
1862609bb2
Version bump 2024-06-01 20:56:02 +02:00
89b08dca0f
Merge pull request #42 from jdejaegh/37-confidence-intervals-get_forecasts_radar
Add  confidence intervals for irm_kmi.get_forecasts_radar
2024-06-01 20:50:43 +02:00
98079d904d
Linter changes 2024-06-01 20:41:23 +02:00
c647e83c4c
Add docstring 2024-06-01 20:39:37 +02:00
a952e3566f
Implement confidence intervals for irm_kmi.get_forecasts_radar 2024-06-01 20:35:28 +02:00
0812ef5eef
Merge pull request #41 from jdejaegh/blocking_calls
Use non-blocking calls in the main loop
2024-06-01 18:51:29 +02:00
b1d21bf858
Rename parameter 2024-06-01 18:49:31 +02:00
d354199ace
Make new tests async 2024-06-01 18:46:03 +02:00
f651968b4d
Merge with origin/main 2024-06-01 18:40:44 +02:00
a7d17d707e
Fix test name 2024-06-01 18:36:38 +02:00
2562b2b733
Merge pull request #40 from jdejaegh/midnight_bug
Fix midnight bug: between 12AM and 1AM hourly forecast was shifted by +1 day
2024-06-01 18:33:17 +02:00
59a3a3f07a
Use non-blocking calls in the main loop 2024-06-01 18:30:12 +02:00
7bf45ac713
Use beta homeassistant to fix dependencies in the build 2024-05-31 23:03:25 +02:00
03677d01ef
Try to fix pipeline 2024-05-31 22:42:36 +02:00
a211a5e406
Try to fix pipeline 2024-05-31 22:40:54 +02:00
afea4df5d2
Fix midnight bug
Fix #38
2024-05-31 22:36:59 +02:00
2e90931996
Improve device name 2024-05-28 20:45:15 +02:00
2579b79d11
Update requirements to match homeassistant==2024.5.5 2024-05-28 20:24:37 +02:00
1f3bc392ba
Version bump 2024-05-28 20:21:07 +02:00
6babe1e9f4
Merge pull request #35 from jdejaegh/unknown_text_in_radar
Fix regression: unknown text in radar
2024-05-28 20:20:11 +02:00
b973dd57fc
Add config migration test 2024-05-28 20:10:02 +02:00
2bdfa014df
Fix bug in config migration 2024-05-28 19:48:32 +02:00
ff5f2f8adc
Add data attribution 2024-05-26 20:22:20 +02:00
9e07e18e85
Version bump 2024-05-24 17:47:38 +02:00
4decfe72ac
Merge pull request #32 from jdejaegh/language-picker
Add language picker to override default Home Assistant language in the integration
2024-05-24 17:44:32 +02:00
6bc54898ef
Simplify values in options 2024-05-20 22:03:28 +02:00
8d2fcbefb5
Fix hassfest validation 2024-05-20 21:14:02 +02:00
e1ffb2ec8e
Update requirements 2024-05-20 21:08:31 +02:00
377601cf4d
Add language picker to override Home Assistant server language 2024-05-20 21:05:19 +02:00
92d4084cf2
Version bump 2024-05-20 11:28:13 +02:00
7ff9705536
Merge pull request #31 from jdejaegh/update-readme
Update README
2024-05-20 11:19:18 +02:00
e55a2e5300
Update license date 2024-05-20 11:13:33 +02:00
d75756968a
Update sensors screenshot 2024-05-20 11:08:54 +02:00
d85d4c7264
Add big blue buttons to install and configure the integration in one click 2024-05-20 11:04:10 +02:00
35bc326267
Add User-Agent to the client 2024-05-20 10:54:42 +02:00
e4a6d254af
Make service parameter optional 2024-05-20 10:46:12 +02:00
a1e0b7d394
Update README.md 2024-05-20 10:45:33 +02:00
a93e583c32
Update README.md 2024-05-20 10:44:16 +02:00
f5ebf31b0e
Add default value to parameter 2024-05-20 10:17:53 +02:00
911aa3a1c7
Merge pull request #30 from jdejaegh/rain_radar_service
Create new service: irm_kmi.get_forecasts_radar
2024-05-20 10:13:09 +02:00
121b6e50c3
Create new service: irm_kmi.get_forecasts_radar 2024-05-19 22:54:55 +02:00
22b7305e14
Version bump 2024-05-15 18:06:11 +02:00
a7201e5cb6
Merge pull request #28 from jdejaegh/21-warnings-should-be-visible-before-they-actually-start
Warnings should be visible before they actually start
2024-05-12 19:46:47 +02:00
a0b6fdc36c
Add timezone offset in forecast service call response.
Related to #26
2024-05-12 17:58:12 +02:00
e693224792
Add timestamp sensor for next weather warning 2024-05-12 17:28:24 +02:00
a93b199364
Merge pull request #27 from jdejaegh/timeout_handling
Improve handling of API errors such as timeout
2024-05-12 14:35:57 +02:00
4e8f1faebc
Add test 2024-05-12 14:33:45 +02:00
68491fc7da
Increaste timeout to 60s 2024-05-12 14:33:24 +02:00
d5d4005634
Weather data will become unavailable at the third failed update in a row 2024-05-10 20:36:03 +02:00
ac0fe07f4f
Keep the previous value for pollen and radar when the API fails only for those items but not for the main forecast 2024-05-10 18:43:54 +02:00
87bd3dc256
Merge branch 'main' of github.com:jdejaegh/irm-kmi-ha 2024-05-10 15:14:05 +02:00
580a3a0350
Version bump 2024-05-10 15:13:47 +02:00
dependabot[bot]
14e0512b12
Update dependencies to fix dependabot alerts (#24)
* Bump aiohttp from 3.9.3 to 3.9.4

Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.9.3 to 3.9.4.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.9.3...v3.9.4)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update requirements

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jules Dejaeghere <dej.j@live.be>
2024-05-10 13:07:04 +00:00
3c2bd51e85
First steps for better timeout handling 2024-05-10 14:58:21 +02:00
8aeb656360
Version bump 2024-04-09 18:41:40 +02:00
191f7f54fb
Fix typo 2024-04-09 18:11:51 +02:00
6060c09cc0
Version bump 2024-04-06 18:31:37 +02:00
e4c4c8e954
Use degrees for wind bearing instead of cardinal point 2024-04-06 18:31:15 +02:00
f98f846d71
Merge pull request #19 from jdejaegh/dev
Keep forecast attribute
2024-04-06 17:13:08 +02:00
73236a7649
Version bump 2024-04-06 17:07:19 +02:00
4d45275c64
Keep forecast attribute 2024-04-06 17:05:36 +02:00
0ae257aa4c
Update workflow action versions 2024-04-04 21:28:55 +02:00
002255d422
Update workflow action versions 2024-04-04 21:25:25 +02:00
842ac0a5e4
Update versions to Home Assistant 2024.4.0 and Python 3.12 2024-04-04 21:21:11 +02:00
6744629bec
Fix typo 2024-04-03 14:33:25 +02:00
cb9cc62ce0
Version bump 2024-04-03 14:25:05 +02:00
28cebe63c3
Merge pull request #18 from jdejaegh/12-pollen-and-uv-index-data-as-attributes
Fetch pollen level data from API
2024-04-03 14:22:53 +02:00
c8b4a3b109
Update pollen image in README 2024-04-03 14:20:59 +02:00
14d03b47d4
Update README 2024-04-03 14:11:46 +02:00
686ee62df6
Add test for SVG parsing 2024-04-03 11:29:28 +02:00
1d2681ce68
Add test for SVG parsing 2024-04-03 11:28:54 +02:00
cb32f77130
Add test for SVG parsing 2024-04-03 11:27:06 +02:00
39f5c75486
Add debug line 2024-04-03 11:24:13 +02:00
b5b9efc65f
Add icons for pollens 2024-04-02 22:41:34 +02:00
eec3564d17
Add docstring 2024-04-02 22:31:11 +02:00
8a93adb053
Add test for pollen data fetching 2024-04-02 21:15:54 +02:00
adc9e5d013
Translation and device info 2024-04-02 20:56:00 +02:00
67ab04499e
Re-enable logging line 2024-04-01 18:14:25 +02:00
43a207522a
Add tests 2024-04-01 18:13:16 +02:00
c8b2954ef3
Initial support for pollen data 2024-04-01 18:08:16 +02:00
33671e7a52
Version bump 2024-04-01 14:03:59 +02:00
9d7451af09
Merge pull request #17 from jdejaegh/13-font-not-found
Use hass.config option to define config directory when loading files
2024-04-01 14:00:47 +02:00
b2bc3a72ba
Use hass.config option to define config directory when loading files 2024-04-01 13:56:41 +02:00
18737439db
First step for pollen support 2024-04-01 12:14:51 +02:00
efac0f4fcd
Merge pull request #15 from jdejaegh/main
Update branch
2024-03-30 17:05:09 +01:00
3804c1ae47
Merge pull request #14 from jdejaegh/13-font-not-found
Try to fix font file not found issue
2024-03-30 16:56:12 +01:00
23f690027d
Version bump 2024-03-30 16:52:21 +01:00
833796bf26
Try fix font not loading 2024-03-30 16:52:05 +01:00
11c1adda5e
Try fix font not loading 2024-03-30 16:51:45 +01:00
4865b94313
Update requirements 2024-03-30 15:25:02 +01:00
16f40cf564
Fix typo in docstring 2024-02-25 14:32:17 +01:00
cda538f22f
Version bump 2024-02-25 14:25:03 +01:00
e1c2e8a659
Fix language setting causing sensor to fail. Fix #10 2024-02-25 14:19:49 +01:00
274e53a9ca
Fix requirements dependencies 2024-02-12 18:53:55 +01:00
d838adce73
Update requirements to fix dependabot alert 2024-02-12 18:49:32 +01:00
fbb3780a41
Update requirements 2024-01-24 21:00:06 +01:00
9435e9a057
Version bump 2024-01-22 18:50:14 +01:00
aa5c0e5748
Merge pull request #9 from jdejaegh/high-low-temp
Ensure native_templow <= native_temperature
2024-01-22 18:48:38 +01:00
11d055e5f4
Reformat code 2024-01-22 18:35:02 +01:00
ce79abbefe
Ensure native_templow <= native_temperature 2024-01-22 18:32:55 +01:00
21abf641e5
Update issue templates 2024-01-22 17:02:58 +00:00
4571ef1f1a
Version bump 2024-01-21 15:27:28 +01:00
e68d4744fb
Merge branch 'main' of github.com:jdejaegh/irm-kmi-ha 2024-01-21 15:24:21 +01:00
34c63fe199
Create issue templates 2024-01-21 14:21:44 +00:00
8ee79f43e9
Add full API data logging in debug mode 2024-01-21 15:04:01 +01:00
5c640a284f
Remove some logging 2024-01-20 15:17:34 +01:00
2aa41f0155
Merge pull request #6 from jdejaegh/attributes
Update attributes
2024-01-20 15:04:15 +01:00
fe13def7e8
Merge pull request #7 from jdejaegh/exceptional-weather
Map freezing rain and freezing fog to rainy and fog respectively (instead of the vague exceptional weather condition)
2024-01-20 15:01:49 +01:00
6aef5ffa19
Remove outdated TODO 2024-01-20 14:50:59 +01:00
a401bc172a
Remove text_fr and text_nl from forecast and add text instead (auto-detect instance language) 2024-01-20 14:39:42 +01:00
44dd78c077
Add attributes to warning binary sensor 2024-01-20 14:29:20 +01:00
1613d24f0c
Map freezing rain and freezing fog to rainy and fog respectively 2024-01-20 11:40:26 +01:00
b6e445f9fd
Merge pull request #4 from jdejaegh/warnings
Add binary sensor for weather warnings
2024-01-13 23:08:54 +01:00
102a939679
Update README 2024-01-13 23:03:01 +01:00
c2ca26d975
Fix typo 2024-01-13 22:23:29 +01:00
dceb98ae12
Add TODO comment 2024-01-13 22:22:39 +01:00
1c4fa60612
Fix test requirements 2024-01-13 22:00:05 +01:00
bd26a99b0c
Implement binary sensor for weather warning 2024-01-13 21:54:44 +01:00
5b02c8e29a
Merge pull request #3 from jdejaegh/main
Sync jdejaegh/warnings with jdejaegh/main
2024-01-12 22:42:52 +01:00
4a5734daf6
Set update interval to 7 minutes 2024-01-12 22:37:42 +01:00
f30821e0a8
Prepare support for weather warning 2024-01-12 22:30:38 +01:00
a8cb3ce620
Add Dutch translation 2024-01-11 22:08:39 +01:00
438f26f514
Fix typo in fr.json 2024-01-11 22:08:23 +01:00
67ff243991
Update French translation 2024-01-11 20:39:08 +01:00
a992f40227
Update en.json 2024-01-11 20:37:30 +01:00
cf9e361bb1
Merge pull request #2 from jdejaegh/error_handling
Improve error handling and add repairs
2024-01-11 18:43:53 +01:00
9925808b44
Remove unused fixture 2024-01-08 21:36:42 +01:00
e53f9aded4
Add test for config and repair flow 2024-01-08 21:32:28 +01:00
2cca89f5ba
Add tests for config flow 2024-01-08 20:20:31 +01:00
dcb4f09793
Adjust camera settings 2024-01-07 21:31:03 +01:00
90d4dd3a78
Start adding repairs and better error handling 2024-01-07 11:20:03 +01:00
0eb96a63bf
Refactor and add docstring 2024-01-06 15:10:28 +01:00
758eac09c2
Update README 2024-01-04 21:28:43 +01:00
829f6f46db
Fix typo 2024-01-04 21:21:35 +01:00
8b43469c17
Update README 2024-01-04 21:20:56 +01:00
af27179b08
Update requirements 2024-01-04 20:09:06 +01:00
19f1f593fd
Version bump 2024-01-02 21:50:41 +01:00
c5a9c2e613
Optimize imports 2024-01-02 21:50:28 +01:00
b7d39bf753
Add tests for new SVG camera and remove old ones 2024-01-02 21:45:37 +01:00
c9ec30b8b2
Refactor SVG camera 2024-01-02 20:45:14 +01:00
3915d9ff23
First try at supporting rain graph 2024-01-01 21:49:18 +01:00
27fab148b6
Prepare data structures for rain graph 2023-12-31 23:36:57 +01:00
77653976cf
Version bump 2023-12-30 18:38:49 +01:00
e31f14989a
Remove logging 2023-12-30 18:38:26 +01:00
b4685a397f
Add config migration test and optimize imports 2023-12-30 18:37:45 +01:00
e74e7c2873
Update English strings 2023-12-30 18:24:09 +01:00
0e58df9434
Add config option to use the deprecated forecast attribute 2023-12-30 18:23:36 +01:00
0ca408e0e9
Version bump 2023-12-29 23:19:34 +01:00
a2fc11ddbd
Cleanup code 2023-12-29 23:18:47 +01:00
2b8fa6e444
Add support for more config options 2023-12-29 23:05:50 +01:00
974d6baca2
Fix lowest temperature for next day if it is currently the night 2023-12-29 21:40:36 +01:00
f4cd89e3fe
Return tonight's forecast after sunset in daily view 2023-12-29 21:35:14 +01:00
becd6418de
Organize imports 2023-12-29 21:01:35 +01:00
fe0b3419ce
When getting daily forecast, the min temperature of the current day should be the min temperature of the coming night 2023-12-29 20:59:32 +01:00
15925baab7
Fix translation en.json nesting 2023-12-29 20:31:18 +01:00
d7b2e9a742
Update config flow and improve error handling 2023-12-29 20:27:07 +01:00
3c58da53e5
Fix typo 2023-12-29 14:13:06 +01:00
67ee56947e
Update README 2023-12-29 14:11:29 +01:00
cd48705c1b
Start adding multiple languages 2023-12-28 22:35:41 +01:00
b6e3482bf6
Add ressources 2023-12-28 21:54:43 +01:00
36fffbf91b
Version bump to 0.1.4-beta 2023-12-28 21:44:10 +01:00
c82de28cdc
Remove some logging 2023-12-28 21:42:37 +01:00
88f889757a
Add docstring and reformat files 2023-12-28 21:37:51 +01:00
aae39d8ddc
Add tests for weather radar data 2023-12-28 21:09:09 +01:00
f392e8b004
Refactor 2023-12-28 15:53:13 +01:00
ed20cd9922
Refactor and use latest observation as radar thumbnail 2023-12-28 15:30:30 +01:00
1f97db64ff
Initial support for rain radar 2023-12-27 23:31:48 +01:00
64 changed files with 15213 additions and 621 deletions

45
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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.

View 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.

View file

@ -3,6 +3,7 @@ name: Run Python tests
on:
push:
pull_request:
workflow_call:
jobs:
build:
@ -10,15 +11,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"]
python-version: ["3.13"]
steps:
- uses: szenius/set-timezone@v1.2
- uses: MathRobin/timezone-action@v1.1
with:
timezoneLinux: "Europe/Brussels"
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies

27
.github/workflows/release.yml vendored Normal file
View 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

View file

@ -1,6 +1,6 @@
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
of this software and associated documentation files (the "Software"), to deal

166
README.md
View file

@ -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 🇳🇱
**Note: this is still under development, new versions may not be backward compatible.**
## Installing via HACS
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=jdejaegh&repository=irm-kmi-ha&category=integration)
or
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
4. Restart Home Assistant
5. Configure the integration via the UI (search for 'IRM KMI')
## Set up the integration
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=irm_kmi)
or
1. Configure the integration via the UI (search for 'IRM KMI')
## Roadmap
## Features
- [X] Basic weather provider capability (current weather only)
- [X] Forecasts
- [X] Hourly
- [X] Daily
- [ ] Camera entity for the satellite view
- [X] Use UI to configure the integration
This integration provides the following things:
- A weather entity with current weather conditions
- Weather forecasts (hourly, daily and twice-daily) [using the service `weather.get_forecasts`](https://www.home-assistant.io/integrations/weather/#service-weatherget_forecasts)
- Short-term rain forecasts using the radar data using the [custom service `ìrm_kmi.get_forecasts_radar`](#custom-service-irm_kmiget_forecasts_radar)
- 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 was established based on my own interpretation of the icons and conditions.
| 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` |
| 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` |
| 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` |
| 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"/> <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 | | |
| 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` |
| 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` |
| 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-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` |
| windy | Wind | | |
| 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.

View file

@ -1,24 +1,36 @@
"""Integration for IRM KMI weather"""
# File inspired from https://github.com/ludeeus/integration_blueprint
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ZONE
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 .weather import IrmKmiWeather
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
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
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)
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:
"""Reload config entry."""
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)
await hass.config_entries.async_reload(entry.entry_id)
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

View file

@ -1,97 +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
_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):
"""Get API key."""
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
class IrmKmiApiClient:
"""Sample API Client."""
COORD_DECIMALS = 6
def __init__(self, session: aiohttp.ClientSession) -> None:
"""Sample API Client."""
self._session = session
self._base_url = "https://app.meteo.be/services/appv4/"
async def get_forecasts_coord(self, coord: dict) -> any:
"""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)
return await self._api_wrapper(
params={"s": "getForecasts"} | coord
)
async def _api_wrapper(
self,
params: dict,
path: str = "",
method: str = "get",
data: dict | None = None,
headers: dict | None = None
) -> any:
"""Get information from the API."""
if 's' not in params:
raise IrmKmiApiParametersError("No query provided as 's' argument for API")
else:
params['k'] = _api_key(params['s'])
try:
async with async_timeout.timeout(10):
_LOGGER.debug(f"Calling for {params}")
response = await self._session.request(
method=method,
url=f"{self._base_url}{path}",
headers=headers,
json=data,
params=params
)
_LOGGER.debug(f"API status code {response.status}")
response.raise_for_status()
return await response.json()
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

View 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

View file

@ -0,0 +1,88 @@
"""Create a radar view for IRM KMI weather"""
import logging
from aiohttp import web
from homeassistant.components.camera import Camera, async_get_still_stream
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 . import IrmKmiCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
"""Set up the camera entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([IrmKmiRadar(coordinator, entry)])
class IrmKmiRadar(CoordinatorEntity, Camera):
"""Representation of a radar view camera."""
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
def __init__(self,
coordinator: IrmKmiCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize IrmKmiRadar component."""
super().__init__(coordinator)
Camera.__init__(self)
self.content_type = 'image/svg+xml'
self._name = f"Radar {entry.title}"
self._attr_unique_id = entry.entry_id
self._attr_device_info = coordinator.shared_device_info
self._image_index = False
@property
def frame_interval(self) -> float:
"""Return the interval between frames of the mjpeg stream."""
return 1
async def async_camera_image(
self,
width: int | None = None,
height: int | None = None
) -> bytes | None:
"""Return still image to be used as thumbnail."""
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:
"""Generate an HTTP MJPEG stream from camera images."""
self._image_index = False
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:
"""Serve an HTTP MJPEG stream from the camera."""
return await self.handle_async_still_stream(request, self.frame_interval)
async def get_animated_svg(self) -> bytes | None:
"""Returns the animated svg for camera display"""
# If this is not done this way, the live view can only be opened once
self._image_index = not self._image_index
if self._image_index and self.coordinator.data.get('animation', None) is not None:
return await self.coordinator.data.get('animation').get_animated()
else:
return None
@property
def name(self) -> str:
"""Return the name of this camera."""
return self._name
@property
def extra_state_attributes(self) -> dict:
"""Return the camera state attributes."""
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

View file

@ -1,42 +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 async_timeout
import voluptuous as vol
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ZONE
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
from homeassistant.core import callback
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__)
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:
"""Define the user step of the configuration flow."""
errors = {}
if user_input is not None:
if 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])
self._abort_if_unique_id_configured()
state = self.hass.states.get(user_input[CONF_ZONE])
return self.async_create_entry(
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(
step_id="user",
errors=errors,
description_placeholders={'zone': user_input.get('zone') if user_input is not None else None},
data_schema=vol.Schema({
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.Required(CONF_ZONE): EntitySelector(
EntitySelectorConfig(domain=ZONE_DOMAIN),
),
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))
}
),
)

View file

@ -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,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_EXCEPTIONAL,
ATTR_CONDITION_FOG,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
@ -11,17 +12,58 @@ from homeassistant.components.weather import (ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
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'
PLATFORMS: list[Platform] = [Platform.WEATHER]
OUT_OF_BENELUX = ["außerhalb der Benelux (Brussels)",
DOMAIN: Final = 'irm_kmi'
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_FLOW_VERSION = 5
OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
"Hors de Belgique (Bxl)",
"Outside the Benelux (Brussels)",
"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
IRM_KMI_TO_HA_CONDITION_MAP = {
IRM_KMI_TO_HA_CONDITION_MAP: Final = {
(0, 'd'): ATTR_CONDITION_SUNNY,
(0, 'n'): ATTR_CONDITION_CLEAR_NIGHT,
(1, 'd'): ATTR_CONDITION_SUNNY,
@ -64,8 +106,8 @@ IRM_KMI_TO_HA_CONDITION_MAP = {
(19, 'n'): ATTR_CONDITION_POURING,
(20, 'd'): ATTR_CONDITION_SNOWY_RAINY,
(20, 'n'): ATTR_CONDITION_SNOWY_RAINY,
(21, 'd'): ATTR_CONDITION_EXCEPTIONAL,
(21, 'n'): ATTR_CONDITION_EXCEPTIONAL,
(21, 'd'): ATTR_CONDITION_RAINY,
(21, 'n'): ATTR_CONDITION_RAINY,
(22, 'd'): ATTR_CONDITION_SNOWY,
(22, 'n'): ATTR_CONDITION_SNOWY,
(23, 'd'): ATTR_CONDITION_SNOWY,
@ -76,6 +118,46 @@ IRM_KMI_TO_HA_CONDITION_MAP = {
(25, 'n'): ATTR_CONDITION_FOG,
(26, 'd'): ATTR_CONDITION_FOG,
(26, 'n'): ATTR_CONDITION_FOG,
(27, 'd'): ATTR_CONDITION_EXCEPTIONAL,
(27, 'n'): ATTR_CONDITION_EXCEPTIONAL
(27, 'd'): ATTR_CONDITION_FOG,
(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}

View file

@ -1,187 +1,149 @@
"""DataUpdateCoordinator for the IRM KMI integration."""
import logging
from datetime import datetime, timedelta
from typing import List
from datetime import timedelta
import async_timeout
from homeassistant.components.weather import Forecast
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator,
UpdateFailed)
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (
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 OUT_OF_BENELUX
from .data import IrmKmiForecast
from .const import OUT_OF_BENELUX, USER_AGENT
from .data import ProcessedCoordinatorData
from .utils import disable_from_config, get_config_value, preferred_language
_LOGGER = logging.getLogger(__name__)
class IrmKmiCoordinator(DataUpdateCoordinator):
class IrmKmiCoordinator(TimestampDataUpdateCoordinator):
"""Coordinator to update data from IRM KMI"""
def __init__(self, hass, zone):
"""Initialize my coordinator."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
# Name of the data. For logging purposes.
name="IRM KMI weather",
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(minutes=7),
)
self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass))
self._zone = zone
self._api = IrmKmiApiClientHa(session=async_get_clientsession(hass), user_agent=USER_AGENT, cdt_map=CDT_MAP)
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):
async def _async_update_data(self) -> ProcessedCoordinatorData:
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
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:
raise UpdateFailed(f"Zone '{self._zone}' not found")
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
async with async_timeout.timeout(10):
api_data = await self._api_client.get_forecasts_coord(
async with async_timeout.timeout(60):
await self._api.refresh_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'long': zone.attributes[ATTR_LONGITUDE]}
)
_LOGGER.debug(f"Observation for {api_data.get('cityName', '')}: {api_data.get('obs', '{}')}")
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:
_LOGGER.warning(f"Error communicating with API for general forecast: {err}. Keeping the old data.")
return self.data
else:
raise UpdateFailed(f"Error communicating with API for general forecast: {err}. "
f"Last success time is: {self.last_update_success_time}")
if api_data.get('cityName', None) in OUT_OF_BENELUX:
raise UpdateFailed(f"Zone '{self._zone}' is out of Benelux and forecast is only available in the Benelux")
if self._api.get_city() in OUT_OF_BENELUX:
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux. "
f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix "
f"this")
disable_from_config(self.hass, self.config_entry)
return self.process_api_data(api_data)
@staticmethod
def process_api_data(api_data):
# Process data to get current hour forecast
now_hourly = None
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
# 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')
# Put everything together
processed_data = {
'current_weather': {
'condition': CDT_MAP.get(
(api_data.get('obs', {}).get('ww'), api_data.get('obs', {}).get('dayNight')), None),
'temperature': api_data.get('obs', {}).get('temp'),
'wind_speed': now_hourly.get('windSpeedKm', None) if now_hourly is not None else None,
'wind_gust_speed': now_hourly.get('windPeakSpeedKm', None) if now_hourly is not None else None,
'wind_bearing': now_hourly.get('windDirectionText', {}).get('en') if now_hourly is not None else None,
'pressure': now_hourly.get('pressure', None) if now_hourly is not None else None,
'uv_index': uv_index
},
'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'))
}
return processed_data
@staticmethod
def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
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'
issue_registry.async_create_issue(
self.hass,
DOMAIN,
"zone_moved",
is_fixable=True,
severity=issue_registry.IssueSeverity.ERROR,
translation_key='zone_moved',
data={'config_entry_id': self.config_entry.entry_id, 'zone': self._zone},
translation_placeholders={'zone': self._zone}
)
return ProcessedCoordinatorData()
forecasts.append(forecast)
return await self.process_api_data()
return forecasts
async def async_refresh(self) -> None:
"""Refresh data and log errors."""
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
@staticmethod
def daily_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
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:
async def process_api_data(self) -> ProcessedCoordinatorData:
"""From the API data, create the object that will be used in the entities"""
tz = await dt.async_get_time_zone('Europe/Brussels')
lang = preferred_language(self.hass, self.config_entry)
try:
precipitation = float(f.get('precipQuantity'))
except TypeError:
pass
pollen = await self._api.get_pollen()
except IrmKmiApiError as err:
_LOGGER.warning(f"Could not get pollen data from the API: {err}. Keeping the same data.")
pollen = self.data.get('pollen', PollenParser.get_unavailable_data()) \
if self.data is not None else PollenParser.get_unavailable_data()
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
radar_animation = self._api.get_animation_data(tz, lang, self._style, self._dark_mode)
animation = await RainGraph(radar_animation,
country=self._api.get_country(),
style=self._style,
tz=tz,
dark_mode=self._dark_mode,
api_client=self._api
).build()
except ValueError:
animation = None
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')
# Make 'condition_evol' in a str instead of enum variant
daily_forecast = [
{**d, "condition_evol": d["condition_evol"].value}
if "condition_evol" in d and hasattr(d["condition_evol"], "value")
else d
for d in self._api.get_daily_forecast(tz, lang)
]
return ProcessedCoordinatorData(
current_weather=self._api.get_current_weather(tz),
daily_forecast=daily_forecast,
hourly_forecast=self._api.get_hourly_forecast(tz),
radar_forecast=self._api.get_radar_forecast(),
animation=animation,
warnings=self._api.get_warnings(lang),
pollen=pollen,
country=self._api.get_country()
)
forecasts.append(forecast)
if is_daytime or idx == 0:
n_days += 1
return forecasts

View file

@ -1,10 +1,17 @@
"""Data classes for IRM KMI integration"""
from typing import List, TypedDict
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 ProcessedCoordinatorData(TypedDict, total=False):
"""Data class that will be exposed to the entities consuming data from an IrmKmiCoordinator"""
current_weather: CurrentWeatherData
hourly_forecast: List[Forecast] | None
daily_forecast: List[IrmKmiForecast] | None
radar_forecast: List[Forecast] | None
animation: RainGraph | None
warnings: List[WarningData]
pollen: dict
country: str

View file

@ -0,0 +1,5 @@
{
"services": {
"get_forecasts_radar": "mdi:weather-cloudy-clock"
}
}

View file

@ -3,11 +3,13 @@
"name": "IRM KMI Weather Belgium",
"codeowners": ["@jdejaegh"],
"config_flow": true,
"dependencies": [],
"dependencies": ["zone"],
"documentation": "https://github.com/jdejaegh/irm-kmi-ha/",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
"requirements": [],
"version": "0.1.3-beta"
"requirements": [
"irm-kmi-api==0.2.0"
],
"version": "0.3.2"
}

View 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)

View 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)

View 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:

View 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."
}
}
}
}
}

View 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é."
}
}
}
}
}

View 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."
}
}
}
}
}

View 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."
}
}
}
}
}

View 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)

View file

@ -1,50 +1,71 @@
""""Support for IRM KMI weather."""
"""Support for IRM KMI weather."""
import logging
from datetime import datetime
from typing import List
import voluptuous as vol
from homeassistant.components.weather import (Forecast, WeatherEntity,
WeatherEntityFeature)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (UnitOfPrecipitationDepth, UnitOfPressure,
UnitOfSpeed, UnitOfTemperature)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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 .utils import get_config_value
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
"""Set up the weather entry."""
add_services()
_LOGGER.debug(f'async_setup_entry entry is: {entry}')
coordinator = hass.data[DOMAIN][entry.entry_id]
await coordinator.async_config_entry_first_refresh()
async_add_entities(
[IrmKmiWeather(coordinator, entry)]
async_add_entities([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):
_attr_attribution = "Weather data from the Royal Meteorological Institute of Belgium meteo.be"
def __init__(self,
coordinator: IrmKmiCoordinator,
entry: ConfigEntry
) -> None:
super().__init__(coordinator)
WeatherEntity.__init__(self)
self._name = entry.title
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="IRM KMI",
name=entry.title
)
self._attr_device_info = coordinator.shared_device_info
self._deprecated_forecast_as = get_config_value(entry, CONF_USE_DEPRECATED_FORECAST)
if self._deprecated_forecast_as != OPTION_DEPRECATED_FORECAST_NOT_USED:
_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
def supported_features(self) -> WeatherEntityFeature:
@ -60,11 +81,11 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
@property
def condition(self) -> str | None:
return self.coordinator.data.get('current_weather').get('condition')
return self.coordinator.data.get('current_weather', {}).get('condition')
@property
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
def native_temperature_unit(self) -> str | None:
@ -76,15 +97,15 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
@property
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
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
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
def native_precipitation_unit(self) -> str | None:
@ -92,7 +113,7 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
@property
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
def native_pressure_unit(self) -> str | None:
@ -100,16 +121,72 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
@property
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:
return self.coordinator.data.get('daily_forecast')
async def async_forecast_daily(self) -> list[Forecast] | None:
data: list[Forecast] = self.coordinator.data.get('daily_forecast')
if not isinstance(data, list):
return None
return [f for f in data if f.get('is_daytime')]
return self.daily_forecast()
async def async_forecast_hourly(self) -> list[Forecast] | None:
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}

View file

@ -1,5 +1,6 @@
{
"name": "IRM KMI Belgian weather",
"country": ["BE", "NL", "LU"],
"render_readme": true
"render_readme": true,
"homeassistant": "2024.6.0"
}

BIN
img/camera_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
img/camera_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
img/camera_sat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

BIN
img/forecast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
img/monday.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
img/pollens.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

9
img/radar_example.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 471 KiB

BIN
img/sensors.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

18
pyproject.toml Normal file
View 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}'"]

View file

@ -1,4 +1,4 @@
aiohttp==3.9.1
async_timeout==4.0.3
homeassistant==2023.12.3
voluptuous==0.13.1
aiohttp>=3.11.13
homeassistant==2025.6.1
voluptuous==0.15.2
irm-kmi-api==0.2.0

View file

@ -1,4 +1,6 @@
homeassistant==2023.12.3
pytest==7.4.3
pytest_homeassistant_custom_component==0.13.85
freezegun==1.2.2
homeassistant==2025.6.1
pytest_homeassistant_custom_component==0.13.252
pytest
freezegun
isort
bumpver

View file

@ -1,6 +1,5 @@
[tool:pytest]
testpaths = tests
norecursedirs = .git
addopts =
--cov=custom_components
addopts = -s -v
asyncio_mode = auto

View file

@ -2,16 +2,34 @@
from __future__ import annotations
import json
from collections.abc import Generator
from datetime import datetime, timedelta
from typing import Generator
from unittest.mock import MagicMock, patch
import pytest
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,
load_fixture)
from custom_components.irm_kmi.api import IrmKmiApiParametersError
from custom_components.irm_kmi.const import DOMAIN
from custom_components.irm_kmi import OPTION_STYLE_STD
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)
@ -25,7 +43,26 @@ def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry(
title="Home",
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",
)
@ -39,15 +76,61 @@ def mock_setup_entry() -> Generator[None, None, None]:
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()
def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast.json"
forecast = json.loads(load_fixture(fixture))
print(type(forecast))
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:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
@ -55,14 +138,13 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
@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."""
fixture: str = "forecast_out_of_benelux.json"
forecast = json.loads(load_fixture(fixture))
print(type(forecast))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
"custom_components.irm_kmi.repairs.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
@ -73,8 +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]:
"""Return a mocked IrmKmi api client."""
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.side_effect = IrmKmiApiParametersError
irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiParametersError
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()
sequence = [
AnimationFrameData(
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)
]
return RadarAnimationData(
sequence=sequence,
most_recent_image_idx=2,
hint="Testing SVG camera",
unit="mm/10min",
location=location
)

1668
tests/fixtures/be_forecast_warning.json vendored Normal file

File diff suppressed because it is too large Load diff

BIN
tests/fixtures/clouds_be.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
tests/fixtures/clouds_nl.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -66,7 +66,8 @@
"dayNight": "d",
"text": {
"nl": "Foo",
"fr": "Bar"
"fr": "Bar",
"en": "Hey!"
},
"dawnRiseSeconds": "31440",
"dawnSetSeconds": "60180",
@ -1359,7 +1360,7 @@
}
],
"animation": {
"localisationLayer": "https:\/\/app.meteo.be\/services\/appv4\/?s=getLocalizationLayer&ins=92094&f=2&k=2c886c51e74b671c8fc3865f4a0e9318",
"localisationLayer": "https:\/\/app.meteo.be\/services\/appv4\/?s=getLocalizationLayerBE&ins=92094&f=2&k=2c886c51e74b671c8fc3865f4a0e9318",
"localisationLayerRatioX": 0.6667,
"localisationLayerRatioY": 0.523,
"speed": 0.3,
@ -1407,7 +1408,7 @@
{
"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",
"value": 0,
"value": 0.1,
"position": 0,
"positionLower": 0,
"positionHigher": 0
@ -1415,7 +1416,7 @@
{
"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",
"value": 0,
"value": 0.01,
"position": 0,
"positionLower": 0,
"positionHigher": 0
@ -1423,7 +1424,7 @@
{
"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",
"value": 0,
"value": 0.12,
"position": 0,
"positionLower": 0,
"positionHigher": 0
@ -1431,7 +1432,7 @@
{
"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",
"value": 0,
"value": 1.2,
"position": 0,
"positionLower": 0,
"positionHigher": 0
@ -1439,7 +1440,7 @@
{
"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",
"value": 0,
"value": 2,
"position": 0,
"positionLower": 0,
"positionHigher": 0
@ -1459,158 +1460,6 @@
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T18:50:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261800&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:00:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261810&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:10:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261820&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:20:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261830&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:30:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261840&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:40:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261850&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T19:50:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261900&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:00:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261910&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:10:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261920&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:20:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261930&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:30:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261940&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:40:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312261950&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T20:50:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262000&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:00:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262010&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:10:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262020&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:20:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262030&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:30:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262040&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:40:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262050&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
},
{
"time": "2023-12-26T21:50:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202312262100&f=2&k=4a71be18d6cb09f98c49c53f59902f8c&d=202312261720",
"value": 0,
"position": 0,
"positionLower": 0,
"positionHigher": 0
}
],
"threshold": [],

1676
tests/fixtures/forecast_ams_no_ww.json vendored Normal file

File diff suppressed because it is too large Load diff

1355
tests/fixtures/forecast_nl.json vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

BIN
tests/fixtures/loc_layer_be_n.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
tests/fixtures/loc_layer_nl.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
tests/fixtures/loc_layer_nl_d.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because it is too large Load diff

56
tests/fixtures/new_two_pollens.svg vendored Normal file
View 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

File diff suppressed because it is too large Load diff

42
tests/fixtures/pollen.svg vendored Normal file
View 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
View 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

View file

@ -7,13 +7,20 @@ from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_ZONE
from homeassistant.core import HomeAssistant
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(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_in_benelux: MagicMock
) -> None:
"""Test the full user configuration flow."""
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(
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
assert result2.get("title") == "IRM KMI"
assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME}
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 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

View file

@ -1,86 +1,111 @@
import json
from datetime import datetime
from datetime import timedelta
from freezegun import freeze_time
from homeassistant.components.weather import (ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_RAINY, Forecast)
from pytest_homeassistant_custom_component.common import load_fixture
from homeassistant.components.weather import ATTR_CONDITION_CLOUDY
from homeassistant.core import HomeAssistant
from irm_kmi_api.data import CurrentWeatherData, IrmKmiRadarForecast
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.data import IrmKmiForecast
from custom_components.irm_kmi.data import ProcessedCoordinatorData
from tests.conftest import get_api_data, get_api_with_data
def get_api_data() -> dict:
fixture: str = "forecast.json"
return json.loads(load_fixture(fixture))
async def test_jules_forgot_to_revert_update_interval_before_pushing(
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.028724'))
def test_current_weather() -> None:
api_data = get_api_data()
result = IrmKmiCoordinator.process_api_data(api_data).get('current_weather')
async def test_refresh_succeed_even_when_pollen_and_radar_fail(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
):
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
coordinator._api._api_data = get_api_data("forecast.json")
expected = {
'condition': ATTR_CONDITION_CLOUDY,
'temperature': 7,
'wind_speed': 5,
'wind_gust_speed': None,
'wind_bearing': 'WSW',
'pressure': 1020,
'uv_index': .7
}
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') == PollenParser.get_unavailable_data()
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.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-26T18:30:00.028724'))
def test_daily_forecast() -> None:
api_data = get_api_data().get('for', {}).get('daily')
result = IrmKmiCoordinator.daily_list_to_forecast(api_data)
def test_radar_forecast() -> None:
api = get_api_with_data("forecast.json")
result = api.get_radar_forecast()
assert isinstance(result, list)
assert len(result) == 8
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')
]
expected = IrmKmiForecast(
datetime='2023-12-27',
condition=ATTR_CONDITION_PARTLYCLOUDY,
native_precipitation=0,
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 expected == result
def test_radar_forecast_rain_interval() -> None:
api = get_api_with_data('forecast_with_rain_on_radar.json')
result = api.get_radar_forecast()
_12 = IrmKmiRadarForecast(
datetime='2024-05-30T18:00:00+02:00',
native_precipitation=0.89,
might_rain=True,
rain_forecast_max=1.12,
rain_forecast_min=0.50,
unit='mm/10min'
)
assert result[1] == expected
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00.028724'))
def test_hourly_forecast() -> None:
api_data = get_api_data().get('for', {}).get('hourly')
result = IrmKmiCoordinator.hourly_list_to_forecast(api_data)
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
_13 = IrmKmiRadarForecast(
datetime="2024-05-30T18:10:00+02:00",
native_precipitation=0.83,
might_rain=True,
rain_forecast_max=1.09,
rain_forecast_min=0.64,
unit='mm/10min'
)
assert result[8] == expected
assert result[12] == _12
assert result[13] == _13

View 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

View file

@ -8,7 +8,11 @@ from homeassistant.const import CONF_ZONE
from homeassistant.core import HomeAssistant
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(
@ -52,7 +56,7 @@ async def test_config_entry_not_ready(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
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
@ -75,27 +79,27 @@ async def test_config_entry_zone_removed(
assert "Zone 'zone.castle' not found" in caplog.text
async def test_zone_out_of_benelux(
async def test_config_entry_migration(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mock_irm_kmi_api_out_benelux: AsyncMock
) -> None:
"""Test the IRM KMI when configuration zone is out of Benelux"""
"""Test the IRM KMI configuration entry not ready."""
mock_config_entry = MockConfigEntry(
title="London",
title="My Castle",
domain=DOMAIN,
data={CONF_ZONE: "zone.london"},
unique_id="zone.london",
data={CONF_ZONE: "zone.castle"},
unique_id="zone.castle",
)
hass.states.async_set(
"zone.london",
0,
{"latitude": 51.5072, "longitude": 0.1276},
)
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
assert "Zone 'zone.london' is out of Benelux" in caplog.text
success = await async_migrate_entry(hass, mock_config_entry)
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
View 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
View 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
View 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
View 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)