Compare commits

..

155 commits
0.2.8 ... main

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
61 changed files with 8872 additions and 1801 deletions

View file

@ -3,6 +3,7 @@ name: Run Python tests
on:
push:
pull_request:
workflow_call:
jobs:
build:
@ -10,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12"]
python-version: ["3.13"]
steps:
- uses: MathRobin/timezone-action@v1.1

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

View file

@ -7,11 +7,22 @@ Although the provider is Belgian, the data is available for Belgium 🇧🇪, Lu
## 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](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')
## Features
@ -20,8 +31,10 @@ 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:
@ -74,6 +87,12 @@ Mapping was established based on my own interpretation of the icons and conditio
## 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.
@ -100,6 +119,7 @@ The following table summarizes the different known warning types. Other warning
| 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 |
@ -109,18 +129,52 @@ The following table summarizes the different known warning types. Other warning
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: active, green, yellow, orange,
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 oak and ash have the 'active' state, birch would be 'purple' and alder would be 'green'.
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

View file

@ -6,11 +6,11 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from irm_kmi_api.const import OPTION_STYLE_STD
from .const import (CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST,
CONFIG_FLOW_VERSION, DOMAIN,
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD,
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
@ -22,6 +22,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator = IrmKmiCoordinator(hass, entry)
# 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()
@ -45,8 +47,7 @@ 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):
@ -55,18 +56,26 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry):
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}
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)
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}
config_entry.version = 3
hass.config_entries.async_update_entry(config_entry, data=new)
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")

View file

@ -1,90 +0,0 @@
"""API Client for IRM KMI weather"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import socket
from datetime import datetime
import aiohttp
import async_timeout
from aiohttp import ClientResponse
_LOGGER = logging.getLogger(__name__)
class IrmKmiApiError(Exception):
"""Exception to indicate a general API error."""
class IrmKmiApiCommunicationError(IrmKmiApiError):
"""Exception to indicate a communication error."""
class IrmKmiApiParametersError(IrmKmiApiError):
"""Exception to indicate a parameter error."""
def _api_key(method_name: str) -> str:
"""Get API key."""
return hashlib.md5(f"r9EnW374jkJ9acc;{method_name};{datetime.now().strftime('%d/%m/%Y')}".encode()).hexdigest()
class IrmKmiApiClient:
"""API client for IRM KMI weather data"""
COORD_DECIMALS = 6
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
self._base_url = "https://app.meteo.be/services/appv4/"
async def get_forecasts_coord(self, coord: dict) -> dict:
"""Get forecasts for given city."""
assert 'lat' in coord
assert 'long' in coord
coord['lat'] = round(coord['lat'], self.COORD_DECIMALS)
coord['long'] = round(coord['long'], self.COORD_DECIMALS)
response = await self._api_wrapper(params={"s": "getForecasts", "k": _api_key("getForecasts")} | coord)
return await response.json()
async def get_image(self, url, params: dict | None = None) -> bytes:
"""Get the image at the specified url with the parameters"""
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
return await r.read()
async def get_svg(self, url, params: dict | None = None) -> str:
"""Get SVG as str at the specified url with the parameters"""
r: ClientResponse = await self._api_wrapper(base_url=url, params={} if params is None else params)
return await r.text()
async def _api_wrapper(
self,
params: dict,
base_url: str | None = None,
path: str = "",
method: str = "get",
data: dict | None = None,
headers: dict | None = None,
) -> any:
"""Get information from the API."""
try:
async with async_timeout.timeout(10):
response = await self._session.request(
method=method,
url=f"{self._base_url if base_url is None else base_url}{path}",
headers=headers,
json=data,
params=params
)
response.raise_for_status()
return response
except asyncio.TimeoutError as exception:
raise IrmKmiApiCommunicationError("Timeout error fetching information") from exception
except (aiohttp.ClientError, socket.gaierror) as exception:
raise IrmKmiApiCommunicationError("Error fetching information") from exception
except Exception as exception: # pylint: disable=broad-except
raise IrmKmiApiError(f"Something really wrong happened! {exception}") from exception

View file

@ -1,8 +1,6 @@
"""Sensor to signal weather warning from the IRM KMI"""
import datetime
import logging
import pytz
from homeassistant.components import binary_sensor
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass,
BinarySensorEntity)
@ -10,8 +8,9 @@ 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 custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
from . import DOMAIN, IrmKmiCoordinator
_LOGGER = logging.getLogger(__name__)
@ -25,6 +24,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
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
@ -42,7 +43,7 @@ class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
if self.coordinator.data.get('warnings') is None:
return False
now = datetime.datetime.now(tz=pytz.timezone(self.hass.config.time_zone))
now = dt.now()
for item in self.coordinator.data.get('warnings'):
if item.get('starts_at') < now < item.get('ends_at'):
return True
@ -54,7 +55,7 @@ class IrmKmiWarning(CoordinatorEntity, BinarySensorEntity):
"""Return the warning sensor attributes."""
attrs = {"warnings": self.coordinator.data.get('warnings', [])}
now = datetime.datetime.now(tz=pytz.timezone(self.hass.config.time_zone))
now = dt.now()
for warning in attrs['warnings']:
warning['is_active'] = warning.get('starts_at') < now < warning.get('ends_at')

View file

@ -25,6 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
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,
@ -44,19 +46,15 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
"""Return the interval between frames of the mjpeg stream."""
return 1
def camera_image(self,
width: int | None = None,
height: int | None = None) -> bytes | None:
"""Return still image to be used as thumbnail."""
return self.coordinator.data.get('animation', {}).get('svg_still')
async def async_camera_image(
self,
width: int | None = None,
height: int | None = None
) -> bytes | None:
"""Return still image to be used as thumbnail."""
return self.camera_image()
if self.coordinator.data.get('animation', None) is not None:
return await self.coordinator.data.get('animation').get_still()
return None
async def handle_async_still_stream(self, request: web.Request, interval: float) -> web.StreamResponse:
"""Generate an HTTP MJPEG stream from camera images."""
@ -71,8 +69,8 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
"""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:
return self.coordinator.data.get('animation', {}).get('svg_animated')
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
@ -84,5 +82,7 @@ class IrmKmiRadar(CoordinatorEntity, Camera):
@property
def extra_state_attributes(self) -> dict:
"""Return the camera state attributes."""
attrs = {"hint": self.coordinator.data.get('animation', {}).get('hint')}
rain_graph = self.coordinator.data.get('animation', None)
hint = rain_graph.get_hint() if rain_graph is not None else None
attrs = {"hint": hint}
return attrs

View file

@ -14,13 +14,15 @@ from homeassistant.helpers.selector import (EntitySelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode)
from irm_kmi_api.api import IrmKmiApiClient
from .api import IrmKmiApiClient
from .const import (CONF_DARK_MODE, CONF_STYLE, CONF_STYLE_OPTIONS,
CONF_USE_DEPRECATED_FORECAST,
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,
OPTION_STYLE_STD, OUT_OF_BENELUX)
OUT_OF_BENELUX, USER_AGENT)
from .utils import get_config_value
_LOGGER = logging.getLogger(__name__)
@ -49,9 +51,11 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors:
api_data = {}
try:
async with async_timeout.timeout(10):
async with (async_timeout.timeout(60)):
api_data = await IrmKmiApiClient(
session=async_get_clientsession(self.hass)).get_forecasts_coord(
session=async_get_clientsession(self.hass),
user_agent=USER_AGENT
).get_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'long': zone.attributes[ATTR_LONGITUDE]}
)
@ -71,7 +75,8 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
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_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST],
CONF_LANGUAGE_OVERRIDE: user_input[CONF_LANGUAGE_OVERRIDE]},
)
return self.async_show_form(
@ -92,7 +97,12 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
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))
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))
}))
@ -100,29 +110,36 @@ class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
class IrmKmiOptionFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
self.current_config_entry = config_entry
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug(user_input)
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(CONF_STYLE, default=get_config_value(self.config_entry, CONF_STYLE)):
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.config_entry, CONF_DARK_MODE)): bool,
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.config_entry, 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))
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,6 +1,7 @@
"""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_FOG,
@ -11,11 +12,14 @@ 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: Final = 'irm_kmi'
PLATFORMS: Final = [Platform.WEATHER, Platform.CAMERA, Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_FLOW_VERSION = 3
CONFIG_FLOW_VERSION = 5
OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
"Hors de Belgique (Bxl)",
@ -23,10 +27,6 @@ OUT_OF_BENELUX: Final = ["außerhalb der Benelux (Brussels)",
"Buiten de Benelux (Brussel)"]
LANGS: Final = ['en', 'fr', 'nl', 'de']
OPTION_STYLE_STD: Final = 'standard_style'
OPTION_STYLE_CONTRAST: Final = 'contrast_style'
OPTION_STYLE_YELLOW_RED: Final = 'yellow_red_style'
OPTION_STYLE_SATELLITE: Final = 'satellite_style'
CONF_STYLE: Final = "style"
CONF_STYLE_OPTIONS: Final = [
@ -38,13 +38,6 @@ CONF_STYLE_OPTIONS: Final = [
CONF_DARK_MODE: Final = "dark_mode"
STYLE_TO_PARAM_MAP: Final = {
OPTION_STYLE_STD: 1,
OPTION_STYLE_CONTRAST: 2,
OPTION_STYLE_YELLOW_RED: 3,
OPTION_STYLE_SATELLITE: 4
}
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'
@ -58,6 +51,12 @@ CONF_USE_DEPRECATED_FORECAST_OPTIONS: Final = [
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"
@ -123,22 +122,42 @@ IRM_KMI_TO_HA_CONDITION_MAP: Final = {
(27, 'n'): ATTR_CONDITION_FOG
}
MAP_WARNING_ID_TO_SLUG: Final = {
0: 'wind',
1: 'rain',
2: 'ice_or_snow',
3: 'thunder',
7: 'fog',
9: 'cold',
12: 'thunder_wind_rain',
13: 'thunderstorm_strong_gusts',
14: 'thunderstorm_large_rainfall',
15: 'storm_surge',
17: 'coldspell'}
POLLEN_NAMES: Final = {'Alder', 'Ash', 'Birch', 'Grasses', 'Hazel', 'Mugwort', 'Oak'}
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,37 +1,32 @@
"""DataUpdateCoordinator for the IRM KMI integration."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Any, List, Tuple
from datetime import timedelta
import async_timeout
import pytz
from homeassistant.components.weather import Forecast
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.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator,
UpdateFailed)
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
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 LANGS
from .const import MAP_WARNING_ID_TO_SLUG as SLUG_MAP
from .const import OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP
from .data import (AnimationFrameData, CurrentWeatherData, IrmKmiForecast,
ProcessedCoordinatorData, RadarAnimationData, WarningData)
from .pollen import PollenParser
from .rain_graph import RainGraph
from .utils import disable_from_config, get_config_value
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: HomeAssistant, entry: ConfigEntry):
@ -39,20 +34,20 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
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._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._config_entry = entry
self.shared_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="IRM KMI",
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, self.config_entry)),
name=f"{entry.title}"
)
@ -62,27 +57,35 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
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', '{}')}")
_LOGGER.debug(f"Full data: {api_data}")
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:
_LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in 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)
disable_from_config(self.hass, self.config_entry)
issue_registry.async_create_issue(
self.hass,
@ -91,357 +94,56 @@ class IrmKmiCoordinator(DataUpdateCoordinator):
is_fixable=True,
severity=issue_registry.IssueSeverity.ERROR,
translation_key='zone_moved',
data={'config_entry_id': self._config_entry.entry_id, 'zone': self._zone},
data={'config_entry_id': self.config_entry.entry_id, 'zone': self._zone},
translation_placeholders={'zone': self._zone}
)
return ProcessedCoordinatorData()
return await self.process_api_data(api_data)
return await self.process_api_data()
async def async_refresh(self) -> None:
"""Refresh data and log errors."""
await self._async_refresh(log_failures=True, raise_on_entry_error=True)
async def _async_animation_data(self, api_data: dict) -> RadarAnimationData:
"""From the API data passed in, call the API to get all the images and create the radar animation data object.
Frames from the API are merged with the background map and the location marker to create each frame."""
animation_data = api_data.get('animation', {}).get('sequence')
localisation_layer_url = api_data.get('animation', {}).get('localisationLayer')
country = api_data.get('country', '')
if animation_data is None or localisation_layer_url is None or not isinstance(animation_data, list):
return RadarAnimationData()
try:
images_from_api = await self.download_images_from_api(animation_data, country, localisation_layer_url)
except IrmKmiApiError:
_LOGGER.warning(f"Could not get images for weather radar")
return RadarAnimationData()
localisation = images_from_api[0]
images_from_api = images_from_api[1:]
lang = self.hass.config.language if self.hass.config.language in LANGS else 'en'
radar_animation = RadarAnimationData(
hint=api_data.get('animation', {}).get('sequenceHint', {}).get(lang),
unit=api_data.get('animation', {}).get('unit', {}).get(lang),
location=localisation
)
rain_graph = self.create_rain_graph(radar_animation, animation_data, country, images_from_api)
radar_animation['svg_animated'] = rain_graph.get_svg_string()
radar_animation['svg_still'] = rain_graph.get_svg_string(still_image=True)
return radar_animation
async def _async_pollen_data(self, api_data: dict) -> dict:
_LOGGER.debug("Getting pollen data from API")
svg_url = None
for module in api_data.get('module', []):
_LOGGER.debug(f"module: {module}")
if module.get('type', None) == 'svg':
url = module.get('data', {}).get('url', {}).get('en', '')
if 'pollen' in url:
svg_url = url
break
if svg_url is None:
return PollenParser.get_default_data()
try:
_LOGGER.debug(f"Requesting pollen SVG at url {svg_url}")
pollen_svg: str = await self._api_client.get_svg(svg_url)
except IrmKmiApiError:
_LOGGER.warning(f"Could not get pollen data from the API")
return PollenParser.get_default_data()
return PollenParser(pollen_svg).get_pollen_data()
async def process_api_data(self, api_data: dict) -> ProcessedCoordinatorData:
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:
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()
try:
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
# 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=IrmKmiCoordinator.current_weather_from_data(api_data),
daily_forecast=self.daily_list_to_forecast(api_data.get('for', {}).get('daily')),
hourly_forecast=IrmKmiCoordinator.hourly_list_to_forecast(api_data.get('for', {}).get('hourly')),
animation=await self._async_animation_data(api_data=api_data),
warnings=self.warnings_from_data(api_data.get('for', {}).get('warning')),
pollen=await self._async_pollen_data(api_data=api_data)
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()
)
async def download_images_from_api(self,
animation_data: list,
country: str,
localisation_layer_url: str) -> tuple[Any]:
"""Download a batch of images to create the radar frames."""
coroutines = list()
coroutines.append(
self._api_client.get_image(localisation_layer_url,
params={'th': 'd' if country == 'NL' or not self._dark_mode else 'n'}))
for frame in animation_data:
if frame.get('uri', None) is not None:
coroutines.append(
self._api_client.get_image(frame.get('uri'), params={'rs': STYLE_TO_PARAM_MAP[self._style]}))
async with async_timeout.timeout(20):
images_from_api = await asyncio.gather(*coroutines)
_LOGGER.debug(f"Just downloaded {len(images_from_api)} images")
return images_from_api
@staticmethod
def current_weather_from_data(api_data: dict) -> CurrentWeatherData:
"""Parse the API data to build a CurrentWeatherData."""
# 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
break
# Get UV index
module_data = api_data.get('module', None)
uv_index = None
if not (module_data is None or not isinstance(module_data, list)):
for module in module_data:
if module.get('type', None) == 'uv':
uv_index = module.get('data', {}).get('levelValue')
try:
pressure = float(now_hourly.get('pressure', None)) if now_hourly is not None else None
except (TypeError, ValueError):
pressure = None
try:
wind_speed = float(now_hourly.get('windSpeedKm', None)) if now_hourly is not None else None
except (TypeError, ValueError):
wind_speed = None
try:
wind_gust_speed = float(now_hourly.get('windPeakSpeedKm', None)) if now_hourly is not None else None
except (TypeError, ValueError):
wind_gust_speed = None
try:
temperature = float(api_data.get('obs', {}).get('temp'))
except (TypeError, ValueError):
temperature = None
try:
dir_cardinal = now_hourly.get('windDirectionText', {}).get('en') if now_hourly is not None else None
if dir_cardinal == 'VAR' or now_hourly is None:
wind_bearing = None
else:
wind_bearing = (float(now_hourly.get('windDirection')) + 180) % 360
except (TypeError, ValueError):
wind_bearing = None
current_weather = CurrentWeatherData(
condition=CDT_MAP.get((api_data.get('obs', {}).get('ww'), api_data.get('obs', {}).get('dayNight')), None),
temperature=temperature,
wind_speed=wind_speed,
wind_gust_speed=wind_gust_speed,
wind_bearing=wind_bearing,
pressure=pressure,
uv_index=uv_index
)
if api_data.get('country', '') == 'NL':
current_weather['wind_speed'] = api_data.get('obs', {}).get('windSpeedKm')
if api_data.get('obs', {}).get('windDirectionText', {}).get('en') == 'VAR':
current_weather['wind_bearing'] = None
else:
try:
current_weather['wind_bearing'] = (float(api_data.get('obs', {}).get('windDirection')) + 180) % 360
except ValueError:
current_weather['wind_bearing'] = None
return current_weather
@staticmethod
def hourly_list_to_forecast(data: List[dict] | None) -> List[Forecast] | None:
"""Parse data from the API to create a list of hourly forecasts"""
if data is None or not isinstance(data, list) or len(data) == 0:
return None
forecasts = list()
day = datetime.now()
for f in data:
if 'dateShow' in f:
day = day + timedelta(days=1)
hour = f.get('hour', None)
if hour is None:
continue
precipitation_probability = None
if f.get('precipChance', None) is not None:
precipitation_probability = int(f.get('precipChance'))
ww = None
if f.get('ww', None) is not None:
ww = int(f.get('ww'))
wind_bearing = None
if f.get('windDirectionText', {}).get('en') != 'VAR':
try:
wind_bearing = (float(f.get('windDirection')) + 180) % 360
except (TypeError, ValueError):
pass
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=wind_bearing,
native_pressure=f.get('pressure', None),
is_daytime=f.get('dayNight', None) == 'd'
)
forecasts.append(forecast)
return forecasts
def daily_list_to_forecast(self, data: List[dict] | None) -> List[Forecast] | None:
"""Parse data from the API to create a list of daily forecasts"""
if data is None or not isinstance(data, list) or len(data) == 0:
return None
forecasts = list()
n_days = 0
for (idx, f) in enumerate(data):
precipitation = None
if f.get('precipQuantity', None) is not None:
try:
precipitation = float(f.get('precipQuantity'))
except (TypeError, ValueError):
pass
native_wind_gust_speed = None
if f.get('wind', {}).get('peakSpeed') is not None:
try:
native_wind_gust_speed = int(f.get('wind', {}).get('peakSpeed'))
except (TypeError, ValueError):
pass
wind_bearing = None
if f.get('wind', {}).get('dirText', {}).get('en') != 'VAR':
try:
wind_bearing = (float(f.get('wind', {}).get('dir')) + 180) % 360
except (TypeError, ValueError):
pass
is_daytime = f.get('dayNight', None) == 'd'
forecast = IrmKmiForecast(
datetime=(datetime.now() + timedelta(days=n_days)).strftime('%Y-%m-%d')
if is_daytime else datetime.now().strftime('%Y-%m-%d'),
condition=CDT_MAP.get((f.get('ww1', None), f.get('dayNight', None)), None),
native_precipitation=precipitation,
native_temperature=f.get('tempMax', None),
native_templow=f.get('tempMin', None),
native_wind_gust_speed=native_wind_gust_speed,
native_wind_speed=f.get('wind', {}).get('speed'),
precipitation_probability=f.get('precipChance', None),
wind_bearing=wind_bearing,
is_daytime=is_daytime,
text=f.get('text', {}).get(self.hass.config.language, ""),
)
# Swap temperature and templow if needed
if (forecast['native_templow'] is not None
and forecast['native_temperature'] is not None
and forecast['native_templow'] > forecast['native_temperature']):
(forecast['native_templow'], forecast['native_temperature']) = \
(forecast['native_temperature'], forecast['native_templow'])
forecasts.append(forecast)
if is_daytime or idx == 0:
n_days += 1
return forecasts
def create_rain_graph(self,
radar_animation: RadarAnimationData,
api_animation_data: List[dict],
country: str,
images_from_api: Tuple[bytes],
) -> RainGraph:
"""Create a RainGraph object that is ready to output animated and still SVG images"""
sequence: List[AnimationFrameData] = list()
tz = pytz.timezone(self.hass.config.time_zone)
current_time = datetime.now(tz=tz)
most_recent_frame = None
for idx, item in enumerate(api_animation_data):
frame = AnimationFrameData(
image=images_from_api[idx],
time=datetime.fromisoformat(item.get('time')) if item.get('time', None) is not None else None,
value=item.get('value', 0),
position=item.get('position', 0),
position_lower=item.get('positionLower', 0),
position_higher=item.get('positionHigher', 0)
)
sequence.append(frame)
if most_recent_frame is None and current_time < frame['time']:
most_recent_frame = idx - 1 if idx > 0 else idx
radar_animation['sequence'] = sequence
radar_animation['most_recent_image_idx'] = most_recent_frame
satellite_mode = self._style == OPTION_STYLE_SATELLITE
if country == 'NL':
image_path = "custom_components/irm_kmi/resources/nl.png"
bg_size = (640, 600)
else:
image_path = (f"custom_components/irm_kmi/resources/be_"
f"{'satellite' if satellite_mode else 'black' if self._dark_mode else 'white'}.png")
bg_size = (640, 490)
return RainGraph(radar_animation, image_path, bg_size,
config_dir=self.hass.config.config_dir,
dark_mode=self._dark_mode,
tz=self.hass.config.time_zone)
def warnings_from_data(self, warning_data: list | None) -> List[WarningData]:
"""Create a list of warning data instances based on the api data"""
if warning_data is None or not isinstance(warning_data, list) or len(warning_data) == 0:
return []
result = list()
for data in warning_data:
try:
warning_id = int(data.get('warningType', {}).get('id'))
start = datetime.fromisoformat(data.get('fromTimestamp', None))
end = datetime.fromisoformat(data.get('toTimestamp', None))
except (TypeError, ValueError):
# Without this data, the warning is useless
continue
try:
level = int(data.get('warningLevel'))
except TypeError:
level = None
lang = self.hass.config.language if self.hass.config.language in LANGS else 'en'
result.append(
WarningData(
slug=SLUG_MAP.get(warning_id, 'unknown'),
id=warning_id,
level=level,
friendly_name=data.get('warningType', {}).get('name', {}).get(lang, ''),
text=data.get('text', {}).get(lang, ''),
starts_at=start,
ends_at=end
)
)
return result if len(result) > 0 else []

View file

@ -1,58 +1,8 @@
"""Data classes for IRM KMI integration"""
from datetime import datetime
from typing import List, TypedDict
from homeassistant.components.weather import Forecast
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: str | None
class CurrentWeatherData(TypedDict, total=False):
"""Class to hold the currently observable weather at a given location"""
condition: str | None
temperature: float | None
wind_speed: float | None
wind_gust_speed: float | None
wind_bearing: float | str | None
uv_index: float | None
pressure: float | None
class AnimationFrameData(TypedDict, total=False):
"""Holds one single frame of the radar camera, along with the timestamp of the frame"""
time: datetime | None
image: bytes | None
value: float | None
position: float | None
position_higher: float | None
position_lower: float | None
class RadarAnimationData(TypedDict, total=False):
"""Holds frames and additional data for the animation to be rendered"""
sequence: List[AnimationFrameData] | None
most_recent_image_idx: int | None
hint: str | None
unit: str | None
location: bytes | None
svg_still: bytes | None
svg_animated: bytes | None
class WarningData(TypedDict, total=False):
"""Holds data about a specific warning"""
slug: str
id: int
level: int
friendly_name: str
text: str
starts_at: datetime
ends_at: datetime
from irm_kmi_api.data import CurrentWeatherData, IrmKmiForecast, WarningData
from irm_kmi_api.rain_graph import RainGraph
class ProcessedCoordinatorData(TypedDict, total=False):
@ -60,6 +10,8 @@ class ProcessedCoordinatorData(TypedDict, total=False):
current_weather: CurrentWeatherData
hourly_forecast: List[Forecast] | None
daily_forecast: List[IrmKmiForecast] | None
animation: RadarAnimationData
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

@ -9,8 +9,7 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jdejaegh/irm-kmi-ha/issues",
"requirements": [
"pytz==2024.1",
"svgwrite==1.4.3"
"irm-kmi-api==0.2.0"
],
"version": "0.2.8"
"version": "0.3.2"
}

View file

@ -1,131 +0,0 @@
"""Parse pollen info from SVG from IRM KMI api"""
import logging
import xml.etree.ElementTree as ET
from typing import List
from custom_components.irm_kmi.const import POLLEN_NAMES
_LOGGER = logging.getLogger(__name__)
class PollenParser:
"""
The SVG looks as follows (see test fixture for a real example)
Active pollens
---------------------------------
Oak active
Ash active
---------------------------------
Birch ---|---|---|---|-*-
Alder -*-|---|---|---|---
This classe parses the oak and ash as active, birch as purple and alder as green in the example.
For active pollen, check if an active text is present on the same line as the pollen name
For the color scale, look for a white dot (nearly) at the same level as the pollen name. From the white dot
horizontal position, determine the level
"""
def __init__(
self,
xml_string: str
):
self._xml = xml_string
@staticmethod
def _validate_svg(elements: List[ET.Element]) -> bool:
"""Make sure that the colors of the scale are still where we expect them"""
x_values = {"rectgreen": 80,
"rectyellow": 95,
"rectorange": 110,
"rectred": 125,
"rectpurple": 140}
for e in elements:
if e.attrib.get('id', '') in x_values.keys():
try:
if float(e.attrib.get('x', '0')) != x_values.get(e.attrib.get('id')):
return False
except ValueError:
return False
return True
@staticmethod
def get_default_data() -> dict:
"""Return all the known pollen with 'none' value"""
return {k.lower(): 'none' for k in POLLEN_NAMES}
@staticmethod
def get_option_values() -> List[str]:
"""List all the values that the pollen can have"""
return ['active', 'green', 'yellow', 'orange', 'red', 'purple', 'none']
@staticmethod
def _extract_elements(root) -> List[ET.Element]:
"""Recursively collect all elements of the SVG in a list"""
elements = []
for child in root:
elements.append(child)
elements.extend(PollenParser._extract_elements(child))
return elements
@staticmethod
def _dot_to_color_value(dot: ET.Element) -> str:
"""Map the dot horizontal position to a color or 'none'"""
try:
cx = float(dot.attrib.get('cx'))
except ValueError:
return 'none'
if cx > 155:
return 'none'
elif cx > 140:
return 'purple'
elif cx > 125:
return 'red'
elif cx > 110:
return 'orange'
elif cx > 95:
return 'yellow'
elif cx > 80:
return 'green'
else:
return 'none'
def get_pollen_data(self) -> dict:
"""From the XML string, parse the SVG and extract the pollen data from the image.
If an error occurs, return the default value"""
pollen_data = self.get_default_data()
try:
_LOGGER.debug(f"Full SVG: {self._xml}")
root = ET.fromstring(self._xml)
except ET.ParseError:
_LOGGER.warning("Could not parse SVG pollen XML")
return pollen_data
elements: List[ET.Element] = self._extract_elements(root)
if not self._validate_svg(elements):
_LOGGER.warning("Could not validate SVG pollen data")
return pollen_data
pollens = [e for e in elements if 'tspan' in e.tag and e.text in POLLEN_NAMES]
active = [e for e in elements if 'tspan' in e.tag and e.text == 'active']
dots = [e for e in elements if 'ellipse' in e.tag
and 'fill:#ffffff' in e.attrib.get('style', '')
and 3 == float(e.attrib.get('rx', '0'))]
for pollen in pollens:
try:
y = float(pollen.attrib.get('y'))
if y in [float(e.attrib.get('y')) for e in active]:
pollen_data[pollen.text.lower()] = 'active'
else:
dot = [d for d in dots if y - 3 <= float(d.attrib.get('cy', '0')) <= y + 3]
if len(dot) == 1:
dot = dot[0]
pollen_data[pollen.text.lower()] = self._dot_to_color_value(dot)
except ValueError | NameError:
_LOGGER.warning("Skipped some data in the pollen SVG")
_LOGGER.debug(f"Pollen data: {pollen_data}")
return pollen_data

View file

@ -1,342 +0,0 @@
"""Create graphs for rain short term forecast."""
import base64
import copy
import logging
import os
from typing import List
import pytz
from svgwrite import Drawing
from svgwrite.animate import Animate
from custom_components.irm_kmi.data import (AnimationFrameData,
RadarAnimationData)
_LOGGER = logging.getLogger(__name__)
class RainGraph:
def __init__(self,
animation_data: RadarAnimationData,
background_image_path: str,
background_size: (int, int),
config_dir: str = '.',
dark_mode: bool = False,
tz: str = 'UTC',
svg_width: float = 640,
inset: float = 20,
graph_height: float = 150,
top_text_space: float = 30,
top_text_y_pos: float = 20,
bottom_text_space: float = 50,
bottom_text_y_pos: float = 218,
auto=True
):
self._animation_data: RadarAnimationData = animation_data
self._background_image_path: str = background_image_path
self._background_size: (int, int) = background_size
self._config_dir: str = config_dir
self._dark_mode: bool = dark_mode
self._tz = pytz.timezone(tz)
self._svg_width: float = svg_width
self._inset: float = inset
self._graph_height: float = graph_height
self._top_text_space: float = top_text_space + background_size[1]
self._top_text_y_pos: float = top_text_y_pos + background_size[1]
self._bottom_text_space: float = bottom_text_space
self._bottom_text_y_pos: float = bottom_text_y_pos + background_size[1]
self._frame_count: int = len(self._animation_data['sequence'])
self._graph_width: float = self._svg_width - 2 * self._inset
self._graph_bottom: float = self._top_text_space + self._graph_height
self._svg_height: float = self._graph_height + self._top_text_space + self._bottom_text_space
self._interval_width: float = self._graph_width / self._frame_count
self._offset: float = self._inset + self._interval_width / 2
if not (0 <= self._top_text_y_pos <= self._top_text_space):
raise ValueError("It must hold that 0 <= top_text_y_pos <= top_text_space")
if not (self._graph_bottom <= self._bottom_text_y_pos <= self._graph_bottom + self._bottom_text_space):
raise ValueError("bottom_text_y_pos must be below the graph")
self._dwg: Drawing = Drawing(size=(self._svg_width, self._svg_height), profile='full')
self._dwg_save: Drawing
self._dwg_animated: Drawing
self._dwg_still: Drawing
if auto:
self.draw_svg_frame()
self.draw_hour_bars()
self.draw_chances_path()
self.draw_data_line()
self.write_hint()
self.insert_background()
self._dwg_save = copy.deepcopy(self._dwg)
self.draw_current_fame_line()
self.draw_description_text()
self.insert_cloud_layer()
self.draw_location()
self._dwg_animated = self._dwg
self._dwg = self._dwg_save
idx = self._animation_data['most_recent_image_idx']
self.draw_current_fame_line(idx)
self.draw_description_text(idx)
self.insert_cloud_layer(idx)
self.draw_location()
self._dwg_still = self._dwg
def draw_svg_frame(self):
"""Create the global area to draw the other items"""
font_file = os.path.join(self._config_dir, 'custom_components/irm_kmi/resources/roboto_medium.ttf')
_LOGGER.debug(f"Opening font file at {font_file}")
self._dwg.embed_font(name="Roboto Medium", filename=font_file)
self._dwg.embed_stylesheet("""
.roboto {
font-family: "Roboto Medium";
}
""")
fill_color = '#393C40' if self._dark_mode else '#385E95'
self._dwg.add(self._dwg.rect(insert=(0, 0),
size=(self._svg_width, self._svg_height),
rx=None, ry=None,
fill=fill_color, stroke='none'))
def draw_description_text(self, idx: int | None = None):
"""For every frame write the amount of precipitation and the time at the top of the graph.
If idx is set, only do it for the given idx"""
times = [e['time'].astimezone(tz=self._tz).strftime('%H:%M') for e in
self._animation_data['sequence']]
rain_levels = [f"{e['value']}{self._animation_data['unit']}" for e in self._animation_data['sequence']]
if idx is not None:
time = times[idx]
rain_level = rain_levels[idx]
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
self.write_time_and_rain(paragraph, rain_level, time)
return
for i in range(self._frame_count):
time = times[i]
rain_level = rain_levels[i]
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
values = ['hidden'] * self._frame_count
values[i] = 'visible'
paragraph.add(Animate(
attributeName="visibility",
values=";".join(values),
dur=f"{self._frame_count * 0.3}s",
begin="0s",
repeatCount="indefinite"
))
self.write_time_and_rain(paragraph, rain_level, time)
def write_time_and_rain(self, paragraph, rain_level, time):
"""Using the paragraph object, write the time and rain level data"""
paragraph.add(self._dwg.text(f"{time}", insert=(self._offset, self._top_text_y_pos),
text_anchor="start",
font_size="16px",
fill="white",
stroke='none'))
paragraph.add(self._dwg.text(f"{rain_level}", insert=(self._svg_width / 2, self._top_text_y_pos),
text_anchor="middle",
font_size="16px",
fill="white",
stroke='none'))
def write_hint(self):
"""Add the hint text at the bottom of the graph"""
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
hint = self._animation_data['hint']
paragraph.add(self._dwg.text(f"{hint}", insert=(self._svg_width / 2, self._bottom_text_y_pos),
text_anchor="middle",
font_size="16px",
fill="white",
stroke='none'))
def draw_chances_path(self):
"""Draw the prevision margin area around the main forecast line"""
list_lower_points = []
list_higher_points = []
rain_list: List[AnimationFrameData] = self._animation_data['sequence']
graph_rect_left = self._offset
graph_rect_top = self._top_text_space
for i in range(len(rain_list)):
position_higher = rain_list[i]['position_higher']
if position_higher is not None:
list_higher_points.append((graph_rect_left, graph_rect_top + (
1.0 - position_higher) * self._graph_height))
graph_rect_left += self._interval_width
graph_rect_right = graph_rect_left - self._interval_width
for i in range(len(rain_list) - 1, -1, -1):
position_lower = rain_list[i]['position_lower']
if position_lower is not None:
list_lower_points.append((graph_rect_right, graph_rect_top + (
1.0 - position_lower) * self._graph_height))
graph_rect_right -= self._interval_width
if list_higher_points and list_lower_points:
self.draw_chance_precip(list_higher_points, list_lower_points)
def draw_chance_precip(self, list_higher_points: List, list_lower_points: List):
"""Draw the blue solid line representing the actual rain forecast"""
precip_higher_chance_path = self._dwg.path(fill='#63c8fa', stroke='none', opacity=.3)
list_higher_points[-1] = tuple(list(list_higher_points[-1]) + ['last'])
self.set_curved_path(precip_higher_chance_path, list_higher_points + list_lower_points)
self._dwg.add(precip_higher_chance_path)
@staticmethod
def set_curved_path(path, points):
"""Pushes points on the path by creating a nice curve between them"""
if len(points) < 2:
return
path.push('M', *points[0])
for i in range(1, len(points)):
x_mid = (points[i - 1][0] + points[i][0]) / 2
y_mid = (points[i - 1][1] + points[i][1]) / 2
path.push('Q', points[i - 1][0], points[i - 1][1], x_mid, y_mid)
if points[i][-1] == 'last' or points[i - 1][-1] == 'last':
path.push('Q', points[i][0], points[i][1], points[i][0], points[i][1])
path.push('Q', points[-1][0], points[-1][1], points[-1][0], points[-1][1])
def draw_data_line(self):
"""Draw the main data line for the rain forecast"""
rain_list: List[AnimationFrameData] = self._animation_data['sequence']
graph_rect_left = self._offset
graph_rect_top = self._top_text_space
entry_list = []
for i in range(len(rain_list)):
position = rain_list[i]['position']
entry_list.append(
(graph_rect_left,
graph_rect_top + (1.0 - position) * self._graph_height))
graph_rect_left += self._interval_width
data_line_path = self._dwg.path(fill='none', stroke='#63c8fa', stroke_width=2)
self.set_curved_path(data_line_path, entry_list)
self._dwg.add(data_line_path)
def draw_hour_bars(self):
"""Draw the small bars at the bottom to represent the time"""
hour_bar_height = 8
horizontal_inset = self._offset
for (i, rain_item) in enumerate(self._animation_data['sequence']):
time_image = rain_item['time'].astimezone(tz=self._tz)
is_hour_bar = time_image.minute == 0
x_position = horizontal_inset
if i == self._animation_data['most_recent_image_idx']:
self._dwg.add(self._dwg.line(start=(x_position, self._top_text_space),
end=(x_position, self._graph_bottom),
stroke='white',
opacity=0.5,
stroke_dasharray=4))
self._dwg.add(self._dwg.line(start=(x_position, self._graph_bottom - hour_bar_height),
end=(x_position, self._graph_bottom),
stroke='white' if is_hour_bar else 'lightgrey',
opacity=0.9 if is_hour_bar else 0.7))
if is_hour_bar:
graph_rect_center_x = x_position
graph_rect_center_y = self._graph_bottom + 18
paragraph = self._dwg.add(self._dwg.g(class_="roboto", ))
paragraph.add(self._dwg.text(f"{time_image.hour}h", insert=(graph_rect_center_x, graph_rect_center_y),
text_anchor="middle",
font_size="16px",
fill="white",
stroke='none'))
horizontal_inset += self._interval_width
self._dwg.add(self._dwg.line(start=(self._offset, self._graph_bottom),
end=(self._graph_width + self._interval_width / 2, self._graph_bottom),
stroke='white'))
def draw_current_fame_line(self, idx: int | None = None):
"""Draw a solid white line on the timeline at the position of the given frame index"""
x_position = self._offset if idx is None else self._offset + idx * self._interval_width
now = self._dwg.add(self._dwg.line(start=(x_position, self._top_text_space),
end=(x_position, self._graph_bottom),
id='now',
stroke='white',
opacity=1,
stroke_width=2))
if idx is not None:
return
now.add(self._dwg.animateTransform("translate", "transform",
id="now",
from_=f"{self._offset} 0",
to=f"{self._graph_width - self._offset} 0",
dur=f"{self._frame_count * 0.3}s",
repeatCount="indefinite"))
def get_svg_string(self, still_image: bool = False) -> bytes:
return self._dwg_still.tostring().encode() if still_image else self._dwg_animated.tostring().encode()
def insert_background(self):
bg_image_path = os.path.join(self._config_dir, self._background_image_path)
with open(bg_image_path, 'rb') as f:
png_data = base64.b64encode(f.read()).decode('utf-8')
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image)
def insert_cloud_layer(self, idx: int | None = None):
imgs = [e['image'] for e in self._animation_data['sequence']]
if idx is not None:
img = imgs[idx]
png_data = base64.b64encode(img).decode('utf-8')
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image)
return
for i, img in enumerate(imgs):
png_data = base64.b64encode(img).decode('utf-8')
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image)
values = ['hidden'] * self._frame_count
values[i] = 'visible'
image.add(Animate(
attributeName="visibility",
values=";".join(values),
dur=f"{self._frame_count * 0.3}s",
begin="0s",
repeatCount="indefinite"
))
def draw_location(self):
img = self._animation_data['location']
png_data = base64.b64encode(img).decode('utf-8')
image = self._dwg.image("data:image/png;base64," + png_data, insert=(0, 0), size=self._background_size)
self._dwg.add(image)
def get_dwg(self):
return copy.deepcopy(self._dwg)

View file

@ -8,13 +8,12 @@ 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 custom_components.irm_kmi import async_reload_entry
from custom_components.irm_kmi.api import IrmKmiApiClient
from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE,
REPAIR_OPT_MOVE, REPAIR_OPTIONS,
REPAIR_SOLUTION)
from custom_components.irm_kmi.utils import modify_from_config
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__)
@ -50,7 +49,9 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
try:
async with async_timeout.timeout(10):
api_data = await IrmKmiApiClient(
session=async_get_clientsession(self.hass)).get_forecasts_coord(
session=async_get_clientsession(self.hass),
user_agent=USER_AGENT
).get_forecasts_coord(
{'lat': zone.attributes[ATTR_LATITUDE],
'long': zone.attributes[ATTR_LONGITUDE]}
)
@ -84,8 +85,8 @@ class OutOfBeneluxRepairFlow(RepairsFlow):
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
_hass: HomeAssistant,
_issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> OutOfBeneluxRepairFlow:
"""Create flow."""

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View file

@ -1,5 +1,6 @@
"""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
@ -7,10 +8,15 @@ 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 custom_components.irm_kmi import DOMAIN, IrmKmiCoordinator
from custom_components.irm_kmi.const import POLLEN_NAMES, POLLEN_TO_ICON_MAP
from custom_components.irm_kmi.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__)
@ -19,12 +25,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
"""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,
@ -45,3 +58,173 @@ class IrmKmiPollen(CoordinatorEntity, SensorEntity):
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

@ -9,9 +9,11 @@
"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"
"use_deprecated_forecast_attribute": "Use the deprecated forecat attribute",
"language_override": "Language"
}
}
},
@ -34,7 +36,7 @@
"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 daily forecast",
"twice_daily_in_deprecated_forecast": "Use for twice daily forecast",
"hourly_in_deprecated_forecast": "Use for hourly forecast"
}
},
@ -43,6 +45,15 @@
"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": {
@ -52,7 +63,8 @@
"data": {
"style": "Style of the radar",
"dark_mode": "Radar dark mode",
"use_deprecated_forecast_attribute": "Use the deprecated forecat attribute"
"use_deprecated_forecast_attribute": "Use the deprecated forecat attribute",
"language_override": "Language"
}
}
}
@ -78,6 +90,15 @@
},
"entity": {
"sensor": {
"next_warning": {
"name": "Next warning"
},
"next_sunrise": {
"name": "Next sunrise"
},
"next_sunset": {
"name": "Next sunset"
},
"pollen_alder": {
"name": "Alder pollen",
"state": {
@ -161,6 +182,39 @@
"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

@ -9,9 +9,11 @@
"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é)"
"use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)",
"language_override": "Langue"
}
}
},
@ -43,6 +45,15 @@
"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": {
@ -52,7 +63,8 @@
"data": {
"style": "Style du radar",
"dark_mode": "Radar en mode sombre",
"use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)"
"use_deprecated_forecast_attribute": "Utiliser l'attribut forecat (déprécié)",
"language_override": "Langue"
}
}
}
@ -78,6 +90,15 @@
},
"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": {
@ -161,6 +182,39 @@
"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

@ -9,9 +9,11 @@
"user": {
"title": "Instellingen",
"data": {
"zone": "Zone",
"style": "Radarstijl",
"dark_mode": "Radar in donkere modus",
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)"
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)",
"language_override": "Taal"
}
}
},
@ -43,6 +45,15 @@
"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": {
@ -52,7 +63,8 @@
"data": {
"style": "Radarstijl",
"dark_mode": "Radar in donkere modus",
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)"
"use_deprecated_forecast_attribute": "Gebruik het forecat attribuut (afgeschaft)",
"language_override": "Taal"
}
}
}
@ -78,6 +90,15 @@
},
"entity": {
"sensor": {
"next_warning": {
"name": "Volgende waarschuwing"
},
"next_sunrise": {
"name": "Volgende zonsopkomst"
},
"next_sunset": {
"name": "Volgende zonsondergang"
},
"pollen_alder": {
"name": "Elzenpollen",
"state": {
@ -161,6 +182,39 @@
"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

@ -5,6 +5,8 @@ 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__)
@ -29,3 +31,12 @@ 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,16 +1,20 @@
"""Support for IRM KMI weather."""
import copy
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.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 CONF_USE_DEPRECATED_FORECAST, DOMAIN
from .const import (OPTION_DEPRECATED_FORECAST_DAILY,
@ -25,12 +29,27 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
"""Set up the weather entry."""
add_services()
coordinator = hass.data[DOMAIN][entry.entry_id]
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,
@ -41,7 +60,6 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
self._name = entry.title
self._attr_unique_id = entry.entry_id
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:
@ -134,6 +152,22 @@ class IrmKmiWeather(CoordinatorEntity, WeatherEntity):
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.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

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,6 +1,4 @@
aiohttp==3.9.3
async-timeout==4.0.3
homeassistant==2024.4.0
voluptuous==0.13.1
pytz==2024.1
svgwrite==1.4.3
aiohttp>=3.11.13
homeassistant==2025.6.1
voluptuous==0.15.2
irm-kmi-api==0.2.0

View file

@ -1,5 +1,6 @@
homeassistant==2024.4.0
homeassistant==2025.6.1
pytest_homeassistant_custom_component==0.13.252
pytest
pytest_homeassistant_custom_component==0.13.111
freezegun
isort
isort
bumpver

View file

@ -2,39 +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 (IrmKmiApiError,
IrmKmiApiParametersError)
from custom_components.irm_kmi import OPTION_STYLE_STD
from custom_components.irm_kmi.const import (
CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST, DOMAIN,
OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD, OPTION_DEPRECATED_FORECAST_TWICE_DAILY)
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))
async def patched(url: str, params: dict | None = None) -> bytes:
if "cdn.knmi.nl" in url:
file_name = "tests/fixtures/clouds_nl.png"
elif "app.meteo.be/services/appv4/?s=getIncaImage" in url:
file_name = "tests/fixtures/clouds_be.png"
elif "getLocalizationLayerBE" in url:
file_name = "tests/fixtures/loc_layer_be_n.png"
elif "getLocalizationLayerNL" in url:
file_name = "tests/fixtures/loc_layer_nl.png"
else:
raise ValueError(f"Not a valid parameter for the mock: {url}")
with open(file_name, "rb") as file:
return file.read()
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)
@ -51,7 +46,8 @@ def mock_config_entry() -> MockConfigEntry:
data={CONF_ZONE: "zone.home",
CONF_STYLE: OPTION_STYLE_STD,
CONF_DARK_MODE: True,
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED},
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
CONF_LANGUAGE_OVERRIDE: 'none'},
unique_id="zone.home",
)
@ -65,7 +61,8 @@ def mock_config_entry_with_deprecated() -> MockConfigEntry:
data={CONF_ZONE: "zone.home",
CONF_STYLE: OPTION_STYLE_STD,
CONF_DARK_MODE: True,
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_TWICE_DAILY},
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_TWICE_DAILY,
CONF_LANGUAGE_OVERRIDE: 'none'},
unique_id="zone.home",
)
@ -118,24 +115,11 @@ def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMoc
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_irm_kmi_api_coordinator_out_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))
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
@ -171,94 +155,34 @@ def mock_irm_kmi_api_repair_out_of_benelux(request: pytest.FixtureRequest) -> Ge
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()
@pytest.fixture()
def mock_image_and_nl_forecast_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast_nl.json"
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)
]
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_image_and_high_temp_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "high_low_temp.json"
forecast = json.loads(load_fixture(fixture))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_image_and_simple_forecast_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))
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_image.side_effect = patched
irm_kmi.get_svg.return_value = ""
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture()
def mock_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
fixture: str = "pollen.svg"
svg_str = load_fixture(fixture)
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_svg.return_value = svg_str
yield irm_kmi
@pytest.fixture()
def mock_exception_irm_kmi_api_svg_pollen(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked IrmKmi api client."""
with patch(
"custom_components.irm_kmi.coordinator.IrmKmiApiClient", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_svg.side_effect = IrmKmiApiParametersError
yield irm_kmi
@pytest.fixture()
def mock_coordinator(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
"""Return a mocked coordinator."""
with patch(
"custom_components.irm_kmi.IrmKmiCoordinator", autospec=True
) as coordinator_mock:
coord = coordinator_mock.return_value
coord._async_animation_data.return_value = {'animation': None}
yield coord
return RadarAnimationData(
sequence=sequence,
most_recent_image_idx=2,
hint="Testing SVG camera",
unit="mm/10min",
location=location
)

View file

@ -1474,7 +1474,7 @@
{
"time": "2024-01-12T10:10:00+01:00",
"uri": "https:\/\/app.meteo.be\/services\/appv4\/?s=getIncaImage&i=202401120920&f=2&k=2160a92594985471351907ee5cc75d1f&d=202401120900",
"value": 0,
"value": 0.42,
"position": 0,
"positionLower": 0,
"positionHigher": 0

View file

@ -1408,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
@ -1416,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
@ -1424,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
@ -1432,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
@ -1440,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

1676
tests/fixtures/forecast_ams_no_ww.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"municipality_code": "0995",
"temp": 11,
"windSpeedKm": 40,
"timestamp": "2023-12-28T14:20:00+00:00",
"timestamp": "2023-12-28T14:30:00+00:00",
"windDirection": 45,
"municipality": "Lelystad",
"windDirectionText": {
@ -1337,7 +1337,7 @@
{
"time": "2023-12-28T14:25:00+00:00",
"uri": "https:\/\/cdn.knmi.nl\/knmi\/map\/page\/weer\/actueel-weer\/neerslagradar\/weerapp\/RAD_NL25_PCP_CM_202312281425_640.png",
"value": 0,
"value": 0.15,
"position": 0,
"positionLower": 0,
"positionHigher": 0

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"
}
]
}
]
}
]
}
]

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="35 88 129 63.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" height="63.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"/>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text>
<text xml:space="preserve"
style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583"
x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="35 88 129 63.0" version="1.1" id="svg740" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <defs id="defs737" /> <g id="layer1"> <rect id="rectangle-white" x="35" y="88" width="129" height="63.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" /><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="119.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="119.0">Alder</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="119.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="119.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="128.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="128.0">Ash</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="128.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="128.0">active</tspan></text><text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.973724" y="137.0" id="text993"><tspan id="tspan991" style="stroke-width:0.264583" x="42.973724" y="137.0">Oak</tspan></text> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="142.0" y="137.0" id="text1001"><tspan id="tspan999" style="stroke-width:0.264583" x="142.0" y="137.0">active</tspan></text><rect style="fill:#607eaa;stroke-width:0.264583" id="rect392" width="127.80161" height="0.44039145" x="35.451504" y="141.0" /> <rect style="fill:#70ad47;fill-opacity:1;stroke-width:0.2187" id="rectgreen" width="15.0" height="4.0" x="80.0" y="144.0" /> <rect style="fill:#ffd966;fill-opacity:1;stroke-width:0.2187" id="rectyellow" width="15.0" height="4.0" x="95.0" y="144.0" /> <rect style="fill:#ed7d31;fill-opacity:1;stroke-width:0.2187" id="rectorange" width="15.0" height="4.0" x="110.0" y="144.0" /> <rect style="fill:#c00000;fill-opacity:1;stroke-width:0.2187" id="rectred" width="15.0" height="4.0" x="125.0" y="144.0" /> <rect style="fill:#7030a0;fill-opacity:1;stroke-width:0.2187" id="rectpurple" width="15.0" height="4.0" x="140.0" y="144.0" /> <ellipse style="fill:#70ad47;fill-opacity:1;stroke-width:0.264583" id="path1639" cx="80.0" cy="146.0" rx="2.0" ry="2.0" /> <ellipse style="fill:#7030a0;fill-opacity:1;stroke-width:0.264583" id="path1641" cx="155.0" cy="146.0" rx="2.0" ry="2.0" /> <text xml:space="preserve" style="font-family:Arial,Helvetica,sans-serif;font-size:6.0px;fill:#ffffff;stroke-width:0.264583" x="42.351849" y="148.0" id="text396"><tspan id="tspan394" style="stroke-width:0.264583" x="42.351849" y="148.0">Birch</tspan></text> <ellipse style="fill:#ffffff;fill-opacity:1;stroke-width:0.264583" id="path1644" cx="147.5" cy="146.0" rx="3.0" ry="3.0" /></g> </svg>

Before

Width:  |  Height:  |  Size: 4.4 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

@ -1,58 +0,0 @@
from datetime import datetime
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 tests.conftest import get_api_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_data = get_api_data("be_forecast_warning.json")
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
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(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
# When language is unknown, default to english setting
hass.config.language = "foo"
api_data = get_api_data("be_forecast_warning.json")
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
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"

View file

@ -7,13 +7,14 @@ 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 import async_migrate_entry
from custom_components.irm_kmi.const import (
CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST,
CONFIG_FLOW_VERSION, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED,
OPTION_STYLE_SATELLITE, OPTION_STYLE_STD)
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(
@ -40,7 +41,8 @@ async def test_full_user_flow(
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_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
CONF_LANGUAGE_OVERRIDE: 'none'}
async def test_config_flow_out_benelux_zone(
@ -128,7 +130,8 @@ async def test_option_flow(
assert result["data"] == {
CONF_STYLE: OPTION_STYLE_SATELLITE,
CONF_DARK_MODE: True,
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED
CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED,
CONF_LANGUAGE_OVERRIDE: 'none'
}

View file

@ -1,15 +1,14 @@
from datetime import datetime, timedelta
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 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 CurrentWeatherData, IrmKmiForecast
from tests.conftest import get_api_data
from custom_components.irm_kmi.data import ProcessedCoordinatorData
from tests.conftest import get_api_data, get_api_with_data
async def test_jules_forgot_to_revert_update_interval_before_pushing(
@ -21,116 +20,92 @@ async def test_jules_forgot_to_revert_update_interval_before_pushing(
assert timedelta(minutes=5) <= coordinator.update_interval
@freeze_time(datetime.fromisoformat('2024-01-12T07:10:00'))
async def test_warning_data(
async def test_refresh_succeed_even_when_pollen_and_radar_fail(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api_data = get_api_data("be_forecast_warning.json")
mock_config_entry: MockConfigEntry,
):
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
coordinator._api._api_data = get_api_data("forecast.json")
result = coordinator.warnings_from_data(api_data.get('for', {}).get('warning'))
result = await coordinator.process_api_data()
assert isinstance(result, list)
assert len(result) == 2
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
first = result[0]
assert result.get('animation').get_hint() == "No rain forecasted shortly"
assert first.get('starts_at').replace(tzinfo=None) < datetime.now()
assert first.get('ends_at').replace(tzinfo=None) > datetime.now()
assert result.get('pollen') == PollenParser.get_unavailable_data()
assert first.get('slug') == 'fog'
assert first.get('friendly_name') == 'Fog'
assert first.get('id') == 7
assert first.get('level') == 1
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00'))
def test_current_weather_be() -> None:
api_data = get_api_data("forecast.json")
result = IrmKmiCoordinator.current_weather_from_data(api_data)
expected = CurrentWeatherData(
condition=ATTR_CONDITION_CLOUDY,
temperature=7,
wind_speed=5,
wind_gust_speed=None,
wind_bearing=248,
pressure=1020,
uv_index=.7
existing_data = ProcessedCoordinatorData(
current_weather=CurrentWeatherData(),
daily_forecast=[],
hourly_forecast=[],
animation=None,
warnings=[],
pollen={'foo': 'bar'}
)
coordinator.data = existing_data
result = await coordinator.process_api_data()
assert result == expected
assert result.get('current_weather').get('condition') == ATTR_CONDITION_CLOUDY
assert result.get('animation').get_hint() == "No rain forecasted shortly"
assert result.get('pollen') == {'foo': 'bar'}
@freeze_time(datetime.fromisoformat("2023-12-28T15:30:00"))
def test_current_weather_nl() -> None:
api_data = get_api_data("forecast_nl.json")
result = IrmKmiCoordinator.current_weather_from_data(api_data)
def test_radar_forecast() -> None:
api = get_api_with_data("forecast.json")
result = api.get_radar_forecast()
expected = CurrentWeatherData(
condition=ATTR_CONDITION_CLOUDY,
temperature=11,
wind_speed=40,
wind_gust_speed=None,
wind_bearing=225,
pressure=1008,
uv_index=1
)
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 expected == result
@freeze_time(datetime.fromisoformat('2023-12-26T18:30:00.028724'))
async def test_daily_forecast(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry
) -> None:
api_data = get_api_data("forecast.json").get('for', {}).get('daily')
def test_radar_forecast_rain_interval() -> None:
api = get_api_with_data('forecast_with_rain_on_radar.json')
result = api.get_radar_forecast()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
result = coordinator.daily_list_to_forecast(api_data)
assert isinstance(result, list)
assert len(result) == 8
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=180,
is_daytime=True,
text='Hey!',
_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("forecast.json").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=180,
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,14 +8,17 @@ 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(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api: AsyncMock,
mock_coordinator: AsyncMock
) -> None:
"""Test the IRM KMI configuration entry loading/unloading."""
hass.states.async_set(
@ -53,7 +56,7 @@ async def test_config_entry_not_ready(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.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
@ -74,3 +77,29 @@ async def test_config_entry_zone_removed(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert "Zone 'zone.castle' not found" in caplog.text
async def test_config_entry_migration(
hass: HomeAssistant,
) -> None:
"""Test the IRM KMI configuration entry not ready."""
mock_config_entry = MockConfigEntry(
title="My Castle",
domain=DOMAIN,
data={CONF_ZONE: "zone.castle"},
unique_id="zone.castle",
)
mock_config_entry.add_to_hass(hass)
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

View file

@ -1,63 +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 custom_components.irm_kmi.pollen import PollenParser
from tests.conftest import get_api_data
from tests.conftest import get_api_with_data
def test_svg_pollen_parsing():
with open("tests/fixtures/pollen.svg", "r") as file:
svg_data = file.read()
data = PollenParser(svg_data).get_pollen_data()
assert data == {'birch': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'green',
'grasses': 'none', 'ash': 'active'}
with open("tests/fixtures/pollen_two.svg", "r") as file:
svg_data = file.read()
data = PollenParser(svg_data).get_pollen_data()
assert data == {'birch': 'purple', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active',
'grasses': 'none', 'ash': 'active'}
with open("tests/fixtures/pollen_three.svg", "r") as file:
svg_data = file.read()
data = PollenParser(svg_data).get_pollen_data()
assert data == {'birch': 'none', 'oak': 'active', 'hazel': 'none', 'mugwort': 'none', 'alder': 'active',
'grasses': 'none', 'ash': 'active'}
def test_pollen_options():
assert PollenParser.get_option_values() == ['active', 'green', 'yellow', 'orange', 'red', 'purple', 'none']
def test_pollen_default_values():
assert PollenParser.get_default_data() == {'birch': 'none', 'oak': 'none', 'hazel': 'none', 'mugwort': 'none',
'alder': 'none', 'grasses': 'none', 'ash': 'none'}
async def test_pollen_data_from_api(
hass: HomeAssistant,
mock_svg_pollen: AsyncMock,
mock_config_entry: MockConfigEntry
) -> None:
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
api_data = get_api_data("be_forecast_warning.json")
result = await coordinator._async_pollen_data(api_data)
expected = {'mugwort': 'none', 'birch': 'purple', 'alder': 'green', 'ash': 'active', 'oak': 'active',
'grasses': 'none', 'hazel': 'none'}
assert result == expected
async def test_pollen_error_leads_to_default_values(
async def test_pollen_error_leads_to_unavailable_on_first_call(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_exception_irm_kmi_api_svg_pollen: AsyncMock
) -> None:
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
api_data = get_api_data("be_forecast_warning.json")
api = get_api_with_data("be_forecast_warning.json")
result = await coordinator._async_pollen_data(api_data)
expected = PollenParser.get_default_data()
assert result == expected
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

View file

@ -1,278 +0,0 @@
import base64
from datetime import datetime, timedelta
from custom_components.irm_kmi.data import (AnimationFrameData,
RadarAnimationData)
from custom_components.irm_kmi.rain_graph import RainGraph
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
)
def test_svg_frame_setup():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_svg_frame()
svg_str = rain_graph.get_dwg().tostring()
with open("custom_components/irm_kmi/resources/roboto_medium.ttf", "rb") as file:
font_b64 = base64.b64encode(file.read()).decode('utf-8')
assert '#385E95' in svg_str
assert 'font-family: "Roboto Medium";' in svg_str
assert font_b64 in svg_str
def test_svg_hint():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.write_hint()
svg_str = rain_graph.get_dwg().tostring()
assert "Testing SVG camera" in svg_str
def test_svg_time_bars():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_hour_bars()
svg_str = rain_graph.get_dwg().tostring()
assert "19h" in svg_str
assert "20h" in svg_str
assert "<line" in svg_str
assert 'stroke="white"' in svg_str
def test_draw_chances_path():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_chances_path()
svg_str = rain_graph.get_dwg().tostring()
assert 'fill="#63c8fa"' in svg_str
assert 'opacity="0.3"' in svg_str
assert 'stroke="none"' in svg_str
assert '<path ' in svg_str
def test_draw_data_line():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_data_line()
svg_str = rain_graph.get_dwg().tostring()
assert 'fill="none"' in svg_str
assert 'stroke-width="2"' in svg_str
assert 'stroke="#63c8fa"' in svg_str
assert '<path ' in svg_str
def test_insert_background():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.insert_background()
with open("custom_components/irm_kmi/resources/be_white.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8')
svg_str = rain_graph.get_dwg().tostring()
assert png_b64 in svg_str
assert "<image " in svg_str
assert 'height="490"' in svg_str
assert 'width="640"' in svg_str
assert 'x="0"' in svg_str
assert 'y="0"' in svg_str
def test_draw_current_frame_line_moving():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_current_fame_line()
str_svg = rain_graph.get_dwg().tostring()
assert '<line' in str_svg
assert 'id="now"' in str_svg
assert 'opacity="1"' in str_svg
assert 'stroke="white"' in str_svg
assert 'stroke-width="2"' in str_svg
assert 'x1="50' in str_svg
assert 'x2="50' in str_svg
assert 'y1="520' in str_svg
assert 'y2="670' in str_svg
assert 'animateTransform' in str_svg
assert 'attributeName="transform"' in str_svg
assert 'repeatCount="indefinite"' in str_svg
assert 'type="translate"' in str_svg
def test_draw_current_frame_line_index():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_current_fame_line(0)
str_svg = rain_graph.get_dwg().tostring()
assert '<line' in str_svg
assert 'id="now"' in str_svg
assert 'opacity="1"' in str_svg
assert 'stroke="white"' in str_svg
assert 'stroke-width="2"' in str_svg
assert 'x1="50' in str_svg
assert 'x2="50' in str_svg
assert 'y1="520' in str_svg
assert 'y2="670' in str_svg
assert 'animateTransform' not in str_svg
assert 'attributeName="transform"' not in str_svg
assert 'repeatCount="indefinite"' not in str_svg
assert 'type="translate"' not in str_svg
def test_draw_description_text():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_description_text()
str_svg = rain_graph.get_dwg().tostring()
assert "18:30" in str_svg
assert "18:40" in str_svg
assert "18:50" in str_svg
assert "19:00" in str_svg
assert "19:10" in str_svg
assert "19:20" in str_svg
assert "19:30" in str_svg
assert "19:40" in str_svg
assert "19:50" in str_svg
assert "20:00" in str_svg
assert str_svg.count("2mm/10") == 10
assert 'class="roboto"' in str_svg
def test_draw_cloud_layer():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.insert_cloud_layer()
str_svg = rain_graph.get_dwg().tostring()
with open("tests/fixtures/clouds_be.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8')
assert str_svg.count(png_b64) == 10
assert str_svg.count('height="490"') == 10
assert str_svg.count('width="640"') == 11 # Is also the width of the SVG itself
def test_draw_location_layer():
data = get_radar_animation_data()
rain_graph = RainGraph(
animation_data=data,
background_image_path="custom_components/irm_kmi/resources/be_white.png",
background_size=(640, 490),
auto=False
)
rain_graph.draw_location()
str_svg = rain_graph.get_dwg().tostring()
with open("tests/fixtures/loc_layer_be_n.png", "rb") as file:
png_b64 = base64.b64encode(file.read()).decode('utf-8')
assert png_b64 in str_svg

View file

@ -1,10 +1,12 @@
import json
import logging
from unittest.mock import MagicMock
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
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,
@ -28,6 +30,11 @@ async def get_repair_flow(
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")
@ -38,7 +45,6 @@ async def get_repair_flow(
async def test_repair_triggers_when_out_of_benelux(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
hass.states.async_set(
@ -50,6 +56,8 @@ async def test_repair_triggers_when_out_of_benelux(
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)
@ -65,7 +73,6 @@ async def test_repair_triggers_when_out_of_benelux(
async def test_repair_flow(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_in_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -87,7 +94,6 @@ async def test_repair_flow(
async def test_repair_flow_invalid_choice(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_in_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -106,7 +112,6 @@ async def test_repair_flow_invalid_choice(
async def test_repair_flow_api_error(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_get_forecast_api_error_repair: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -125,7 +130,6 @@ async def test_repair_flow_api_error(
async def test_repair_flow_out_of_benelux(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_irm_kmi_api_repair_out_of_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
@ -144,7 +148,6 @@ async def test_repair_flow_out_of_benelux(
async def test_repair_flow_delete_entry(
hass: HomeAssistant,
mock_irm_kmi_api_coordinator_out_benelux: MagicMock,
mock_config_entry: MockConfigEntry
) -> None:
repair_flow = await get_repair_flow(hass, mock_config_entry)

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

View file

@ -1,32 +1,29 @@
import os
import json
from datetime import datetime
from typing import List
from unittest.mock import AsyncMock
from freezegun import freeze_time
from homeassistant.components.weather import Forecast
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
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_image_and_nl_forecast_irm_kmi_api: AsyncMock,
mock_config_entry: MockConfigEntry
) -> None:
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
await coordinator.async_config_entry_first_refresh()
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()
@ -41,19 +38,14 @@ async def test_weather_nl(
@freeze_time(datetime.fromisoformat("2024-01-21T14:15:00+01:00"))
async def test_weather_higher_temp_at_night(
hass: HomeAssistant,
mock_image_and_high_temp_irm_kmi_api: AsyncMock,
mock_config_entry: MockConfigEntry
) -> None:
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator = IrmKmiCoordinator(hass, mock_config_entry)
await coordinator.async_config_entry_first_refresh()
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()
@ -72,18 +64,13 @@ async def test_weather_higher_temp_at_night(
@freeze_time(datetime.fromisoformat("2023-12-26T18:30:00+01:00"))
async def test_forecast_attribute_same_as_service_call(
hass: HomeAssistant,
mock_image_and_simple_forecast_irm_kmi_api: AsyncMock,
mock_config_entry_with_deprecated: MockConfigEntry
) -> None:
hass.states.async_set(
"zone.home",
0,
{"latitude": 50.738681639, "longitude": 4.054077148},
)
hass.config.config_dir = os.getcwd()
coordinator = IrmKmiCoordinator(hass, mock_config_entry_with_deprecated)
await coordinator.async_config_entry_first_refresh()
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)
@ -91,3 +78,88 @@ async def test_forecast_attribute_same_as_service_call(
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)