├── .gitattributes ├── .prettierignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug.yaml ├── workflows │ ├── validate.yaml │ ├── release-drafter.yaml │ ├── license.yaml │ ├── stale.yaml │ ├── release.yaml │ ├── api-issue-comment.yaml │ ├── pr.yaml │ └── ci.yaml ├── PULL_REQUEST_TEMPLATE.md ├── release-drafter.yaml └── renovate.json ├── custom_components ├── __init__.py └── knmi │ ├── const.py │ ├── manifest.json │ ├── icons.json │ ├── diagnostics.py │ ├── coordinator.py │ ├── entity.py │ ├── __init__.py │ ├── binary_sensor.py │ ├── translations │ ├── en.json │ └── nl.json │ ├── config_flow.py │ ├── weather.py │ └── sensor.py ├── .prettierrc.yaml ├── hacs.json ├── config ├── configuration.yaml └── README.md ├── .vscode ├── extensions.json ├── launch.json ├── tasks.json └── settings.json ├── package.json ├── scripts ├── develop.sh ├── local_ci_checks.sh ├── utils.sh └── setup_env.sh ├── sonar-project.properties ├── LICENSE ├── tests ├── test_diagnostics.py ├── __init__.py ├── test_metadata.py ├── conftest.py ├── test_sensor.py ├── test_coordinator.py ├── test_init.py ├── test_binary_sensor.py ├── test_config_flow.py ├── fixtures │ ├── cold_snow.json │ ├── warm_snow.json │ ├── clear_night_fix.json │ ├── response.json │ └── response_alarm.json └── test_weather.py ├── .yamllint ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── pyproject.toml ├── .devcontainer └── devcontainer.json ├── .gitignore └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: golles 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 88 2 | tabWidth: 2 3 | useTabs: false 4 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "KNMI", 3 | "homeassistant": "2025.8.0", 4 | "zip_release": true, 5 | "filename": "knmi.zip" 6 | } 7 | -------------------------------------------------------------------------------- /custom_components/knmi/const.py: -------------------------------------------------------------------------------- 1 | """Constants for knmi.""" 2 | 3 | from typing import Final 4 | 5 | # Base component constants. 6 | DOMAIN: Final = "knmi" 7 | NAME: Final = "KNMI" 8 | 9 | # Defaults 10 | DEFAULT_NAME: Final = NAME 11 | DEFAULT_SCAN_INTERVAL: Final = 300 12 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | ffmpeg: 2 | ffmpeg_bin: /usr/bin/ffmpeg 3 | 4 | history: 5 | 6 | http: 7 | trusted_proxies: 8 | - 127.0.0.1 9 | - ::1 10 | use_x_forwarded_for: true 11 | 12 | logbook: 13 | 14 | logger: 15 | default: warn 16 | logs: 17 | custom_components.knmi: debug 18 | weerlive: debug 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "esbenp.prettier-vscode", 5 | "github.vscode-github-actions", 6 | "ms-python.mypy-type-checker", 7 | "ms-python.pylint", 8 | "ms-python.python", 9 | "ms-python.vscode-pylance", 10 | "ryanluker.vscode-coverage-gutters", 11 | "timonwong.shellcheck" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python Debugger: Current File", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "purpose": ["debug-test"], 10 | "console": "integratedTerminal", 11 | "justMyCode": true, 12 | "env": { "PYTEST_ADDOPTS": "--no-cov" } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ha-knmi", 3 | "version": "0.0.1", 4 | "description": "Custom component that integrates KNMI weather service (via Weerlive.nl) in to Home Assistant", 5 | "author": "Sander Gols ", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=22.0.0" 9 | }, 10 | "devDependencies": { 11 | "prettier": "3.7.4" 12 | }, 13 | "scripts": { 14 | "prettier": "prettier" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/knmi/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "knmi", 3 | "name": "KNMI", 4 | "codeowners": ["@golles"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/golles/ha-knmi/blob/main/README.md", 7 | "iot_class": "cloud_polling", 8 | "issue_tracker": "https://github.com/golles/ha-knmi/issues", 9 | "loggers": ["weerlive", "custom_components.knmi"], 10 | "requirements": ["weerlive-api==0.2.2"], 11 | "version": "0.0.0" 12 | } 13 | -------------------------------------------------------------------------------- /scripts/develop.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Set the path to custom_components 8 | ## This let's us have the structure we want /custom_components/integration_name 9 | ## while at the same time have Home Assistant configuration inside /config 10 | ## without resulting to symlinks. 11 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 12 | 13 | # Install dependencies needed to run Home Assistant 14 | uv sync --group runhass 15 | 16 | # Start Home Assistant 17 | uv run hass --config "${PWD}/config" --debug 18 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=golles_ha-knmi 2 | sonar.organization=golles 3 | sonar.projectName=ha-knmi 4 | sonar.projectVersion=1.0 5 | 6 | sonar.links.homepage=https://github.com/golles/ha-knmi 7 | sonar.links.ci=https://github.com/golles/ha-knmi/actions 8 | sonar.links.issue=https://github.com/golles/ha-knmi/issues 9 | sonar.links.scm=https://github.com/golles/ha-knmi/tree/main 10 | 11 | sonar.language=py 12 | sonar.sourceEncoding=UTF-8 13 | sonar.sources=custom_components/knmi 14 | sonar.tests=tests 15 | 16 | sonar.python.version=3.13 17 | sonar.python.coverage.reportPaths=coverage.xml 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Report API issue 4 | url: https://weerlive.nl/over-weerlive-contact.php#contact 5 | about: Report issues related to the API. 6 | 7 | - name: API key request 8 | url: https://weerlive.nl/api/toegang/index.php 9 | about: Request your free API key. 10 | 11 | - name: API documentation 12 | url: https://weerlive.nl/delen.php 13 | about: Weerlive API documentation. 14 | 15 | - name: Weerlive library 16 | url: https://github.com/golles/python-weerlive 17 | about: Python library repository. 18 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Configuration 2 | 3 | This folder is used as the configuration directory for Home Assistant when running in a development container or GitHub Codespace. 4 | 5 | ## Why is this folder in the repo? 6 | 7 | When Home Assistant is started in the development environment, it uses this directory for all its configuration files, logs, and other runtime data. Keeping it in the repository ensures a consistent setup for development. 8 | 9 | ## Git Ignore Policy 10 | 11 | This folder is ignored by Git to prevent committing personal data, logs, and temporary files. However, some essential configuration files, like `configuration.yaml`, are included to provide a working base configuration. 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "pre-commit:all-files", 6 | "type": "shell", 7 | "command": "uv run pre-commit run --all-files", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "pre-commit:staged", 12 | "type": "shell", 13 | "command": "uv run pre-commit run", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Run Home Assistant", 18 | "type": "shell", 19 | "command": "scripts/develop.sh", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Run tests", 24 | "type": "shell", 25 | "command": "uv run pytest", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /custom_components/knmi/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "sun": { 5 | "default": "mdi:white-balance-sunny", 6 | "state": { 7 | "off": "mdi:weather-night" 8 | } 9 | } 10 | }, 11 | "sensor": { 12 | "neersl_perc_dag_today": { "default": "mdi:weather-rainy" }, 13 | "neersl_perc_dag_tomorrow": { "default": "mdi:weather-rainy" }, 14 | "plaats": { "default": "mdi:map-marker" }, 15 | "rest_verz": { "default": "mdi:api" }, 16 | "samenv": { "default": "mdi:text" }, 17 | "verw": { "default": "mdi:text" }, 18 | "windkmh": { "default": "mdi:weather-windy" }, 19 | "wrschklr": { "default": "mdi:information" } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - main 7 | pull_request: ~ 8 | schedule: 9 | - cron: "0 0 * * 6" 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | hacs: 16 | name: HACS validation 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Run HACS validation 20 | uses: hacs/action@main 21 | with: 22 | category: integration 23 | 24 | hassfest: 25 | name: Hassfest validation 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Check out code from GitHub 29 | uses: actions/checkout@v6 30 | 31 | - name: Run hassfest validation 32 | uses: home-assistant/actions/hassfest@master 33 | -------------------------------------------------------------------------------- /custom_components/knmi/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for knmi.""" 2 | 3 | from homeassistant.components.diagnostics import async_redact_data 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE 6 | from homeassistant.core import HomeAssistant 7 | 8 | from .coordinator import KnmiDataUpdateCoordinator 9 | 10 | TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE} 11 | 12 | 13 | async def async_get_config_entry_diagnostics(_hass: HomeAssistant, config_entry: ConfigEntry[KnmiDataUpdateCoordinator]) -> dict: 14 | """Return diagnostics for a config entry.""" 15 | coordinator = config_entry.runtime_data 16 | 17 | return { 18 | "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), 19 | "data": coordinator.data.to_dict() if coordinator.data else {}, 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | update_release_draft: 13 | name: Update release draft 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | steps: 18 | - name: Check out code from GitHub 19 | uses: actions/checkout@v6 20 | 21 | - name: Setup app 22 | id: app 23 | uses: actions/create-github-app-token@v2 24 | with: 25 | app-id: ${{ secrets.PR2D2_APP_ID }} 26 | private-key: ${{ secrets.PR2D2_APP_PRIVATE_KEY }} 27 | 28 | - name: Run release drafter 29 | uses: release-drafter/release-drafter@v6 30 | env: 31 | GITHUB_TOKEN: ${{ steps.app.outputs.token }} 32 | with: 33 | config-name: release-drafter.yaml 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Proposed change 7 | 8 | 20 | 21 | ## Breaking change 22 | 23 | 28 | 29 | ## Checklist: 30 | 31 | - [ ] I have performed a self-review of my code 32 | - [ ] I have added tests that prove my fix is effective or that my feature works 33 | -------------------------------------------------------------------------------- /.github/workflows/license.yaml: -------------------------------------------------------------------------------- 1 | name: License 2 | 3 | on: # yamllint disable-line rule:truthy 4 | schedule: 5 | - cron: "0 11 1 1 *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | update-license-year: 13 | name: Update license year 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - name: Check out code from GitHub 20 | uses: actions/checkout@v6 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup app 25 | id: app 26 | uses: actions/create-github-app-token@v2 27 | with: 28 | app-id: ${{ secrets.PR2D2_APP_ID }} 29 | private-key: ${{ secrets.PR2D2_APP_PRIVATE_KEY }} 30 | 31 | - name: Update license 32 | uses: FantasticFiasco/action-update-license-year@v3 33 | with: 34 | token: ${{ steps.app.outputs.token }} 35 | labels: enhancement 36 | -------------------------------------------------------------------------------- /scripts/local_ci_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script parses the CI workflow checks job from .github/workflows/ci.yaml and runs them locally. It extracts each check's name and command from 4 | # the job matrix and executes the commands sequentially, logging the name of each check before running it. 5 | 6 | set -e 7 | 8 | # shellcheck source=/dev/null 9 | source "$(dirname "$0")/utils.sh" 10 | 11 | # Ensure required commands are installed 12 | command_exists jq yq 13 | 14 | cd "$(dirname "$0")/.." 15 | 16 | # Extract names and commands using yq and convert to JSON. 17 | entries=$(yq -o=json '.jobs.checks.strategy.matrix.include' .github/workflows/ci.yaml) 18 | 19 | # Iterate over each entry and execute the corresponding command. 20 | echo "$entries" | jq -c '.[]' | while IFS= read -r entry; do 21 | name=$(echo "$entry" | jq -r '.name') 22 | command=$(echo "$entry" | jq -r '.command') 23 | 24 | log_yellow "Run $name" 25 | eval "$command" 26 | log_empty_line 1 27 | done 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: The request 10 | description: >- 11 | Describe your feature request here to communicate to the maintainers. Tell us what you were trying to do and why. 12 | 13 | Provide a clear and concise description of what the feature request is. What would you like to happen? 14 | 15 | - type: markdown 16 | attributes: 17 | value: | 18 | ## Details 19 | 20 | - type: textarea 21 | id: additional-information 22 | attributes: 23 | label: Additional information 24 | description: >- 25 | If you have any additional information for us, use the field below. Please note, you can attach screenshots or screen recordings here, by dragging and dropping files in the field below. 26 | 27 | - type: markdown 28 | attributes: 29 | value: | 30 | Thanks for taking the time to fill out this feature request! 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 golles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: # yamllint disable-line rule:truthy 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Run stale 16 | uses: actions/stale@v10 17 | with: 18 | exempt-issue-labels: "no-stale" 19 | exempt-pr-labels: "no-stale" 20 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 21 | stale-pr-message: "This PR is stale because it has been open for 45 days with no activity. Remove stale label or comment or this will be closed in 10 days." 22 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." 23 | close-pr-message: "This PR was closed because it has been stalled for 10 days with no activity." 24 | days-before-issue-stale: 30 25 | days-before-pr-stale: 45 26 | days-before-issue-close: 5 27 | days-before-pr-close: 10 28 | -------------------------------------------------------------------------------- /scripts/utils.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This file contains utility functions used in other scripts. 4 | 5 | set -e 6 | 7 | # Check if all given commands exist 8 | # Usage: command_exists cmd1 cmd2 ... 9 | # - Logs an error for each missing command. 10 | # - Exits with status 1 if any command is missing. 11 | command_exists() { 12 | local missing=() 13 | 14 | for cmd in "$@"; do 15 | if ! command -v "$cmd" >/dev/null 2>&1; then 16 | log_error "$cmd is not installed. Please install it manually." 17 | missing+=("$cmd") 18 | fi 19 | done 20 | 21 | # Exit if any commands were missing 22 | if [ "${#missing[@]}" -ne 0 ]; then 23 | exit 1 24 | fi 25 | } 26 | 27 | # Function to log messages in yellow color 28 | # Usage: log_yellow [string] 29 | log_yellow() { 30 | printf '\e[33m%s\e[0m\n' "$1" 31 | } 32 | 33 | # Function to log error messages in red color 34 | # Usage: log_error [string] 35 | log_error() { 36 | printf '\e[31m%s\e[0m\n' "$1" >&2 37 | } 38 | 39 | # Function to print empty lines 40 | # Usage: log_empty [int] 41 | log_empty_line() { 42 | local count="$1" 43 | for _ in $(seq "$count"); do 44 | printf '\n' 45 | done 46 | } 47 | -------------------------------------------------------------------------------- /.github/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name-template: v$RESOLVED_VERSION 2 | tag-template: v$RESOLVED_VERSION 3 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 4 | sort-direction: ascending 5 | 6 | categories: 7 | - title: Breaking changes 8 | labels: 9 | - breaking-change 10 | - title: New features 11 | labels: 12 | - new-feature 13 | - title: Bug fixes 14 | labels: 15 | - bugfix 16 | - title: Enhancements 17 | labels: 18 | - enhancement 19 | - title: Maintenance 20 | labels: 21 | - ci 22 | - dev-environment 23 | - title: Documentation 24 | labels: 25 | - documentation 26 | - title: Dependency updates 27 | labels: 28 | - dependencies 29 | 30 | version-resolver: 31 | major: 32 | labels: 33 | - major 34 | - breaking-change 35 | minor: 36 | labels: 37 | - minor 38 | - new-feature 39 | patch: 40 | labels: 41 | - bugfix 42 | - ci 43 | - dependencies 44 | - documentation 45 | - enhancement 46 | default: patch 47 | 48 | template: | 49 | Thank you for using the KNMI integration! If you find it helpful, please consider starring the repository. ⭐️ 50 | 51 | ## What's Changed 52 | 53 | $CHANGES 54 | -------------------------------------------------------------------------------- /tests/test_diagnostics.py: -------------------------------------------------------------------------------- 1 | """Test diagnostics.""" 2 | 3 | import pytest 4 | from homeassistant.components.diagnostics import REDACTED 5 | from homeassistant.core import HomeAssistant 6 | from pytest_homeassistant_custom_component.components.diagnostics import get_diagnostics_for_config_entry 7 | from pytest_homeassistant_custom_component.typing import ClientSessionGenerator 8 | 9 | from custom_components.knmi.const import DOMAIN 10 | from custom_components.knmi.diagnostics import TO_REDACT 11 | 12 | from . import setup_integration, unload_integration 13 | 14 | 15 | @pytest.mark.usefixtures("mocked_data") 16 | async def test_config_entry_diagnostics(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None: 17 | """Test config entry diagnostics.""" 18 | config_entry = await setup_integration(hass) 19 | 20 | result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) 21 | 22 | assert result["config_entry"]["entry_id"] == "test_entry" 23 | assert result["config_entry"]["domain"] == DOMAIN 24 | 25 | for key in TO_REDACT: 26 | assert result["config_entry"]["data"][key] == REDACTED 27 | 28 | assert result["data"]["live"]["city"] == "Purmerend" 29 | 30 | await unload_integration(hass, config_entry) 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: # yamllint disable-line rule:truthy 4 | release: 5 | types: 6 | - published 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release: 13 | name: Build and publish 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | steps: 18 | - name: Check out code from GitHub 19 | uses: actions/checkout@v6 20 | 21 | - name: Setup Python and uv 22 | uses: astral-sh/setup-uv@v7 23 | with: 24 | enable-cache: true 25 | 26 | - name: Setup Node 27 | uses: actions/setup-node@v6 28 | with: 29 | cache: npm 30 | 31 | - name: Set integration version 32 | run: | 33 | version="${{ github.event.release.tag_name }}" 34 | version=${version#v} 35 | sed -i -E 's/"version": *"[^"]+"/"version": "'"$version"'"/' custom_components/knmi/manifest.json 36 | 37 | - name: Create archive 38 | shell: bash 39 | run: | 40 | cd "${{ github.workspace }}/custom_components/knmi" 41 | zip -r "${{ github.workspace }}/knmi.zip" ./* 42 | 43 | - name: Upload release artifacts 44 | uses: softprops/action-gh-release@v2 45 | with: 46 | files: knmi.zip 47 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | ignore: 2 | - .venv 3 | 4 | extends: default 5 | 6 | rules: 7 | braces: 8 | level: error 9 | min-spaces-inside: 0 10 | max-spaces-inside: 1 11 | min-spaces-inside-empty: -1 12 | max-spaces-inside-empty: -1 13 | brackets: 14 | level: error 15 | min-spaces-inside: 0 16 | max-spaces-inside: 0 17 | min-spaces-inside-empty: -1 18 | max-spaces-inside-empty: -1 19 | colons: 20 | level: error 21 | max-spaces-before: 0 22 | max-spaces-after: 1 23 | commas: 24 | level: error 25 | max-spaces-before: 0 26 | min-spaces-after: 1 27 | max-spaces-after: 1 28 | comments: 29 | level: error 30 | require-starting-space: true 31 | min-spaces-from-content: 1 32 | comments-indentation: 33 | level: error 34 | document-end: 35 | level: error 36 | present: false 37 | document-start: 38 | level: error 39 | present: false 40 | empty-lines: 41 | level: error 42 | max: 1 43 | max-start: 0 44 | max-end: 1 45 | hyphens: 46 | level: error 47 | max-spaces-after: 1 48 | indentation: 49 | level: error 50 | spaces: 2 51 | indent-sequences: true 52 | check-multi-line-strings: false 53 | key-duplicates: 54 | level: error 55 | line-length: disable 56 | new-line-at-end-of-file: 57 | level: error 58 | new-lines: 59 | level: error 60 | type: unix 61 | trailing-spaces: 62 | level: error 63 | truthy: 64 | level: error 65 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": null, 3 | "editor.formatOnPaste": false, 4 | "editor.formatOnSave": true, 5 | "editor.formatOnType": true, 6 | "editor.rulers": [150], 7 | "editor.tabSize": 4, 8 | "editor.wordWrap": "wordWrapColumn", 9 | "editor.wordWrapColumn": 150, 10 | "files.eol": "\n", 11 | "files.trimTrailingWhitespace": true, 12 | "mypy-type-checker.importStrategy": "fromEnvironment", 13 | "pylint.importStrategy": "fromEnvironment", 14 | "python.analysis.diagnosticSeverityOverrides": { 15 | "reportShadowedImports": "none" 16 | }, 17 | "python.analysis.extraPaths": ["${workspaceFolder}/custom_components/knmi"], 18 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 19 | "python.testing.cwd": "${workspaceFolder}", 20 | "python.testing.pytestArgs": ["--cov-report=xml"], 21 | "python.testing.pytestEnabled": true, 22 | "ruff.importStrategy": "fromEnvironment", 23 | "ruff.interpreter": ["${workspaceFolder}/.venv/bin/python"], 24 | "terminal.integrated.defaultProfile.linux": "zsh", 25 | "[json]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[jsonc]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "[markdown]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode" 33 | }, 34 | "[python]": { 35 | "editor.defaultFormatter": "charliermarsh.ruff" 36 | }, 37 | "[yaml]": { 38 | "editor.defaultFormatter": "esbenp.prettier-vscode" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests.""" 2 | 3 | from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME 4 | from homeassistant.core import HomeAssistant 5 | from pytest_homeassistant_custom_component.common import MockConfigEntry 6 | 7 | from custom_components.knmi.const import DOMAIN 8 | 9 | 10 | def get_mock_config_data() -> dict[str, str | float]: 11 | """Create a mock configuration for testing.""" 12 | return { 13 | CONF_NAME: "Home", 14 | CONF_API_KEY: "abc123xyz000", 15 | CONF_LATITUDE: 52.354, 16 | CONF_LONGITUDE: 4.763, 17 | } 18 | 19 | 20 | def get_mock_config_entry() -> MockConfigEntry: 21 | """Create a mock config entry for testing.""" 22 | return MockConfigEntry( 23 | domain=DOMAIN, 24 | entry_id="test_entry", 25 | data=get_mock_config_data(), 26 | ) 27 | 28 | 29 | async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: 30 | """Set up the custom component for tests.""" 31 | config_entry = get_mock_config_entry() 32 | config_entry.add_to_hass(hass) 33 | assert await hass.config_entries.async_setup(config_entry.entry_id) 34 | await hass.async_block_till_done() 35 | 36 | return config_entry 37 | 38 | 39 | async def unload_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: 40 | """Unload the custom component for tests.""" 41 | assert await hass.config_entries.async_unload(config_entry.entry_id) 42 | await hass.async_block_till_done() 43 | -------------------------------------------------------------------------------- /.github/workflows/api-issue-comment.yaml: -------------------------------------------------------------------------------- 1 | name: API issue comment 2 | 3 | on: # yamllint disable-line rule:truthy 4 | issues: 5 | types: 6 | - labeled 7 | 8 | jobs: 9 | add-comment: 10 | name: Add comment to issue with 'API issue' label 11 | runs-on: ubuntu-latest 12 | permissions: 13 | issues: write 14 | if: github.event.label.name == 'API issue' 15 | steps: 16 | - name: Check out code from GitHub 17 | uses: actions/checkout@v6 18 | 19 | - name: Setup app 20 | id: app 21 | uses: actions/create-github-app-token@v2 22 | with: 23 | app-id: ${{ secrets.PR2D2_APP_ID }} 24 | private-key: ${{ secrets.PR2D2_APP_PRIVATE_KEY }} 25 | 26 | - name: Add comment 27 | run: gh issue comment "$NUMBER" --body "$BODY" 28 | env: 29 | GH_TOKEN: ${{ steps.app.outputs.token }} 30 | GH_REPO: ${{ github.repository }} 31 | NUMBER: ${{ github.event.issue.number }} 32 | BODY: > 33 | Thank you for reporting this issue! 34 | 35 | This appears to be an issue with the external weather service API that this integration uses. 36 | Since I don't own or control this service, I'm unable to fix API-related problems directly. 37 | 38 | For the best chance of getting this resolved, I recommend reaching out to the service provider: 39 | https://weerlive.nl/over-weerlive-contact.php#contact 40 | 41 | They will be able to investigate and address any issues with their API service. 42 | 43 | Thank you for your understanding! 44 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: # yamllint disable-line rule:truthy 4 | pull_request: 5 | branches: 6 | - main 7 | types: 8 | - edited 9 | - opened 10 | - labeled 11 | - reopened 12 | - synchronize 13 | - unlabeled 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | dependency-review: 20 | name: Dependency review 21 | runs-on: ubuntu-latest 22 | permissions: 23 | pull-requests: write 24 | steps: 25 | - name: Check out code from GitHub 26 | uses: actions/checkout@v6 27 | 28 | - name: Dependency review 29 | uses: actions/dependency-review-action@v4 30 | with: 31 | comment-summary-in-pr: always 32 | fail-on-severity: high 33 | 34 | labels: 35 | name: Check labels 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Check if PR has a required label 39 | uses: actions/github-script@v8 40 | with: 41 | script: | 42 | const requiredLabels = [ 43 | 'bugfix', 44 | 'ci', 45 | 'dependencies', 46 | 'dev-environment', 47 | 'documentation', 48 | 'enhancement', 49 | 'new-feature' 50 | ]; 51 | 52 | const prNumber = context.payload.pull_request.number; 53 | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ 54 | owner: context.repo.owner, 55 | repo: context.repo.repo, 56 | issue_number: prNumber, 57 | }); 58 | 59 | const hasValidLabel = labels.some(label => requiredLabels.includes(label.name)); 60 | 61 | if (!hasValidLabel) { 62 | core.setFailed(`PR must have at least one of the following labels: ${requiredLabels.join(', ')}`); 63 | } 64 | -------------------------------------------------------------------------------- /custom_components/knmi/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for knmi.""" 2 | 3 | import logging 4 | from datetime import timedelta 5 | from typing import Self 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 11 | 12 | from weerlive import Response, WeerliveApi 13 | 14 | from .const import DOMAIN 15 | 16 | _LOGGER: logging.Logger = logging.getLogger(__package__) 17 | 18 | 19 | class KnmiDataUpdateCoordinator(DataUpdateCoordinator[Response]): 20 | """Class to manage fetching data from the API.""" 21 | 22 | config_entry: ConfigEntry[Self] 23 | 24 | def __init__( 25 | self, 26 | hass: HomeAssistant, 27 | client: WeerliveApi, 28 | config_entry: ConfigEntry, 29 | update_interval: timedelta, 30 | ) -> None: 31 | """Initialize.""" 32 | self.client = client 33 | 34 | super().__init__( 35 | hass=hass, 36 | logger=_LOGGER, 37 | name=DOMAIN, 38 | config_entry=config_entry, 39 | update_interval=update_interval, 40 | ) 41 | 42 | async def _async_update_data(self) -> Response: 43 | """Update data via library.""" 44 | latitude = self.config_entry.data.get(CONF_LATITUDE) 45 | longitude = self.config_entry.data.get(CONF_LONGITUDE) 46 | 47 | if latitude is None or longitude is None: 48 | raise UpdateFailed 49 | 50 | try: 51 | return await self.client.latitude_longitude(latitude=float(latitude), longitude=float(longitude)) 52 | except Exception as exception: 53 | _LOGGER.warning("Failed to update data: %s", exception) 54 | raise UpdateFailed from exception 55 | -------------------------------------------------------------------------------- /scripts/setup_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | # shellcheck source=/dev/null 6 | source "$(dirname "$0")/utils.sh" 7 | 8 | # Ensure required commands are installed 9 | command_exists uv npm 10 | 11 | cd "$(dirname "$0")/.." 12 | 13 | # Error handling 14 | error_exit() { 15 | log_error "An error occurred. Exiting..." 16 | exit 1 17 | } 18 | 19 | # Trap any error and execute the error_exit function 20 | trap error_exit ERR 21 | 22 | # Install project dependencies 23 | uv sync 24 | 25 | # Install npm dependencies 26 | npm install 27 | 28 | if [ "$CI" != "true" ]; then 29 | # Trust the repo 30 | git config --global --add safe.directory "$(pwd)" 31 | 32 | # Install pre-commit when available 33 | if check_uv_package "pre-commit" && [ -f .pre-commit-config.yaml ]; then 34 | uv run pre-commit install 35 | fi 36 | 37 | # Install auto completions 38 | mkdir -p ~/.zfunc 39 | uv generate-shell-completion zsh > ~/.zfunc/_uv 40 | if check_uv_package "ruff"; then 41 | uv run ruff generate-shell-completion zsh > ~/.zfunc/_ruff 42 | fi 43 | if command_exists gh &> /dev/null; then 44 | gh completion -s zsh > ~/.zfunc/_gh 45 | fi 46 | grep -qxF 'fpath+=~/.zfunc' ~/.zshrc || echo 'fpath+=~/.zfunc' >> ~/.zshrc 47 | grep -qxF 'autoload -Uz compinit && compinit' ~/.zshrc || echo 'autoload -Uz compinit && compinit' >> ~/.zshrc 48 | fi 49 | 50 | # Check for --devcontainer argument 51 | if [ "$1" == "--devcontainer" ]; then 52 | log_empty_line 2 53 | log_yellow "The dev container is ready" 54 | log_yellow "Once all the extensions are installed, reload the window (Command Palette -> Developer: Reload Window) to make sure all extensions are activated!" 55 | else 56 | log_empty_line 1 57 | log_yellow "Done, you might want to reload your terminal or run \"source .venv/bin/activate\" to activate the virtual environment" 58 | fi 59 | -------------------------------------------------------------------------------- /custom_components/knmi/entity.py: -------------------------------------------------------------------------------- 1 | """KNMI entity.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | from typing import Any 8 | 9 | from homeassistant.const import CONF_NAME 10 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 11 | from homeassistant.helpers.entity import EntityDescription 12 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 13 | 14 | from weerlive import Response 15 | 16 | from .const import DOMAIN 17 | from .coordinator import KnmiDataUpdateCoordinator 18 | 19 | 20 | @dataclass(kw_only=True, frozen=True) 21 | class KnmiEntityDescription(EntityDescription): 22 | """Class describing KNMI entities.""" 23 | 24 | state_attributes_fn: Callable[[Response], dict[str, Any]] = lambda _: {} 25 | 26 | 27 | class KnmiEntity(CoordinatorEntity[KnmiDataUpdateCoordinator]): 28 | """Representation of a KNMI entity.""" 29 | 30 | entity_description: KnmiEntityDescription 31 | 32 | _attr_has_entity_name = True 33 | 34 | def __init__( 35 | self, 36 | coordinator: KnmiDataUpdateCoordinator, 37 | ) -> None: 38 | """Initialize the KNMI entity.""" 39 | super().__init__(coordinator=coordinator) 40 | 41 | self.coordinator = coordinator 42 | 43 | self._attr_device_info = DeviceInfo( 44 | configuration_url="https://weerlive.nl/api/toegang/account.php", 45 | entry_type=DeviceEntryType.SERVICE, 46 | identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, 47 | manufacturer="Weerlive", 48 | name=coordinator.config_entry.data.get(CONF_NAME), 49 | sw_version=None, 50 | ) 51 | 52 | @property 53 | def extra_state_attributes(self) -> dict[str, Any]: 54 | """Return entity specific state attributes.""" 55 | return self.entity_description.state_attributes_fn(self.coordinator.data) 56 | -------------------------------------------------------------------------------- /tests/test_metadata.py: -------------------------------------------------------------------------------- 1 | """Test for metadata.""" 2 | 3 | import json 4 | import re 5 | import tomllib 6 | from pathlib import Path 7 | 8 | from custom_components.knmi.const import DOMAIN, NAME 9 | 10 | 11 | async def test_manifest_values() -> None: 12 | """Verify that the manifest and const.py values are equal.""" 13 | # Load manifest.json 14 | manifest_path = Path(__file__).parent.parent / "custom_components" / "knmi" / "manifest.json" 15 | with manifest_path.open() as f: 16 | manifest = json.load(f) 17 | 18 | assert manifest.get("domain") == DOMAIN 19 | assert manifest.get("name") == NAME 20 | 21 | 22 | def get_dependency_version(dependencies: list[str], package: str) -> str | None: 23 | """Extract the version of a specific package from a list of dependencies.""" 24 | pattern = re.compile(rf"^{re.escape(package)}==(.+)$") 25 | for dep in dependencies: 26 | match = pattern.match(dep) 27 | if match: 28 | return match.group(1) 29 | return None 30 | 31 | 32 | def test_versions_match() -> None: 33 | """Verify that the version of the library in pyproject.toml matches the one in manifest.json.""" 34 | lib = "weerlive-api" 35 | 36 | # Load pyproject.toml 37 | pyproject_path = Path(__file__).parent.parent / "pyproject.toml" 38 | with pyproject_path.open("rb") as f: 39 | pyproject = tomllib.load(f) 40 | 41 | pyproject_deps = pyproject.get("project", {}).get("dependencies", []) 42 | assert pyproject_deps != [] 43 | pyproject_version = get_dependency_version(pyproject_deps, lib) 44 | 45 | # Load manifest.json 46 | manifest_path = Path(__file__).parent.parent / "custom_components" / "knmi" / "manifest.json" 47 | with manifest_path.open() as f: 48 | manifest = json.load(f) 49 | 50 | manifest_deps = manifest.get("requirements", []) 51 | assert manifest_deps != [] 52 | manifest_version = get_dependency_version(manifest_deps, lib) 53 | 54 | assert manifest_version == pyproject_version, ( 55 | f"Version mismatch: manifest.json has {lib}=={manifest_version}, but pyproject.toml has {lib}=={pyproject_version}" 56 | ) 57 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for the custom component.""" 2 | 3 | from collections.abc import Generator 4 | from unittest.mock import AsyncMock, Mock, PropertyMock, patch 5 | 6 | import pytest 7 | from pytest_homeassistant_custom_component.common import load_fixture 8 | 9 | from weerlive import Response, WeerliveApi 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def auto_enable_custom_integrations(enable_custom_integrations: Generator) -> Generator[None]: 14 | """Enable custom integrations.""" 15 | return enable_custom_integrations 16 | 17 | 18 | @pytest.fixture(name="enable_all_entities", autouse=True) 19 | def fixture_enable_all_entities() -> Generator[None]: 20 | """Make sure all entities are enabled.""" 21 | with patch( 22 | "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", 23 | PropertyMock(return_value=True), 24 | ): 25 | yield 26 | 27 | 28 | @pytest.fixture(autouse=True) 29 | def mock_async_get_clientsession() -> Generator[None]: 30 | """Mock async_get_clientsession to avoid aiohttp client session issues in tests.""" 31 | with ( 32 | patch("custom_components.knmi.async_get_clientsession"), 33 | patch("custom_components.knmi.config_flow.async_get_clientsession"), 34 | ): 35 | yield 36 | 37 | 38 | @pytest.fixture(autouse=True, name="mock_weerlive_client") 39 | def fixture_mock_weerlive_client() -> Generator[AsyncMock]: 40 | """Auto-patch WeerliveApi in all tests and return the mock for configuration.""" 41 | mock_client = AsyncMock(spec=WeerliveApi) 42 | mock_client_class = Mock(return_value=mock_client) 43 | 44 | with ( 45 | patch("custom_components.knmi.WeerliveApi", mock_client_class), 46 | patch("custom_components.knmi.config_flow.WeerliveApi", mock_client_class), 47 | ): 48 | yield mock_client 49 | 50 | 51 | @pytest.fixture(name="mocked_data") 52 | def fixture_mocked_data(request: pytest.FixtureRequest, mock_weerlive_client: AsyncMock) -> None: 53 | """Fixture for mocking a response with a configurable JSON file.""" 54 | json_file = getattr(request, "param", "response.json") 55 | response_json = load_fixture(json_file) 56 | mock_response = Response.from_json(response_json) 57 | mock_weerlive_client.latitude_longitude.return_value = mock_response 58 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for sensor.""" 2 | 3 | import pytest 4 | from homeassistant.core import HomeAssistant 5 | 6 | from . import setup_integration, unload_integration 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("entity", "value"), 11 | [ 12 | ("sensor.home_dew_point", "10.1"), 13 | ("sensor.home_solar_irradiance", "0"), 14 | ("sensor.home_wind_chill", "6.8"), 15 | ("sensor.home_air_pressure", "1015.03"), 16 | ("sensor.home_humidity", "97"), 17 | ("sensor.home_max_temperature_today", "10.0"), 18 | ("sensor.home_max_temperature_tomorrow", "12.0"), 19 | ("sensor.home_min_temperature_today", "10.0"), 20 | ("sensor.home_min_temperature_tomorrow", "10.0"), 21 | ("sensor.home_precipitation_today", "0"), 22 | ("sensor.home_precipitation_tomorrow", "10"), 23 | ("sensor.home_location", "Purmerend"), 24 | ("sensor.home_remaining_api_requests", "132"), 25 | ("sensor.home_description", "Licht bewolkt"), 26 | ("sensor.home_temperature", "10.5"), 27 | ("sensor.home_latest_update", "2024-02-14T21:08:03+00:00"), 28 | ("sensor.home_weather_forecast", "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht"), 29 | ("sensor.home_wind_speed", "29.1"), 30 | ("sensor.home_weather_code", "groen"), 31 | ("sensor.home_visibility", "6990"), 32 | ], 33 | ) 34 | @pytest.mark.usefixtures("mocked_data") 35 | async def test_state(hass: HomeAssistant, entity: str, value: str) -> None: 36 | """Test sensor state.""" 37 | config_entry = await setup_integration(hass) 38 | 39 | state = hass.states.get(entity) 40 | assert state 41 | assert state.state == value 42 | 43 | await unload_integration(hass, config_entry) 44 | 45 | 46 | @pytest.mark.usefixtures("mocked_data") 47 | async def test_state_with_attributes(hass: HomeAssistant) -> None: 48 | """Test sensor state.""" 49 | config_entry = await setup_integration(hass) 50 | 51 | state = hass.states.get("sensor.home_wind_speed") 52 | assert state 53 | assert state.state == "29.1" 54 | assert state.attributes.get("bearing") == "WZW" 55 | assert state.attributes.get("degree") == 226 56 | assert state.attributes.get("beaufort") == 5 57 | assert (str(state.attributes.get("knots"))) == "15.7" 58 | 59 | await unload_integration(hass, config_entry) 60 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "schedule": ["before 9am"], 5 | "labels": ["dependencies"], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automerge": true 9 | }, 10 | "pre-commit": { 11 | "enabled": true 12 | }, 13 | "packageRules": [ 14 | { 15 | "description": "Devcontainer updates", 16 | "matchManagers": ["devcontainer"], 17 | "addLabels": ["devcontainer"], 18 | "automerge": false 19 | }, 20 | { 21 | "description": "Github actions", 22 | "matchManagers": ["github-actions"], 23 | "addLabels": ["github-actions"], 24 | "rangeStrategy": "pin" 25 | }, 26 | { 27 | "description": "Javascript dependencies ignore node engine", 28 | "matchManagers": ["npm"], 29 | "matchDepNames": ["node"], 30 | "enabled": false 31 | }, 32 | { 33 | "description": "Javascript dependencies", 34 | "matchManagers": ["npm"], 35 | "addLabels": ["javascript"], 36 | "rangeStrategy": "pin" 37 | }, 38 | { 39 | "description": "Python dependencies ignore pytest dependencies", 40 | "matchManagers": ["pep621"], 41 | "matchPackageNames": [ 42 | "pytest", 43 | "pytest-asyncio", 44 | "pytest-cov", 45 | "pytest-homeassistant-custom-component" 46 | ], 47 | "enabled": false 48 | }, 49 | { 50 | "description": "Ignore Home Assistant runtime dependencies (runhass group)", 51 | "matchManagers": ["pep621"], 52 | "matchDepTypes": ["runhass"], 53 | "enabled": false 54 | }, 55 | { 56 | "description": "Python dependencies", 57 | "matchManagers": ["pep621"], 58 | "addLabels": ["python"] 59 | }, 60 | { 61 | "description": "Pre-commit hooks", 62 | "matchManagers": ["pre-commit"], 63 | "addLabels": ["pre-commit"], 64 | "automerge": true 65 | }, 66 | { 67 | "description": "Automerge non-major updates", 68 | "matchUpdateTypes": ["minor", "patch"], 69 | "matchCurrentVersion": "!/^0/", 70 | "automerge": true 71 | }, 72 | { 73 | "description": "Automerge and pin dev dependencies", 74 | "matchDepTypes": ["devDependencies", "dev-dependencies", "dev"], 75 | "automerge": true, 76 | "rangeStrategy": "pin" 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-ast 7 | - id: check-case-conflict 8 | - id: check-docstring-first 9 | # - id: check-executables-have-shebangs # Doesn't work in devcontainer on Mac 10 | # - id: check-json # There is invalid json in the fixtures folder on purpose 11 | - id: check-merge-conflict 12 | - id: check-shebang-scripts-are-executable 13 | - id: check-symlinks 14 | - id: check-toml 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: detect-private-key 18 | - id: end-of-file-fixer 19 | - id: name-tests-test 20 | args: [--pytest-test-first] 21 | exclude: const.py 22 | - id: trailing-whitespace 23 | 24 | # CI checks 25 | # The following tools are all installed locally, so there is no need to use remote repos. 26 | # The main reason is to not have to worry about version mismatch between the project and pre-commit. 27 | - repo: local 28 | hooks: 29 | - id: mypy 30 | name: mypy 31 | entry: uv run mypy custom_components/knmi 32 | language: system 33 | types: [python] 34 | pass_filenames: false 35 | 36 | - id: prettier 37 | name: prettier 38 | entry: npm run prettier -- --write . 39 | language: system 40 | pass_filenames: false 41 | 42 | - id: pylint 43 | name: pylint 44 | entry: uv run pylint 45 | language: system 46 | types: [python] 47 | require_serial: true 48 | 49 | - id: ruff-check 50 | name: ruff linter 51 | entry: uv run ruff check --fix 52 | language: system 53 | types: [python] 54 | 55 | - id: ruff-lint 56 | name: ruff formatter 57 | entry: uv run ruff format --check . 58 | language: system 59 | types: [python] 60 | 61 | - id: shellcheck 62 | name: shellcheck 63 | entry: uv run shellcheck 64 | language: system 65 | types: [shell] 66 | 67 | - id: uv-lock 68 | name: uv lock 69 | entry: uv lock --check 70 | language: system 71 | pass_filenames: false 72 | 73 | - id: yamllint 74 | name: yamllint 75 | entry: uv run yamllint 76 | language: system 77 | types: [yaml] 78 | -------------------------------------------------------------------------------- /tests/test_coordinator.py: -------------------------------------------------------------------------------- 1 | """Test for data update coordinator.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import AsyncMock 5 | 6 | import pytest 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.update_coordinator import UpdateFailed 9 | from pytest_homeassistant_custom_component.common import MockConfigEntry 10 | 11 | from custom_components.knmi.const import DOMAIN 12 | from custom_components.knmi.coordinator import KnmiDataUpdateCoordinator 13 | 14 | from . import get_mock_config_data, get_mock_config_entry 15 | 16 | 17 | async def test_async_update_data_success(hass: HomeAssistant, mock_weerlive_client: AsyncMock) -> None: 18 | """Test successful data update.""" 19 | coordinator = KnmiDataUpdateCoordinator( 20 | hass=hass, 21 | client=mock_weerlive_client, 22 | config_entry=get_mock_config_entry(), 23 | update_interval=timedelta(minutes=5), 24 | ) 25 | result = await coordinator._async_update_data() # pylint: disable=protected-access # noqa: SLF001 26 | assert isinstance(result, AsyncMock) 27 | mock_weerlive_client.latitude_longitude.assert_awaited_once_with( 28 | latitude=get_mock_config_data()["latitude"], 29 | longitude=get_mock_config_data()["longitude"], 30 | ) 31 | 32 | 33 | @pytest.mark.parametrize("missing_key", ["latitude", "longitude"]) 34 | async def test_async_update_data_missing_coordinates(hass: HomeAssistant, mock_weerlive_client: AsyncMock, missing_key: str) -> None: 35 | """Test missing coordinates.""" 36 | config_entry = MockConfigEntry( 37 | domain=DOMAIN, 38 | entry_id="test_entry", 39 | data={ 40 | "latitude": 52.354 if missing_key != "latitude" else None, 41 | "longitude": 4.763 if missing_key != "longitude" else None, 42 | }, 43 | ) 44 | coordinator = KnmiDataUpdateCoordinator( 45 | hass=hass, 46 | client=mock_weerlive_client, 47 | config_entry=config_entry, 48 | update_interval=timedelta(minutes=5), 49 | ) 50 | with pytest.raises(UpdateFailed): 51 | await coordinator._async_update_data() # pylint: disable=protected-access # noqa: SLF001 52 | 53 | 54 | async def test_async_update_data_api_failure(hass: HomeAssistant, mock_weerlive_client: AsyncMock) -> None: 55 | """Test API failure.""" 56 | mock_weerlive_client.latitude_longitude.side_effect = Exception("API error") 57 | coordinator = KnmiDataUpdateCoordinator( 58 | hass=hass, 59 | client=mock_weerlive_client, 60 | config_entry=get_mock_config_entry(), 61 | update_interval=timedelta(minutes=5), 62 | ) 63 | with pytest.raises(UpdateFailed): 64 | await coordinator._async_update_data() # pylint: disable=protected-access # noqa: SLF001 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve the integration 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: The problem 10 | description: >- 11 | Describe the issue you are experiencing here to communicate to the maintainers. Tell us what you were trying to do and what happened. 12 | 13 | Provide a clear and concise description of what the problem is. What did you expect to happen? 14 | 15 | - type: markdown 16 | attributes: 17 | value: | 18 | ## Environment 19 | 20 | - type: input 21 | id: version 22 | validations: 23 | required: true 24 | attributes: 25 | label: Integration version 26 | placeholder: "0.0.1" 27 | description: >- 28 | Can be found in the Configuration panel -> Integrations -> KNMI 29 | 30 | - type: input 31 | id: ha_version 32 | validations: 33 | required: true 34 | attributes: 35 | label: Home Assistant version 36 | placeholder: core-2025.3.0 37 | description: >- 38 | Can be found in [![System info](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 39 | 40 | - type: input 41 | id: py_version 42 | validations: 43 | required: true 44 | attributes: 45 | label: Python version 46 | placeholder: "3.13" 47 | description: >- 48 | Can be found in [![System info](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) 49 | 50 | - type: markdown 51 | attributes: 52 | value: | 53 | ## Details 54 | 55 | - type: textarea 56 | id: logs 57 | attributes: 58 | label: Home Assistant log 59 | description: >- 60 | Paste your full log here, Please copy from your log file and not from the frontend, [how to enable logs](../blob/main/README.md#collect-logs) 61 | render: shell 62 | 63 | - type: textarea 64 | id: diagnostics 65 | attributes: 66 | label: Diagnostics 67 | description: >- 68 | Paste your diagnostics content here, [how to get diagnostics](https://www.home-assistant.io/integrations/diagnostics/). 69 | render: json 70 | 71 | - type: textarea 72 | id: additional-information 73 | attributes: 74 | label: Additional information 75 | description: >- 76 | If you have any additional information for us, use the field below. Please note, you can attach screenshots or screen recordings here, by 77 | dragging and dropping files in the field below. 78 | 79 | - type: markdown 80 | attributes: 81 | value: | 82 | Thanks for taking the time to fill out this bug report! 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Thank you for taking the time and effort to read this guide! Your contributions are valuable, and we appreciate your interest in improving our project. 4 | 5 | ## Getting Started 6 | 7 | Before you start contributing, it's essential to familiarize yourself with the codebase. Spend some time reading the existing code to understand the current style and structure. This will help you align your contributions with the project's conventions and optimize for readability. 8 | 9 | ## Development Environment 10 | 11 | To ensure a consistent development environment, we recommend using the [devcontainer](https://code.visualstudio.com/docs/devcontainers/containers) or a [GitHub Codespace](https://github.com/codespaces). These tools will provide you with a standardized setup that matches the project's requirements. 12 | 13 | If you prefer using a local development environment, you can create a Python virtual environment using the `scripts/setup_env.sh` script. This script uses [uv](https://docs.astral.sh/uv) to manage dependencies and environment settings. It will also install required `npm` tools. 14 | 15 | ```sh 16 | ./scripts/setup_env.sh 17 | ``` 18 | 19 | ### Pre-Commit Hook 20 | 21 | We use a [pre-commit](https://pre-commit.com) hook to help identify simple issues before submitting your code for review. This ensures that your code meets the project's quality standards and reduces the chances of encountering avoidable errors during the review process. 22 | 23 | ## Submitting Changes 24 | 25 | Changes should be proposed through a pull request (PR). When creating a PR, please include the following: 26 | 27 | - A summary of the changes you are proposing. 28 | - Links to any related issues. 29 | - Relevant motivation and context for the changes. 30 | 31 | This information helps reviewers understand the purpose of your changes and facilitates a smoother review process. 32 | 33 | ### Adding Tests 34 | 35 | To ensure the stability and reliability of the codebase, please include tests with your pull request. We use [pytest](https://pytest.org/) for testing. To run the test suite, simply execute: 36 | 37 | ```sh 38 | pytest tests 39 | ``` 40 | 41 | Adding tests helps verify that your changes work as intended and do not introduce new issues. 42 | 43 | ## Reporting Issues 44 | 45 | If you encounter a bug, have a feature request, or a general question, please use the appropriate issue template provided in the repository. When submitting an issue, it is important to fill out all fields in the template. This ensures we have all the necessary information to reproduce bugs, assess feature requests, or answer questions effectively. Incomplete issues may take longer to address due to insufficient information. 46 | 47 | Thank you for contributing! Your efforts help us maintain a high-quality codebase and make the project better for everyone. 48 | 49 | Happy coding! 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - main 7 | pull_request: ~ 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | checks: 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - name: mypy 23 | command: uv run mypy . 24 | - name: Prettier 25 | command: npm run prettier -- --check . 26 | - name: Pylint 27 | command: uv run pylint custom_components/knmi tests 28 | - name: Ruff linter 29 | command: uv run ruff check --output-format=github . 30 | - name: Ruff formatter 31 | command: uv run ruff format --check . 32 | - name: ShellCheck 33 | command: uv run shellcheck scripts/*.sh 34 | - name: uv lock 35 | command: uv lock --check 36 | - name: YamlLint 37 | command: uv run yamllint --format github . 38 | runs-on: ubuntu-latest 39 | name: ${{ matrix.name }} 40 | steps: 41 | - name: Check out code from GitHub 42 | uses: actions/checkout@v6 43 | 44 | - name: Setup Python and uv 45 | uses: astral-sh/setup-uv@v7 46 | with: 47 | enable-cache: true 48 | 49 | - name: Setup Node 50 | uses: actions/setup-node@v6 51 | with: 52 | cache: npm 53 | 54 | - name: Run setup_env script 55 | run: ./scripts/setup_env.sh 56 | 57 | - name: Run ${{ matrix.name }} 58 | run: ${{ matrix.command }} 59 | 60 | tests: 61 | runs-on: ubuntu-latest 62 | needs: 63 | - checks 64 | name: CI - Tests 65 | steps: 66 | - name: Check out code from GitHub 67 | uses: actions/checkout@v6 68 | 69 | - name: Setup Python and uv 70 | uses: astral-sh/setup-uv@v7 71 | with: 72 | enable-cache: true 73 | 74 | - name: Setup Node 75 | uses: actions/setup-node@v6 76 | with: 77 | cache: npm 78 | 79 | - name: Run setup_env script 80 | run: ./scripts/setup_env.sh 81 | 82 | - name: Run tests 83 | run: uv run pytest 84 | 85 | - name: Make coverage XML report 86 | run: uv run coverage xml 87 | 88 | - name: Upload coverage reports to Codecov 89 | uses: codecov/codecov-action@v5 90 | with: 91 | token: ${{ secrets.CODECOV_TOKEN }} 92 | fail_ci_if_error: true 93 | 94 | - name: SonarCloud Scan 95 | if: github.event.repository.private == false && github.event.pull_request.head.repo.fork == false 96 | uses: sonarsource/sonarqube-scan-action@v7 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ha-knmi" 3 | version = "0.0.1" 4 | description = "Custom component that integrates KNMI weather service (via Weerlive.nl) in to Home Assistant" 5 | authors = [{ name = "Sander Gols", email = "developer@golles.nl" }] 6 | requires-python = ">=3.14.2,<3.15" 7 | readme = "README.md" 8 | license = "MIT" 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Natural Language :: English", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3.13", 16 | "Programming Language :: Python :: 3", 17 | ] 18 | dependencies = [ 19 | "weerlive-api==0.2.2", 20 | "homeassistant>=2025.8.0", 21 | ] 22 | 23 | Documentation = "https://github.com/golles/ha-knmi" 24 | Homepage = "https://github.com/golles/ha-knmi" 25 | Repository = "https://github.com/golles/ha-knmi" 26 | Issues = "https://github.com/golles/ha-knmi/issues" 27 | 28 | [dependency-groups] 29 | dev = [ 30 | "mypy==1.19.1", 31 | "pre-commit==4.5.1", 32 | "pylint==4.0.4", 33 | "pytest", 34 | "pytest-asyncio", 35 | "pytest-cov", 36 | "pytest-homeassistant-custom-component", 37 | "ruff==0.14.10", 38 | "shellcheck-py==0.11.0.1", 39 | "yamllint==1.37.1", 40 | ] 41 | runhass = [ 42 | "hassil", 43 | "home_assistant_intents", 44 | "mutagen", 45 | "pymicro_vad", 46 | "pyspeex_noise", 47 | "PyTurboJPEG", 48 | ] 49 | 50 | [build-system] 51 | requires = ["hatchling==1.28.0"] 52 | build-backend = "hatchling.build" 53 | 54 | [tool.coverage.run] 55 | source = ["custom_components/knmi"] 56 | 57 | [tool.hatch.build.targets.wheel] 58 | packages = ["custom_components/knmi"] 59 | 60 | [tool.mypy] 61 | python_version = "3.13" 62 | ignore_missing_imports = true 63 | 64 | [[tool.mypy.overrides]] 65 | module = "aresponses.*" 66 | ignore_missing_imports = true 67 | 68 | [tool.pylint] 69 | max-line-length = 150 70 | 71 | [tool.pylint."MESSAGES CONTROL"] 72 | disable = [ 73 | "abstract-method", 74 | "duplicate-code", 75 | "unexpected-keyword-arg", 76 | "wrong-import-order", 77 | ] 78 | 79 | [tool.pytest.ini_options] 80 | addopts = "--cov --cov-report=term --cov-report=xml" 81 | asyncio_mode = "auto" 82 | asyncio_default_fixture_loop_scope = "function" 83 | log_cli = true 84 | log_cli_level = "DEBUG" 85 | 86 | [tool.ruff] 87 | line-length = 150 88 | target-version = "py313" 89 | src = ["custom_components/knmi"] 90 | 91 | [tool.ruff.lint] 92 | select = ["ALL"] 93 | ignore = [ 94 | "A005", 95 | "COM812", # Cause conflicts when used with the formatter. 96 | "TC", # Ignore type-checking blocks. 97 | ] 98 | 99 | [tool.ruff.lint.per-file-ignores] 100 | "tests/**/*.py" = [ 101 | "S101", # Allow assertions in tests. 102 | "PLR2004", # Allow magic numbers in tests. 103 | ] 104 | 105 | [tool.ruff.lint.pydocstyle] 106 | convention = "google" 107 | 108 | [tool.ruff.lint.isort] 109 | known-first-party = ["custom_components.knmi", "weerlive"] 110 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HA KNMI", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.13", 4 | "postCreateCommand": "./scripts/setup_env.sh --devcontainer", 5 | "forwardPorts": [8123], 6 | "portsAttributes": { 7 | "8123": { 8 | "label": "Home Assistant", 9 | "onAutoForward": "openBrowserOnce" 10 | } 11 | }, 12 | "containerEnv": { 13 | "DEVCONTAINER": "true" 14 | }, 15 | "customizations": { 16 | "codespaces": { 17 | "openFiles": ["README.md", "CONTRIBUTING.md"] 18 | }, 19 | "vscode": { 20 | "extensions": [ 21 | "charliermarsh.ruff", 22 | "esbenp.prettier-vscode", 23 | "github.vscode-github-actions", 24 | "ms-python.mypy-type-checker", 25 | "ms-python.pylint", 26 | "ms-python.python", 27 | "ms-python.vscode-pylance", 28 | "redhat.vscode-yaml", 29 | "ryanluker.vscode-coverage-gutters", 30 | "timonwong.shellcheck" 31 | ], 32 | "settings": { 33 | "editor.defaultFormatter": null, 34 | "editor.formatOnPaste": false, 35 | "editor.formatOnSave": true, 36 | "editor.formatOnType": true, 37 | "editor.rulers": [150], 38 | "editor.tabSize": 4, 39 | "editor.wordWrap": "wordWrapColumn", 40 | "editor.wordWrapColumn": 150, 41 | "files.eol": "\n", 42 | "files.trimTrailingWhitespace": true, 43 | "mypy-type-checker.importStrategy": "fromEnvironment", 44 | "pylint.importStrategy": "fromEnvironment", 45 | "python.analysis.diagnosticSeverityOverrides": { 46 | "reportShadowedImports": "none" 47 | }, 48 | "python.analysis.extraPaths": ["${workspaceFolder}/custom_components/knmi"], 49 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 50 | "python.testing.cwd": "${workspaceFolder}", 51 | "python.testing.pytestArgs": ["--cov-report=xml"], 52 | "python.testing.pytestEnabled": true, 53 | "ruff.importStrategy": "fromEnvironment", 54 | "ruff.interpreter": ["${workspaceFolder}/.venv/bin/python"], 55 | "terminal.integrated.defaultProfile.linux": "zsh", 56 | "[json]": { 57 | "editor.defaultFormatter": "esbenp.prettier-vscode" 58 | }, 59 | "[jsonc]": { 60 | "editor.defaultFormatter": "esbenp.prettier-vscode" 61 | }, 62 | "[markdown]": { 63 | "editor.defaultFormatter": "esbenp.prettier-vscode" 64 | }, 65 | "[python]": { 66 | "editor.defaultFormatter": "charliermarsh.ruff" 67 | }, 68 | "[yaml]": { 69 | "editor.defaultFormatter": "esbenp.prettier-vscode" 70 | } 71 | } 72 | } 73 | }, 74 | "remoteUser": "vscode", 75 | "features": { 76 | "ghcr.io/devcontainers/features/github-cli:1": { 77 | "version": "latest" 78 | }, 79 | "ghcr.io/devcontainers/features/node:1": { 80 | "version": 22 81 | }, 82 | "ghcr.io/devcontainers-extra/features/apt-packages:1": { 83 | "packages": ["ffmpeg", "libturbojpeg0"] 84 | }, 85 | "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": { 86 | "jqVersion": "latest", 87 | "yqVersion": "latest" 88 | }, 89 | "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {} 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /custom_components/knmi/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom integration to integrate knmi with Home Assistant. 2 | 3 | For more details about this integration, please refer to 4 | https://github.com/golles/ha-knmi/ 5 | """ 6 | 7 | import logging 8 | from datetime import timedelta 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL, Platform 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers import entity_registry as er 14 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 15 | 16 | from weerlive import WeerliveApi 17 | 18 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN 19 | from .coordinator import KnmiDataUpdateCoordinator 20 | 21 | PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.WEATHER] 22 | 23 | _LOGGER: logging.Logger = logging.getLogger(__package__) 24 | 25 | 26 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry[KnmiDataUpdateCoordinator]) -> bool: 27 | """Set up this integration using UI.""" 28 | hass.data.setdefault(DOMAIN, {}) 29 | 30 | api_key = config_entry.data.get(CONF_API_KEY) 31 | scan_interval_seconds = config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) 32 | scan_interval = timedelta(seconds=scan_interval_seconds) 33 | 34 | if not api_key: 35 | msg = "Missing required configuration options: api_key." 36 | raise ValueError(msg) 37 | 38 | client = WeerliveApi(api_key, async_get_clientsession(hass)) 39 | 40 | _LOGGER.debug( 41 | "Set up entry, with scan_interval of %s seconds", 42 | scan_interval_seconds, 43 | ) 44 | 45 | config_entry.runtime_data = coordinator = KnmiDataUpdateCoordinator( 46 | hass=hass, 47 | client=client, 48 | config_entry=config_entry, 49 | update_interval=scan_interval, 50 | ) 51 | 52 | await coordinator.async_config_entry_first_refresh() 53 | 54 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 55 | config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) 56 | 57 | return True 58 | 59 | 60 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry[KnmiDataUpdateCoordinator]) -> bool: 61 | """Unload a config entry.""" 62 | return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 63 | 64 | 65 | async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry[KnmiDataUpdateCoordinator]) -> None: 66 | """Reload config entry.""" 67 | await async_unload_entry(hass, config_entry) 68 | await async_setup_entry(hass, config_entry) 69 | 70 | 71 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry[KnmiDataUpdateCoordinator]) -> bool: 72 | """Migrate old entry.""" 73 | _LOGGER.debug("Migrating from version %s", config_entry.version) 74 | 75 | if config_entry.version == 1: 76 | hass.config_entries.async_update_entry(config_entry, version=2) 77 | 78 | entity_registry = er.async_get(hass) 79 | existing_entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) 80 | 81 | for entry in list(existing_entries): 82 | _LOGGER.debug("Deleting version 1 entity: %s", entry.entity_id) 83 | entity_registry.async_remove(entry.entity_id) 84 | 85 | _LOGGER.debug("Migration to version %s successful", config_entry.version) 86 | 87 | return True 88 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test setup.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | import pytest 6 | from _pytest.logging import LogCaptureFixture 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.exceptions import ConfigEntryNotReady 9 | from homeassistant.helpers import entity_registry as er 10 | from pytest_homeassistant_custom_component.common import MockConfigEntry 11 | 12 | from custom_components.knmi import async_migrate_entry, async_setup_entry, async_unload_entry 13 | from custom_components.knmi.const import DOMAIN 14 | from custom_components.knmi.coordinator import KnmiDataUpdateCoordinator 15 | from weerlive import WeerliveAPIError 16 | 17 | from . import get_mock_config_entry, setup_integration 18 | 19 | 20 | async def test_setup_and_unload_entry(hass: HomeAssistant) -> None: 21 | """Test entry setup and unload.""" 22 | config_entry = await setup_integration(hass) 23 | 24 | # Check that the client is stored as runtime_data 25 | assert isinstance(config_entry.runtime_data, KnmiDataUpdateCoordinator) 26 | 27 | # Unload the entry 28 | assert await async_unload_entry(hass, config_entry) 29 | 30 | 31 | async def test_setup_entry_exception(hass: HomeAssistant, mock_weerlive_client: AsyncMock) -> None: 32 | """Test setup entry raises ConfigEntryNotReady on connection error.""" 33 | # Configure the mock to raise an error on latitude_longitude 34 | mock_weerlive_client.latitude_longitude.side_effect = WeerliveAPIError() 35 | 36 | # Create config entry but don't set it up through HA's system 37 | config_entry = get_mock_config_entry() 38 | config_entry.add_to_hass(hass) 39 | 40 | # This should raise ConfigEntryNotReady due to connection error 41 | with pytest.raises(ConfigEntryNotReady): 42 | await async_setup_entry(hass, config_entry) 43 | 44 | 45 | async def test_setup_entry_no_api_key(hass: HomeAssistant) -> None: 46 | """Test setup entry raises ValueError on connection error.""" 47 | # Create config entry but don't set it up through HA's system 48 | config_entry = MockConfigEntry( 49 | domain=DOMAIN, 50 | entry_id="test_entry", 51 | data={}, 52 | ) 53 | config_entry.add_to_hass(hass) 54 | 55 | # This should raise ValueError due to missing API key 56 | with pytest.raises(ValueError): # noqa: PT011 57 | await async_setup_entry(hass, config_entry) 58 | 59 | 60 | async def test_async_reload_entry(hass: HomeAssistant) -> None: 61 | """Test reloading the entry.""" 62 | config_entry = await setup_integration(hass) 63 | 64 | with patch("custom_components.knmi.async_reload_entry") as mock_reload_entry: 65 | assert len(mock_reload_entry.mock_calls) == 0 66 | hass.config_entries.async_update_entry(config_entry, options={"something": "else"}) 67 | assert len(mock_reload_entry.mock_calls) == 1 68 | 69 | 70 | async def test_async_migrate_entry_v1_to_v2(hass: HomeAssistant, entity_registry: er.EntityRegistry, caplog: LogCaptureFixture) -> None: 71 | """Test entry migration, v1 to v2.""" 72 | config_entry = MockConfigEntry( 73 | domain=DOMAIN, 74 | entry_id="test_entry", 75 | data={"api_key": "test_key"}, 76 | version=1, 77 | ) 78 | config_entry.add_to_hass(hass) 79 | 80 | mock_entity_id = "weather.knmi_home" 81 | entity_registry.async_get_or_create( 82 | domain="weather", 83 | platform="knmi", 84 | unique_id="home", 85 | config_entry=config_entry, 86 | ) 87 | 88 | assert await async_migrate_entry(hass, config_entry) 89 | assert len(entity_registry.entities) == 0 90 | assert f"Deleting version 1 entity: {mock_entity_id}" in caplog.text 91 | 92 | assert config_entry.version == 2 93 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for binary_sensor.""" 2 | 3 | import pytest 4 | from homeassistant.core import HomeAssistant 5 | 6 | from . import setup_integration, unload_integration 7 | 8 | 9 | @pytest.mark.usefixtures("mocked_data") 10 | async def test_knmi_binary_alarm_sensor_is_off(hass: HomeAssistant) -> None: 11 | """Test is_on function on alarm sensor.""" 12 | config_entry = await setup_integration(hass) 13 | 14 | state = hass.states.get("binary_sensor.home_warning") 15 | assert state 16 | assert state.state == "off" 17 | assert state.attributes.get("title") == "Vanavond (zeer) zware windstoten" 18 | description = [ 19 | "De eerstkomende uren zijn er geen waarschuwingen van kracht. " 20 | "Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. " 21 | "De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden." 22 | ] 23 | assert state.attributes.get("description") == "".join(description).strip() 24 | assert state.attributes.get("code") == "groen" 25 | assert state.attributes.get("next_code") == "geel" 26 | assert str(state.attributes.get("timestamp")) == "2024-02-22 18:00:00+01:00" 27 | 28 | await unload_integration(hass, config_entry) 29 | 30 | 31 | @pytest.mark.usefixtures("mocked_data") 32 | @pytest.mark.parametrize("mocked_data", ["response_alarm.json"], indirect=True) 33 | async def test_knmi_binary_alarm_sensor_is_on(hass: HomeAssistant) -> None: 34 | """Test is_on function on alarm sensor.""" 35 | config_entry = await setup_integration(hass) 36 | 37 | state = hass.states.get("binary_sensor.home_warning") 38 | assert state 39 | assert state.state == "on" 40 | assert state.attributes.get("title") == "Vanavond (zeer) zware windstoten" 41 | description = [ 42 | "De eerstkomende uren zijn er geen waarschuwingen van kracht. " 43 | "Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. " 44 | "De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden." 45 | ] 46 | assert state.attributes.get("description") == "".join(description).strip() 47 | assert state.attributes.get("code") == "groen" 48 | assert state.attributes.get("next_code") == "geel" 49 | assert str(state.attributes.get("timestamp")) == "2024-02-22 18:00:00+01:00" 50 | 51 | await unload_integration(hass, config_entry) 52 | 53 | 54 | @pytest.mark.freeze_time("2023-02-05T03:30:00+01:00") 55 | @pytest.mark.usefixtures("mocked_data") 56 | async def test_knmi_binary_sun_sensor_is_off(hass: HomeAssistant) -> None: 57 | """Test is_on function on sun sensor.""" 58 | config_entry = await setup_integration(hass) 59 | 60 | state = hass.states.get("binary_sensor.home_sun") 61 | assert state 62 | assert state.state == "off" 63 | assert str(state.attributes.get("sunrise")) == "2023-02-05 07:57:00+01:00" 64 | assert str(state.attributes.get("sunset")) == "2023-02-05 17:51:00+01:00" 65 | assert state.attributes.get("sun_chance0") == 0 66 | assert state.attributes.get("sun_chance1") == 8 67 | assert state.attributes.get("sun_chance2") == 14 68 | 69 | await unload_integration(hass, config_entry) 70 | 71 | 72 | @pytest.mark.freeze_time("2023-02-05T15:30:00+00:00") 73 | @pytest.mark.usefixtures("mocked_data") 74 | async def test_knmi_binary_sun_sensor_is_on(hass: HomeAssistant) -> None: 75 | """Test is_on function on sun sensor.""" 76 | config_entry = await setup_integration(hass) 77 | 78 | state = hass.states.get("binary_sensor.home_sun") 79 | assert state 80 | assert state.state == "on" 81 | 82 | await unload_integration(hass, config_entry) 83 | -------------------------------------------------------------------------------- /custom_components/knmi/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for knmi.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | 6 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import CONF_NAME 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from weerlive import Response 13 | 14 | from .const import DEFAULT_NAME 15 | from .coordinator import KnmiDataUpdateCoordinator 16 | from .entity import KnmiEntity, KnmiEntityDescription 17 | 18 | 19 | @dataclass(kw_only=True, frozen=True) 20 | class KnmiBinarySensorDescription(KnmiEntityDescription, BinarySensorEntityDescription): 21 | """Class describing KNMI binary sensor entities.""" 22 | 23 | value_fn: Callable[[Response], bool] 24 | 25 | 26 | DESCRIPTIONS: list[KnmiBinarySensorDescription] = [ 27 | KnmiBinarySensorDescription( 28 | key="alarm", 29 | device_class=BinarySensorDeviceClass.SAFETY, 30 | translation_key="alarm", 31 | value_fn=lambda data: data.live.alert == 1, 32 | state_attributes_fn=lambda data: { 33 | "title": data.live.alert_title, 34 | "description": data.live.alert_text, 35 | "code": data.live.weather_code, 36 | "next_code": data.live.next_alert_weather_code, 37 | "timestamp": data.live.next_alert_date, 38 | }, 39 | ), 40 | KnmiBinarySensorDescription( 41 | key="sun", 42 | translation_key="sun", 43 | value_fn=lambda data: data.live.is_sun_up, 44 | state_attributes_fn=lambda data: { 45 | "sunrise": data.live.sunrise, 46 | "sunset": data.live.sunset, 47 | "sun_chance0": data.daily_forecast[0].sunshine_probability, 48 | "sun_chance1": data.daily_forecast[1].sunshine_probability, 49 | "sun_chance2": data.daily_forecast[2].sunshine_probability, 50 | }, 51 | ), 52 | ] 53 | 54 | 55 | async def async_setup_entry( 56 | hass: HomeAssistant, 57 | config_entry: ConfigEntry[KnmiDataUpdateCoordinator], 58 | async_add_entities: AddEntitiesCallback, 59 | ) -> None: 60 | """Set up KNMI binary sensors based on a config entry.""" 61 | conf_name = config_entry.data.get(CONF_NAME, hass.config.location_name) 62 | coordinator = config_entry.runtime_data 63 | 64 | # Add all sensors described above. 65 | entities: list[KnmiBinarySensor] = [ 66 | KnmiBinarySensor( 67 | conf_name=conf_name, 68 | coordinator=coordinator, 69 | description=description, 70 | ) 71 | for description in DESCRIPTIONS 72 | ] 73 | 74 | async_add_entities(entities) 75 | 76 | 77 | class KnmiBinarySensor(KnmiEntity, BinarySensorEntity): 78 | """Defines a KNMI binary sensor.""" 79 | 80 | entity_description: KnmiBinarySensorDescription 81 | 82 | def __init__( 83 | self, 84 | conf_name: str, 85 | coordinator: KnmiDataUpdateCoordinator, 86 | description: KnmiBinarySensorDescription, 87 | ) -> None: 88 | """Initialize KNMI binary sensor.""" 89 | super().__init__(coordinator=coordinator) 90 | 91 | self._attr_attribution = coordinator.data.api.source 92 | self._attr_unique_id = f"{DEFAULT_NAME}_{conf_name}_{description.key}".lower() 93 | 94 | self.entity_description = description 95 | 96 | @property 97 | def is_on(self) -> bool: 98 | """Return true if the binary sensor is on.""" 99 | return self.entity_description.value_fn(self.coordinator.data) 100 | -------------------------------------------------------------------------------- /custom_components/knmi/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "KNMI weather", 6 | "description": "To get your free API key, visit: https://weerlive.nl/delen.php", 7 | "data": { 8 | "api_key": "API key", 9 | "latitude": "Latitude", 10 | "longitude": "Longitude", 11 | "name": "Name" 12 | } 13 | } 14 | }, 15 | "error": { 16 | "api_key": "The given API key is invalid. Note that it can take up to 5 minutes for new API keys to become active.", 17 | "daily_limit": "API key daily limit exceeded, try again tomorrow", 18 | "general": "Unknown error fetching weather data, try again later" 19 | } 20 | }, 21 | "options": { 22 | "step": { 23 | "init": { 24 | "data": { 25 | "scan_interval": "Scan interval (seconds)" 26 | } 27 | } 28 | } 29 | }, 30 | "entity": { 31 | "binary_sensor": { 32 | "sun": { 33 | "name": "Sun", 34 | "state": { 35 | "on": "Above horizon", 36 | "off": "Below horizon" 37 | }, 38 | "state_attributes": { 39 | "sunrise": { "name": "Sunrise" }, 40 | "sunset": { "name": "Sunset" }, 41 | "sun_chance0": { "name": "sun chance today" }, 42 | "sun_chance1": { "name": "sun chance tomorrow" }, 43 | "sun_chance2": { "name": "sun chance the day after tomorrow" } 44 | } 45 | }, 46 | "alarm": { 47 | "name": "Warning", 48 | "state_attributes": { 49 | "title": { "name": "Title" }, 50 | "description": { "name": "Description" }, 51 | "code": { 52 | "name": "Code", 53 | "state": { 54 | "groen": "Green", 55 | "geel": "Yellow", 56 | "oranje": "Orange", 57 | "rood": "Red" 58 | } 59 | }, 60 | "next_code": { 61 | "name": "Next code", 62 | "state": { 63 | "groen": "Green", 64 | "geel": "Yellow", 65 | "oranje": "Orange", 66 | "rood": "Red" 67 | } 68 | }, 69 | "timestamp": { "name": "Next warning" } 70 | } 71 | } 72 | }, 73 | "sensor": { 74 | "dauwp": { "name": "Dew point" }, 75 | "gr": { "name": "Solar irradiance" }, 76 | "gtemp": { "name": "Wind chill" }, 77 | "luchtd": { "name": "Air pressure" }, 78 | "lv": { "name": "Humidity" }, 79 | "max_temp_today": { "name": "Max temperature today" }, 80 | "max_temp_tomorrow": { "name": "Max temperature tomorrow" }, 81 | "min_temp_today": { "name": "Min temperature today" }, 82 | "min_temp_tomorrow": { "name": "Min temperature tomorrow" }, 83 | "neersl_perc_dag_today": { "name": "Precipitation today" }, 84 | "neersl_perc_dag_tomorrow": { "name": "Precipitation tomorrow" }, 85 | "plaats": { "name": "Location" }, 86 | "rest_verz": { "name": "Remaining API requests" }, 87 | "samenv": { "name": "Description" }, 88 | "temp": { "name": "Temperature" }, 89 | "timestamp": { "name": "Latest update" }, 90 | "verw": { "name": "Weather forecast" }, 91 | "windkmh": { 92 | "name": "Wind speed", 93 | "state_attributes": { 94 | "bearing": { "name": "Bearing" }, 95 | "degree": { "name": "Degree" }, 96 | "beaufort": { "name": "Beaufort" }, 97 | "knots": { "name": "Knopen" } 98 | } 99 | }, 100 | "wrschklr": { 101 | "name": "Weather code", 102 | "state": { 103 | "groen": "Green", 104 | "geel": "Yellow", 105 | "oranje": "Orange", 106 | "rood": "Red" 107 | } 108 | }, 109 | "zicht": { "name": "Visibility" } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /custom_components/knmi/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "KNMI weer", 6 | "description": "Aanmelden voor een gratis API key: https://weerlive.nl/delen.php", 7 | "data": { 8 | "api_key": "API-sleutel", 9 | "latitude": "Breedtegraad", 10 | "longitude": "Lengtegraad", 11 | "name": "Naam" 12 | } 13 | } 14 | }, 15 | "error": { 16 | "api_key": "De opgegeven API-sleutel is ongeldig. Let op dat het 5 minuten kan duren voordat een nieuwe API key geldig is.", 17 | "daily_limit": "De dagelijkse limiet van de API-sleutel is overschreden, probeer het morgen opnieuw", 18 | "general": "Onbekende fout bij het ophalen van weergegevens, probeer het later opnieuw" 19 | } 20 | }, 21 | "options": { 22 | "step": { 23 | "init": { 24 | "data": { 25 | "scan_interval": "Scaninterval (seconden)" 26 | } 27 | } 28 | } 29 | }, 30 | "entity": { 31 | "binary_sensor": { 32 | "sun": { 33 | "name": "Zon", 34 | "state": { 35 | "on": "Boven de horizon", 36 | "off": "Onder de horizon" 37 | }, 38 | "state_attributes": { 39 | "sunrise": { "name": "Zonsopkomst" }, 40 | "sunset": { "name": "Zonsondergang" }, 41 | "sun_chance0": { "name": "Zonkans vandaag" }, 42 | "sun_chance1": { "name": "Zonkans morgen" }, 43 | "sun_chance2": { "name": "Zonkans overmorgen" } 44 | } 45 | }, 46 | "alarm": { 47 | "name": "Waarschuwing", 48 | "state_attributes": { 49 | "title": { "name": "Titel" }, 50 | "description": { "name": "Beschrijving" }, 51 | "code": { 52 | "name": "Code", 53 | "state": { 54 | "groen": "Groen", 55 | "geel": "Geel", 56 | "oranje": "Oranje", 57 | "rood": "Rood" 58 | } 59 | }, 60 | "next_code": { 61 | "name": "Volgende code", 62 | "state": { 63 | "groen": "Groen", 64 | "geel": "Geel", 65 | "oranje": "Oranje", 66 | "rood": "Rood" 67 | } 68 | }, 69 | "timestamp": { "name": "Volgende waarschuwing" } 70 | } 71 | } 72 | }, 73 | "sensor": { 74 | "dauwp": { "name": "Dauwpunt" }, 75 | "gr": { "name": "Globale stralingsintensiteit" }, 76 | "gtemp": { "name": "Gevoelstemperatuur" }, 77 | "luchtd": { "name": "Luchtdruk" }, 78 | "lv": { "name": "Luchtvochtigheid" }, 79 | "max_temp_today": { "name": "Max temperatuur vandaag" }, 80 | "max_temp_tomorrow": { "name": "Max temperatuur morgen" }, 81 | "min_temp_today": { "name": "Min temperatuur vandaag" }, 82 | "min_temp_tomorrow": { "name": "Min temperatuur morgen" }, 83 | "neersl_perc_dag_today": { "name": "Neerslag vandaag" }, 84 | "neersl_perc_dag_tomorrow": { "name": "Neerslag morgen" }, 85 | "plaats": { "name": "Plaats" }, 86 | "rest_verz": { "name": "Resterende API verzoeken" }, 87 | "samenv": { "name": "Omschrijving" }, 88 | "temp": { "name": "Temperatuur" }, 89 | "timestamp": { "name": "Laatste update" }, 90 | "verw": { "name": "Weersverwachting" }, 91 | "windkmh": { 92 | "name": "Windsnelheid", 93 | "state_attributes": { 94 | "bearing": { "name": "Richting" }, 95 | "degree": { "name": "Graden" }, 96 | "beaufort": { "name": "Beaufort" }, 97 | "knots": { "name": "Knopen" } 98 | } 99 | }, 100 | "wrschklr": { 101 | "name": "Weercode", 102 | "state": { 103 | "groen": "Groen", 104 | "geel": "Geel", 105 | "oranje": "Oranje", 106 | "rood": "Rood" 107 | } 108 | }, 109 | "zicht": { "name": "Zicht" } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /custom_components/knmi/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for knmi.""" 2 | 3 | from typing import Any 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | import voluptuous as vol 7 | from homeassistant.config_entries import SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow 8 | from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_SCAN_INTERVAL 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | 12 | from weerlive import WeerliveApi, WeerliveAPIConnectionError, WeerliveAPIKeyError, WeerliveAPIRateLimitError 13 | 14 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN 15 | 16 | CONFIG_SCHEMA = vol.Schema( 17 | { 18 | vol.Required(CONF_NAME): str, 19 | vol.Required(CONF_LATITUDE): cv.latitude, 20 | vol.Required(CONF_LONGITUDE): cv.longitude, 21 | vol.Required(CONF_API_KEY): str, 22 | } 23 | ) 24 | 25 | 26 | class KnmiFlowHandler(ConfigFlow, domain=DOMAIN): 27 | """Config flow for knmi.""" 28 | 29 | VERSION = 2 30 | 31 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 32 | """Handle a flow initialized by the user.""" 33 | errors = {} 34 | user_input = user_input or {} 35 | 36 | if user_input: 37 | name = user_input[CONF_NAME] 38 | api_key = user_input[CONF_API_KEY] 39 | latitude = user_input[CONF_LATITUDE] 40 | longitude = user_input[CONF_LONGITUDE] 41 | 42 | try: 43 | await self._validate_user_input( 44 | api_key, 45 | latitude, 46 | longitude, 47 | ) 48 | except WeerliveAPIConnectionError: 49 | errors["base"] = "general" 50 | except WeerliveAPIKeyError: 51 | errors["base"] = "api_key" 52 | except WeerliveAPIRateLimitError: 53 | errors["base"] = "daily_limit" 54 | else: 55 | if self.source == SOURCE_RECONFIGURE: 56 | return self.async_update_reload_and_abort( 57 | self._get_reconfigure_entry(), 58 | title=name, 59 | data=user_input, 60 | ) 61 | return self.async_create_entry( 62 | title=name, 63 | data=user_input, 64 | ) 65 | 66 | default_data = { 67 | CONF_NAME: self.hass.config.location_name, 68 | CONF_LATITUDE: self.hass.config.latitude, 69 | CONF_LONGITUDE: self.hass.config.longitude, 70 | } 71 | return self.async_show_form( 72 | step_id="user", 73 | data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, default_data), 74 | errors=errors, 75 | ) 76 | 77 | async def _validate_user_input(self, api_key: str, latitude: float, longitude: float) -> None: 78 | """Validate user input.""" 79 | session = async_get_clientsession(self.hass) 80 | client = WeerliveApi(api_key, session) 81 | await client.latitude_longitude(latitude, longitude) 82 | 83 | async def async_step_reconfigure(self, _: dict[str, Any] | None = None) -> ConfigFlowResult: 84 | """Handle reconfiguration.""" 85 | data = self._get_reconfigure_entry().data.copy() 86 | 87 | return self.async_show_form( 88 | step_id="user", 89 | data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, data), 90 | ) 91 | 92 | @staticmethod 93 | @callback 94 | def async_get_options_flow(_config_entry: ConfigEntry) -> OptionsFlow: 95 | """Get the options flow for this handler.""" 96 | return KnmiOptionsFlowHandler() 97 | 98 | 99 | class KnmiOptionsFlowHandler(OptionsFlow): 100 | """Knmi config flow options handler.""" 101 | 102 | async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 103 | """Manage the options.""" 104 | if user_input is not None: 105 | return self.async_create_entry(title=self.config_entry.data.get(CONF_NAME), data=user_input) 106 | 107 | return self.async_show_form( 108 | step_id="init", 109 | data_schema=vol.Schema( 110 | { 111 | vol.Required( 112 | CONF_SCAN_INTERVAL, 113 | default=self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), 114 | ): vol.All(vol.Coerce(int), vol.Range(min=300, max=86400)) 115 | } 116 | ), 117 | ) 118 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test for config flow.""" 2 | 3 | from collections.abc import Generator 4 | from unittest.mock import AsyncMock, patch 5 | 6 | import pytest 7 | from homeassistant.config_entries import SOURCE_USER 8 | from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_SCAN_INTERVAL 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.data_entry_flow import FlowResultType 11 | 12 | from custom_components.knmi.const import DOMAIN 13 | from weerlive import WeerliveAPIConnectionError, WeerliveAPIKeyError, WeerliveAPIRateLimitError 14 | 15 | from . import get_mock_config_data, setup_integration, unload_integration 16 | 17 | MOCK_UPDATE_CONFIG = {CONF_SCAN_INTERVAL: 600} 18 | 19 | 20 | @pytest.fixture(autouse=True, name="bypass_setup") 21 | def fixture_bypass_setup_fixture() -> Generator[None]: 22 | """Prevent actual setup of the integration during tests.""" 23 | with patch("custom_components.knmi.async_setup_entry", return_value=True): 24 | yield 25 | 26 | 27 | async def test_successful_config_flow(hass: HomeAssistant) -> None: 28 | """Test a successful config flow.""" 29 | config_data = get_mock_config_data() 30 | # Initialize a config flow 31 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 32 | 33 | # Check that the config flow shows the user form as the first step 34 | assert result["type"] == FlowResultType.FORM 35 | assert result["step_id"] == "user" 36 | 37 | # If a user were to fill in all fields, it would result in this function call 38 | result2 = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_data) 39 | 40 | # Check that the config flow is complete and a new entry is created with 41 | # the input data 42 | assert result2["type"] == FlowResultType.CREATE_ENTRY 43 | assert result2["title"] == config_data[CONF_NAME] 44 | assert result2["data"] == config_data 45 | assert result2["result"] 46 | 47 | 48 | @pytest.mark.parametrize( 49 | ("side_effect", "error"), 50 | [ 51 | (WeerliveAPIConnectionError, "general"), 52 | (WeerliveAPIKeyError, "api_key"), 53 | (WeerliveAPIRateLimitError, "daily_limit"), 54 | ], 55 | ) 56 | async def test_unsuccessful_config_flow(side_effect: Exception, error: str, hass: HomeAssistant, mock_weerlive_client: AsyncMock) -> None: 57 | """Test an unsuccessful config flow .""" 58 | config_data = get_mock_config_data() 59 | mock_weerlive_client.latitude_longitude.side_effect = side_effect 60 | 61 | # Initialize a config flow 62 | result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) 63 | 64 | # Check that the config flow shows the user form as the first step 65 | assert result["type"] == FlowResultType.FORM 66 | assert result["step_id"] == "user" 67 | 68 | # If a user were to fill in an incomplete form, it would result in this function call 69 | result2 = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_data) 70 | 71 | # Check that the config flow returns the error 72 | assert result2["type"] == FlowResultType.FORM 73 | assert result2["errors"] == {"base": error} 74 | 75 | 76 | async def test_step_reconfigure(hass: HomeAssistant) -> None: 77 | """Test for reconfigure step.""" 78 | updated_data = { 79 | CONF_API_KEY: "1234567890", 80 | CONF_LATITUDE: 52.354, 81 | CONF_LONGITUDE: 4.763, 82 | CONF_NAME: "Home2", 83 | } 84 | config_entry = await setup_integration(hass) 85 | 86 | result = await config_entry.start_reconfigure_flow(hass) 87 | assert result["type"] == FlowResultType.FORM 88 | assert result["step_id"] == "user" 89 | 90 | result2 = await hass.config_entries.flow.async_configure( 91 | result["flow_id"], 92 | user_input=updated_data, 93 | ) 94 | assert result2["type"] == FlowResultType.ABORT 95 | assert result2["reason"] == "reconfigure_successful" 96 | 97 | assert config_entry.title == updated_data[CONF_NAME] 98 | assert config_entry.data == {**updated_data} 99 | 100 | 101 | async def test_options_flow(hass: HomeAssistant) -> None: 102 | """Test an options flow.""" 103 | # Create a new MockConfigEntry and add to HASS (we're bypassing config 104 | # flow entirely) 105 | config_entry = await setup_integration(hass) 106 | 107 | # Initialize an options flow 108 | result = await hass.config_entries.options.async_init(config_entry.entry_id) 109 | 110 | # Verify that the first options step is a user form 111 | assert result["type"] == FlowResultType.FORM 112 | assert result["step_id"] == "init" 113 | 114 | # Enter some fake data into the form 115 | result2 = await hass.config_entries.options.async_configure( 116 | result["flow_id"], 117 | user_input=MOCK_UPDATE_CONFIG, 118 | ) 119 | 120 | # Verify that the flow finishes 121 | assert result2["type"] == FlowResultType.CREATE_ENTRY 122 | assert result2["title"] == get_mock_config_data()[CONF_NAME] 123 | 124 | # Verify that the options were updated 125 | assert config_entry.options == MOCK_UPDATE_CONFIG 126 | 127 | await unload_integration(hass, config_entry) 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Home Assistant configuration 2 | config/* 3 | !config/configuration.yaml 4 | !config/README.md 5 | 6 | # Created by https://www.toptal.com/developers/gitignore/api/osx,python,node 7 | # Edit at https://www.toptal.com/developers/gitignore?templates=osx,python,node 8 | 9 | ### Node ### 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | .pnpm-debug.log* 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # Snowpack dependency directory (https://snowpack.dev/) 55 | web_modules/ 56 | 57 | # TypeScript cache 58 | *.tsbuildinfo 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Optional stylelint cache 67 | .stylelintcache 68 | 69 | # Microbundle cache 70 | .rpt2_cache/ 71 | .rts2_cache_cjs/ 72 | .rts2_cache_es/ 73 | .rts2_cache_umd/ 74 | 75 | # Optional REPL history 76 | .node_repl_history 77 | 78 | # Output of 'npm pack' 79 | *.tgz 80 | 81 | # Yarn Integrity file 82 | .yarn-integrity 83 | 84 | # dotenv environment variable files 85 | .env 86 | .env.development.local 87 | .env.test.local 88 | .env.production.local 89 | .env.local 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | .parcel-cache 94 | 95 | # Next.js build output 96 | .next 97 | out 98 | 99 | # Nuxt.js build / generate output 100 | .nuxt 101 | dist 102 | 103 | # Gatsby files 104 | .cache/ 105 | # Comment in the public line in if your project uses Gatsby and not Next.js 106 | # https://nextjs.org/blog/next-9-1#public-directory-support 107 | # public 108 | 109 | # vuepress build output 110 | .vuepress/dist 111 | 112 | # vuepress v2.x temp and cache directory 113 | .temp 114 | 115 | # Docusaurus cache and generated files 116 | .docusaurus 117 | 118 | # Serverless directories 119 | .serverless/ 120 | 121 | # FuseBox cache 122 | .fusebox/ 123 | 124 | # DynamoDB Local files 125 | .dynamodb/ 126 | 127 | # TernJS port file 128 | .tern-port 129 | 130 | # Stores VSCode versions used for testing VSCode extensions 131 | .vscode-test 132 | 133 | # yarn v2 134 | .yarn/cache 135 | .yarn/unplugged 136 | .yarn/build-state.yml 137 | .yarn/install-state.gz 138 | .pnp.* 139 | 140 | ### Node Patch ### 141 | # Serverless Webpack directories 142 | .webpack/ 143 | 144 | # Optional stylelint cache 145 | 146 | # SvelteKit build / generate output 147 | .svelte-kit 148 | 149 | ### OSX ### 150 | # General 151 | .DS_Store 152 | .AppleDouble 153 | .LSOverride 154 | 155 | # Icon must end with two \r 156 | Icon 157 | 158 | 159 | # Thumbnails 160 | ._* 161 | 162 | # Files that might appear in the root of a volume 163 | .DocumentRevisions-V100 164 | .fseventsd 165 | .Spotlight-V100 166 | .TemporaryItems 167 | .Trashes 168 | .VolumeIcon.icns 169 | .com.apple.timemachine.donotpresent 170 | 171 | # Directories potentially created on remote AFP share 172 | .AppleDB 173 | .AppleDesktop 174 | Network Trash Folder 175 | Temporary Items 176 | .apdisk 177 | 178 | ### Python ### 179 | # Byte-compiled / optimized / DLL files 180 | __pycache__/ 181 | *.py[cod] 182 | *$py.class 183 | 184 | # C extensions 185 | *.so 186 | 187 | # Distribution / packaging 188 | .Python 189 | build/ 190 | develop-eggs/ 191 | dist/ 192 | downloads/ 193 | eggs/ 194 | .eggs/ 195 | lib/ 196 | lib64/ 197 | parts/ 198 | sdist/ 199 | var/ 200 | wheels/ 201 | share/python-wheels/ 202 | *.egg-info/ 203 | .installed.cfg 204 | *.egg 205 | MANIFEST 206 | 207 | # PyInstaller 208 | # Usually these files are written by a python script from a template 209 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 210 | *.manifest 211 | *.spec 212 | 213 | # Installer logs 214 | pip-log.txt 215 | pip-delete-this-directory.txt 216 | 217 | # Unit test / coverage reports 218 | htmlcov/ 219 | .tox/ 220 | .nox/ 221 | .coverage 222 | .coverage.* 223 | nosetests.xml 224 | coverage.xml 225 | *.cover 226 | *.py,cover 227 | .hypothesis/ 228 | .pytest_cache/ 229 | cover/ 230 | 231 | # Translations 232 | *.mo 233 | *.pot 234 | 235 | # Django stuff: 236 | local_settings.py 237 | db.sqlite3 238 | db.sqlite3-journal 239 | 240 | # Flask stuff: 241 | instance/ 242 | .webassets-cache 243 | 244 | # Scrapy stuff: 245 | .scrapy 246 | 247 | # Sphinx documentation 248 | docs/_build/ 249 | 250 | # PyBuilder 251 | .pybuilder/ 252 | target/ 253 | 254 | # Jupyter Notebook 255 | .ipynb_checkpoints 256 | 257 | # IPython 258 | profile_default/ 259 | ipython_config.py 260 | 261 | # pyenv 262 | # For a library or package, you might want to ignore these files since the code is 263 | # intended to run in multiple environments; otherwise, check them in: 264 | # .python-version 265 | 266 | # pipenv 267 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 268 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 269 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 270 | # install all needed dependencies. 271 | #Pipfile.lock 272 | 273 | # poetry 274 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 275 | # This is especially recommended for binary packages to ensure reproducibility, and is more 276 | # commonly ignored for libraries. 277 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 278 | #poetry.lock 279 | 280 | # pdm 281 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 282 | #pdm.lock 283 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 284 | # in version control. 285 | # https://pdm.fming.dev/#use-with-ide 286 | .pdm.toml 287 | 288 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 289 | __pypackages__/ 290 | 291 | # Celery stuff 292 | celerybeat-schedule 293 | celerybeat.pid 294 | 295 | # SageMath parsed files 296 | *.sage.py 297 | 298 | # Environments 299 | .venv 300 | env/ 301 | venv/ 302 | ENV/ 303 | env.bak/ 304 | venv.bak/ 305 | 306 | # Spyder project settings 307 | .spyderproject 308 | .spyproject 309 | 310 | # Rope project settings 311 | .ropeproject 312 | 313 | # mkdocs documentation 314 | /site 315 | 316 | # mypy 317 | .mypy_cache/ 318 | .dmypy.json 319 | dmypy.json 320 | 321 | # Pyre type checker 322 | .pyre/ 323 | 324 | # pytype static type analyzer 325 | .pytype/ 326 | 327 | # Cython debug symbols 328 | cython_debug/ 329 | 330 | # PyCharm 331 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 332 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 333 | # and can be added to the global gitignore or merged into this file. For a more nuclear 334 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 335 | #.idea/ 336 | 337 | ### Python Patch ### 338 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 339 | poetry.toml 340 | 341 | # ruff 342 | .ruff_cache/ 343 | 344 | # LSP config files 345 | pyrightconfig.json 346 | 347 | # End of https://www.toptal.com/developers/gitignore/api/osx,python,node 348 | -------------------------------------------------------------------------------- /custom_components/knmi/weather.py: -------------------------------------------------------------------------------- 1 | """Weather platform for knmi.""" 2 | 3 | import logging 4 | from dataclasses import dataclass 5 | 6 | from homeassistant.components.weather import ( 7 | ATTR_CONDITION_CLEAR_NIGHT, 8 | ATTR_CONDITION_CLOUDY, 9 | ATTR_CONDITION_FOG, 10 | ATTR_CONDITION_HAIL, 11 | ATTR_CONDITION_LIGHTNING, 12 | ATTR_CONDITION_PARTLYCLOUDY, 13 | ATTR_CONDITION_POURING, 14 | ATTR_CONDITION_RAINY, 15 | ATTR_CONDITION_SNOWY, 16 | ATTR_CONDITION_SUNNY, 17 | Forecast, 18 | WeatherEntity, 19 | WeatherEntityDescription, 20 | WeatherEntityFeature, 21 | ) 22 | from homeassistant.components.weather import DOMAIN as SENSOR_DOMAIN 23 | from homeassistant.config_entries import ConfigEntry 24 | from homeassistant.const import CONF_NAME, UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature 25 | from homeassistant.core import HomeAssistant 26 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 27 | 28 | from .const import DEFAULT_NAME 29 | from .coordinator import KnmiDataUpdateCoordinator 30 | from .entity import KnmiEntity, KnmiEntityDescription 31 | 32 | _LOGGER: logging.Logger = logging.getLogger(__package__) 33 | 34 | SNOW_TO_RAIN_TEMP_CELSIUS = 6 35 | 36 | 37 | @dataclass(kw_only=True, frozen=True) 38 | class KnmiWeatherDescription(KnmiEntityDescription, WeatherEntityDescription): 39 | """Class describing KNMI weather entities.""" 40 | 41 | 42 | DESCRIPTIONS: list[KnmiWeatherDescription] = [ 43 | KnmiWeatherDescription( 44 | key="weer", 45 | ), 46 | ] 47 | 48 | # Map weather conditions from KNMI to HA. 49 | CONDITIONS_MAP = { 50 | "zonnig": ATTR_CONDITION_SUNNY, 51 | "bliksem": ATTR_CONDITION_LIGHTNING, 52 | "buien": ATTR_CONDITION_POURING, 53 | "regen": ATTR_CONDITION_RAINY, 54 | "hagel": ATTR_CONDITION_HAIL, 55 | "mist": ATTR_CONDITION_FOG, 56 | "sneeuw": ATTR_CONDITION_SNOWY, 57 | "bewolkt": ATTR_CONDITION_CLOUDY, 58 | "lichtbewolkt": ATTR_CONDITION_PARTLYCLOUDY, 59 | "halfbewolkt": ATTR_CONDITION_PARTLYCLOUDY, 60 | "halfbewolkt_regen": ATTR_CONDITION_RAINY, 61 | "zwaarbewolkt": ATTR_CONDITION_CLOUDY, 62 | "nachtmist": ATTR_CONDITION_FOG, 63 | "helderenacht": ATTR_CONDITION_CLEAR_NIGHT, 64 | "nachtbewolkt": ATTR_CONDITION_CLOUDY, 65 | # Check with the supplier why this is still in the response while not in the docs. 66 | "wolkennacht": ATTR_CONDITION_CLOUDY, 67 | # Possible unavailable conditions. 68 | "-": None, 69 | "_": None, 70 | } 71 | 72 | 73 | async def async_setup_entry( 74 | hass: HomeAssistant, 75 | config_entry: ConfigEntry[KnmiDataUpdateCoordinator], 76 | async_add_entities: AddEntitiesCallback, 77 | ) -> None: 78 | """Set up KNMI weather based on a config entry.""" 79 | conf_name = config_entry.data.get(CONF_NAME, hass.config.location_name) 80 | coordinator = config_entry.runtime_data 81 | 82 | # Add all sensors described above. 83 | entities: list[KnmiWeather] = [ 84 | KnmiWeather( 85 | conf_name=conf_name, 86 | coordinator=coordinator, 87 | description=description, 88 | ) 89 | for description in DESCRIPTIONS 90 | ] 91 | 92 | async_add_entities(entities) 93 | 94 | 95 | class KnmiWeather(KnmiEntity, WeatherEntity): 96 | """Defines a KNMI weather entity.""" 97 | 98 | entity_description: KnmiWeatherDescription 99 | 100 | _attr_native_pressure_unit = UnitOfPressure.HPA 101 | _attr_native_temperature_unit = UnitOfTemperature.CELSIUS 102 | _attr_native_visibility_unit = UnitOfLength.METERS 103 | _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR 104 | _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY 105 | 106 | def __init__( 107 | self, 108 | conf_name: str, 109 | coordinator: KnmiDataUpdateCoordinator, 110 | description: KnmiWeatherDescription, 111 | ) -> None: 112 | """Initialize KNMI weather entity.""" 113 | super().__init__(coordinator=coordinator) 114 | 115 | self._attr_attribution = self.coordinator.data.api.source 116 | self._attr_unique_id = f"{DEFAULT_NAME}_{conf_name}".lower() 117 | 118 | self.entity_id = f"{SENSOR_DOMAIN}.{DEFAULT_NAME}_{conf_name}".lower() 119 | self.entity_description = description 120 | 121 | def map_condition(self, value: str | None) -> str | None: 122 | """Map weather conditions from KNMI to HA.""" 123 | try: 124 | return CONDITIONS_MAP[value] # type: ignore # noqa: PGH003 125 | except KeyError: 126 | _LOGGER.exception('Weather condition "%s" can\'t be mapped, please raise a bug', value) 127 | return None 128 | 129 | @property 130 | def condition(self) -> str | None: 131 | """Return the current condition.""" 132 | condition = self.map_condition(self.coordinator.data.live.image) 133 | 134 | if condition == ATTR_CONDITION_SUNNY and not self.coordinator.data.live.is_sun_up: 135 | condition = ATTR_CONDITION_CLEAR_NIGHT 136 | 137 | if condition == ATTR_CONDITION_SNOWY and self.native_temperature and self.native_temperature > SNOW_TO_RAIN_TEMP_CELSIUS: 138 | condition = ATTR_CONDITION_RAINY 139 | 140 | return condition 141 | 142 | @property 143 | def native_temperature(self) -> float | None: 144 | """Return the temperature in native units.""" 145 | return self.coordinator.data.live.temperature 146 | 147 | @property 148 | def native_apparent_temperature(self) -> float | None: 149 | """Return the apparent temperature in native units.""" 150 | return self.coordinator.data.live.feels_like_temperature 151 | 152 | @property 153 | def native_dew_point(self) -> float | None: 154 | """Return the dew point temperature in native units.""" 155 | return self.coordinator.data.live.dew_point 156 | 157 | @property 158 | def native_pressure(self) -> float | None: 159 | """Return the pressure in native units.""" 160 | return self.coordinator.data.live.air_pressure 161 | 162 | @property 163 | def humidity(self) -> float | None: 164 | """Return the humidity in native units.""" 165 | return self.coordinator.data.live.humidity 166 | 167 | @property 168 | def native_wind_speed(self) -> float | None: 169 | """Return the wind speed in native units.""" 170 | return self.coordinator.data.live.wind_speed_kmh 171 | 172 | @property 173 | def wind_bearing(self) -> float | None: 174 | """Return the wind bearing.""" 175 | return self.coordinator.data.live.wind_direction_degree 176 | 177 | @property 178 | def native_visibility(self) -> float | None: 179 | """Return the visibility in native units.""" 180 | return self.coordinator.data.live.visibility 181 | 182 | async def async_forecast_daily(self) -> list[Forecast] | None: 183 | """Return the daily forecast in native units.""" 184 | forecasts: list[Forecast] = [] 185 | 186 | for daily_forecast in self.coordinator.data.daily_forecast: 187 | forecast = Forecast( 188 | condition=self.map_condition(daily_forecast.image), 189 | datetime=daily_forecast.day.isoformat(), 190 | precipitation_probability=daily_forecast.precipitation_probability, # Note: Percentage. 191 | native_temperature=daily_forecast.max_temperature, 192 | native_templow=daily_forecast.min_temperature, 193 | wind_bearing=daily_forecast.wind_direction_degree, 194 | native_wind_speed=daily_forecast.wind_speed_kmh, 195 | ) 196 | 197 | # Not officially supported, but nice additions. 198 | forecast["wind_speed_bft"] = daily_forecast.wind_speed_bft # type: ignore # noqa: PGH003 199 | forecast["sun_chance"] = daily_forecast.sunshine_probability # type: ignore # noqa: PGH003 200 | 201 | forecasts.append(forecast) 202 | 203 | return forecasts 204 | 205 | async def async_forecast_hourly(self) -> list[Forecast] | None: 206 | """Return the hourly forecast in native units.""" 207 | forecasts: list[Forecast] = [] 208 | 209 | for hourly_forecast in self.coordinator.data.hourly_forecast: 210 | forecast = Forecast( 211 | condition=self.map_condition(hourly_forecast.image), 212 | datetime=hourly_forecast.time.isoformat(), 213 | native_precipitation=hourly_forecast.precipitation, # Millimeter. 214 | native_temperature=hourly_forecast.temperature, 215 | wind_bearing=hourly_forecast.wind_direction_degree, 216 | native_wind_speed=hourly_forecast.wind_speed_kmh, 217 | ) 218 | 219 | # Not officially supported, but nice additions. 220 | forecast["wind_speed_bft"] = hourly_forecast.wind_speed_bft # type: ignore # noqa: PGH003 221 | forecast["solar_irradiance"] = hourly_forecast.solar_irradiance # type: ignore # noqa: PGH003 222 | 223 | forecasts.append(forecast) 224 | 225 | return forecasts 226 | 227 | async def async_forecast_twice_daily(self) -> list[Forecast] | None: 228 | """Return the daily forecast in native units.""" 229 | raise NotImplementedError 230 | -------------------------------------------------------------------------------- /custom_components/knmi/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for knmi.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from datetime import datetime 6 | 7 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import CONF_NAME, PERCENTAGE, UnitOfIrradiance, UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity import EntityCategory 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.typing import StateType 14 | 15 | from weerlive import Response 16 | 17 | from .const import DEFAULT_NAME 18 | from .coordinator import KnmiDataUpdateCoordinator 19 | from .entity import KnmiEntity, KnmiEntityDescription 20 | 21 | 22 | @dataclass(kw_only=True, frozen=True) 23 | class KnmiSensorDescription(KnmiEntityDescription, SensorEntityDescription): 24 | """Class describing KNMI sensor entities.""" 25 | 26 | value_fn: Callable[[Response], StateType | datetime | None] 27 | 28 | 29 | DESCRIPTIONS: list[KnmiSensorDescription] = [ 30 | KnmiSensorDescription( 31 | key="dauwp", 32 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 33 | device_class=SensorDeviceClass.TEMPERATURE, 34 | state_class=SensorStateClass.MEASUREMENT, 35 | translation_key="dauwp", 36 | value_fn=lambda data: data.live.dew_point, 37 | entity_registry_enabled_default=False, 38 | ), 39 | KnmiSensorDescription( 40 | key="gr", 41 | native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, 42 | device_class=SensorDeviceClass.IRRADIANCE, 43 | state_class=SensorStateClass.MEASUREMENT, 44 | translation_key="gr", 45 | value_fn=lambda data: data.live.solar_irradiance, 46 | entity_registry_enabled_default=False, 47 | ), 48 | KnmiSensorDescription( 49 | key="gtemp", 50 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 51 | device_class=SensorDeviceClass.TEMPERATURE, 52 | state_class=SensorStateClass.MEASUREMENT, 53 | translation_key="gtemp", 54 | value_fn=lambda data: data.live.feels_like_temperature, 55 | entity_registry_enabled_default=False, 56 | ), 57 | KnmiSensorDescription( 58 | key="luchtd", 59 | native_unit_of_measurement=UnitOfPressure.HPA, 60 | device_class=SensorDeviceClass.PRESSURE, 61 | state_class=SensorStateClass.MEASUREMENT, 62 | translation_key="luchtd", 63 | value_fn=lambda data: data.live.air_pressure, 64 | entity_registry_enabled_default=False, 65 | ), 66 | KnmiSensorDescription( 67 | key="lv", 68 | native_unit_of_measurement=PERCENTAGE, 69 | device_class=SensorDeviceClass.HUMIDITY, 70 | state_class=SensorStateClass.MEASUREMENT, 71 | translation_key="lv", 72 | value_fn=lambda data: data.live.humidity, 73 | entity_registry_enabled_default=False, 74 | ), 75 | KnmiSensorDescription( 76 | key="max_temp_today", 77 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 78 | device_class=SensorDeviceClass.TEMPERATURE, 79 | state_class=SensorStateClass.MEASUREMENT, 80 | translation_key="max_temp_today", 81 | value_fn=lambda data: data.daily_forecast[0].max_temperature, 82 | entity_registry_enabled_default=False, 83 | ), 84 | KnmiSensorDescription( 85 | key="max_temp_tomorrow", 86 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 87 | device_class=SensorDeviceClass.TEMPERATURE, 88 | state_class=SensorStateClass.MEASUREMENT, 89 | translation_key="max_temp_tomorrow", 90 | value_fn=lambda data: data.daily_forecast[1].max_temperature, 91 | entity_registry_enabled_default=False, 92 | ), 93 | KnmiSensorDescription( 94 | key="min_temp_today", 95 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 96 | device_class=SensorDeviceClass.TEMPERATURE, 97 | state_class=SensorStateClass.MEASUREMENT, 98 | translation_key="min_temp_today", 99 | value_fn=lambda data: data.daily_forecast[0].min_temperature, 100 | entity_registry_enabled_default=False, 101 | ), 102 | KnmiSensorDescription( 103 | key="min_temp_tomorrow", 104 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 105 | device_class=SensorDeviceClass.TEMPERATURE, 106 | state_class=SensorStateClass.MEASUREMENT, 107 | translation_key="min_temp_tomorrow", 108 | value_fn=lambda data: data.daily_forecast[1].min_temperature, 109 | entity_registry_enabled_default=False, 110 | ), 111 | KnmiSensorDescription( 112 | key="neersl_perc_dag_today", 113 | native_unit_of_measurement=PERCENTAGE, 114 | state_class=SensorStateClass.MEASUREMENT, 115 | translation_key="neersl_perc_dag_today", 116 | value_fn=lambda data: data.daily_forecast[0].precipitation_probability, 117 | entity_registry_enabled_default=False, 118 | ), 119 | KnmiSensorDescription( 120 | key="neersl_perc_dag_tomorrow", 121 | native_unit_of_measurement=PERCENTAGE, 122 | state_class=SensorStateClass.MEASUREMENT, 123 | translation_key="neersl_perc_dag_tomorrow", 124 | value_fn=lambda data: data.daily_forecast[1].precipitation_probability, 125 | entity_registry_enabled_default=False, 126 | ), 127 | KnmiSensorDescription( 128 | key="plaats", 129 | entity_category=EntityCategory.DIAGNOSTIC, 130 | translation_key="plaats", 131 | value_fn=lambda data: data.live.city, 132 | entity_registry_enabled_default=False, 133 | ), 134 | KnmiSensorDescription( 135 | key="rest_verz", 136 | state_class=SensorStateClass.MEASUREMENT, 137 | entity_category=EntityCategory.DIAGNOSTIC, 138 | translation_key="rest_verz", 139 | value_fn=lambda data: data.api.remaining_requests, 140 | entity_registry_enabled_default=False, 141 | ), 142 | KnmiSensorDescription( 143 | key="samenv", 144 | translation_key="samenv", 145 | value_fn=lambda data: data.live.summary, 146 | entity_registry_enabled_default=False, 147 | ), 148 | KnmiSensorDescription( 149 | key="temp", 150 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 151 | device_class=SensorDeviceClass.TEMPERATURE, 152 | state_class=SensorStateClass.MEASUREMENT, 153 | translation_key="temp", 154 | value_fn=lambda data: data.live.temperature, 155 | entity_registry_enabled_default=False, 156 | ), 157 | KnmiSensorDescription( 158 | key="timestamp", 159 | device_class=SensorDeviceClass.TIMESTAMP, 160 | entity_category=EntityCategory.DIAGNOSTIC, 161 | translation_key="timestamp", 162 | value_fn=lambda data: data.live.time, 163 | entity_registry_enabled_default=False, 164 | ), 165 | KnmiSensorDescription( 166 | key="verw", 167 | translation_key="verw", 168 | value_fn=lambda data: data.live.forecast, 169 | ), 170 | KnmiSensorDescription( 171 | key="windkmh", 172 | native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, 173 | device_class=SensorDeviceClass.SPEED, 174 | state_class=SensorStateClass.MEASUREMENT, 175 | translation_key="windkmh", 176 | value_fn=lambda data: data.live.wind_speed_kmh, 177 | state_attributes_fn=lambda data: { 178 | "bearing": data.live.wind_direction, 179 | "degree": data.live.wind_direction_degree, 180 | "beaufort": data.live.wind_speed_bft, 181 | "knots": data.live.wind_speed_knots, 182 | }, 183 | entity_registry_enabled_default=False, 184 | ), 185 | KnmiSensorDescription( 186 | key="wrschklr", 187 | translation_key="wrschklr", 188 | value_fn=lambda data: data.live.weather_code, 189 | entity_registry_enabled_default=False, 190 | ), 191 | KnmiSensorDescription( 192 | key="zicht", 193 | native_unit_of_measurement=UnitOfLength.METERS, 194 | device_class=SensorDeviceClass.DISTANCE, 195 | state_class=SensorStateClass.MEASUREMENT, 196 | translation_key="zicht", 197 | value_fn=lambda data: data.live.visibility, 198 | entity_registry_enabled_default=False, 199 | ), 200 | ] 201 | 202 | 203 | async def async_setup_entry( 204 | hass: HomeAssistant, 205 | config_entry: ConfigEntry[KnmiDataUpdateCoordinator], 206 | async_add_entities: AddEntitiesCallback, 207 | ) -> None: 208 | """Set up KNMI sensors based on a config entry.""" 209 | conf_name = config_entry.data.get(CONF_NAME, hass.config.location_name) 210 | coordinator = config_entry.runtime_data 211 | 212 | # Add all sensors described above. 213 | entities: list[KnmiSensor] = [ 214 | KnmiSensor( 215 | conf_name=conf_name, 216 | coordinator=coordinator, 217 | description=description, 218 | ) 219 | for description in DESCRIPTIONS 220 | ] 221 | 222 | async_add_entities(entities) 223 | 224 | 225 | class KnmiSensor(KnmiEntity, SensorEntity): 226 | """Defines a KNMI sensor.""" 227 | 228 | entity_description: KnmiSensorDescription 229 | 230 | def __init__( 231 | self, 232 | conf_name: str, 233 | coordinator: KnmiDataUpdateCoordinator, 234 | description: KnmiSensorDescription, 235 | ) -> None: 236 | """Initialize KNMI sensor.""" 237 | super().__init__(coordinator=coordinator) 238 | 239 | self._attr_attribution = self.coordinator.data.api.source 240 | self._attr_unique_id = f"{DEFAULT_NAME}_{conf_name}_{description.key}".lower() 241 | 242 | self.entity_description = description 243 | 244 | @property 245 | def native_value(self) -> StateType | datetime | None: 246 | """Return the state.""" 247 | return self.entity_description.value_fn(self.coordinator.data) 248 | -------------------------------------------------------------------------------- /tests/fixtures/cold_snow.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveweer": [ 3 | { 4 | "plaats": "Purmerend", 5 | "timestamp": 1707944883, 6 | "time": "14-02-2024 22:08:03", 7 | "temp": 6, 8 | "gtemp": 6.8, 9 | "samenv": "Licht bewolkt", 10 | "lv": 97, 11 | "windr": "WZW", 12 | "windrgr": 226, 13 | "windms": 8.08, 14 | "windbft": 5, 15 | "windknp": 15.7, 16 | "windkmh": 29.1, 17 | "luchtd": 1015.03, 18 | "ldmmhg": 761, 19 | "dauwp": 10.1, 20 | "zicht": 6990, 21 | "gr": 0, 22 | "verw": "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht", 23 | "sup": "07:57", 24 | "sunder": "17:51", 25 | "image": "sneeuw", 26 | "alarm": 0, 27 | "lkop": "Vanavond (zeer) zware windstoten", 28 | "ltekst": "De eerstkomende uren zijn er geen waarschuwingen van kracht. Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden.", 29 | "wrschklr": "groen", 30 | "wrsch_g": "22-02-2024 18:00", 31 | "wrsch_gts": 1708621200, 32 | "wrsch_gc": "geel" 33 | } 34 | ], 35 | "wk_verw": [ 36 | { 37 | "dag": "14-02-2024", 38 | "image": "bewolkt", 39 | "max_temp": 10, 40 | "min_temp": 10, 41 | "windbft": 4, 42 | "windkmh": 25, 43 | "windknp": 14, 44 | "windms": 7, 45 | "windrgr": 221, 46 | "windr": "ZW", 47 | "neersl_perc_dag": 0, 48 | "zond_perc_dag": 0 49 | }, 50 | { 51 | "dag": "15-02-2024", 52 | "image": "bewolkt", 53 | "max_temp": 12, 54 | "min_temp": 10, 55 | "windbft": 3, 56 | "windkmh": 18, 57 | "windknp": 10, 58 | "windms": 5, 59 | "windrgr": 184, 60 | "windr": "Z", 61 | "neersl_perc_dag": 10, 62 | "zond_perc_dag": 8 63 | }, 64 | { 65 | "dag": "16-02-2024", 66 | "image": "buien", 67 | "max_temp": 10, 68 | "min_temp": 9, 69 | "windbft": 3, 70 | "windkmh": 18, 71 | "windknp": 10, 72 | "windms": 5, 73 | "windrgr": 199, 74 | "windr": "Z", 75 | "neersl_perc_dag": 40, 76 | "zond_perc_dag": 14 77 | }, 78 | { 79 | "dag": "17-02-2024", 80 | "image": "halfbewolkt", 81 | "max_temp": 8, 82 | "min_temp": 6, 83 | "windbft": 3, 84 | "windkmh": 18, 85 | "windknp": 10, 86 | "windms": 5, 87 | "windrgr": 228, 88 | "windr": "ZW", 89 | "neersl_perc_dag": 20, 90 | "zond_perc_dag": 46 91 | }, 92 | { 93 | "dag": "18-02-2024", 94 | "image": "bewolkt", 95 | "max_temp": 8, 96 | "min_temp": 7, 97 | "windbft": 3, 98 | "windkmh": 18, 99 | "windknp": 10, 100 | "windms": 5, 101 | "windrgr": 210, 102 | "windr": "Z", 103 | "neersl_perc_dag": 10, 104 | "zond_perc_dag": 0 105 | } 106 | ], 107 | "uur_verw": [ 108 | { 109 | "uur": "14-02-2024 23:00", 110 | "timestamp": 1707948000, 111 | "image": "nachtbewolkt", 112 | "temp": 10, 113 | "windbft": 4, 114 | "windkmh": 21, 115 | "windknp": 12, 116 | "windms": 6, 117 | "windrgr": 231, 118 | "windr": "ZW", 119 | "neersl": 0, 120 | "gr": 0 121 | }, 122 | { 123 | "uur": "15-02-2024 00:00", 124 | "timestamp": 1707951600, 125 | "image": "nachtbewolkt", 126 | "temp": 10, 127 | "windbft": 3, 128 | "windkmh": 18, 129 | "windknp": 10, 130 | "windms": 5, 131 | "windrgr": 233, 132 | "windr": "ZW", 133 | "neersl": 0, 134 | "gr": 0 135 | }, 136 | { 137 | "uur": "15-02-2024 01:00", 138 | "timestamp": 1707955200, 139 | "image": "regen", 140 | "temp": 10, 141 | "windbft": 3, 142 | "windkmh": 18, 143 | "windknp": 10, 144 | "windms": 5, 145 | "windrgr": 232, 146 | "windr": "ZW", 147 | "neersl": 0.1, 148 | "gr": 0 149 | }, 150 | { 151 | "uur": "15-02-2024 02:00", 152 | "timestamp": 1707958800, 153 | "image": "bewolkt", 154 | "temp": 10, 155 | "windbft": 3, 156 | "windkmh": 18, 157 | "windknp": 10, 158 | "windms": 5, 159 | "windrgr": 226, 160 | "windr": "ZW", 161 | "neersl": 0, 162 | "gr": 0 163 | }, 164 | { 165 | "uur": "15-02-2024 03:00", 166 | "timestamp": 1707962400, 167 | "image": "bewolkt", 168 | "temp": 10, 169 | "windbft": 3, 170 | "windkmh": 18, 171 | "windknp": 10, 172 | "windms": 5, 173 | "windrgr": 224, 174 | "windr": "ZW", 175 | "neersl": 0, 176 | "gr": 0 177 | }, 178 | { 179 | "uur": "15-02-2024 04:00", 180 | "timestamp": 1707966000, 181 | "image": "bewolkt", 182 | "temp": 10, 183 | "windbft": 3, 184 | "windkmh": 18, 185 | "windknp": 10, 186 | "windms": 5, 187 | "windrgr": 219, 188 | "windr": "ZW", 189 | "neersl": 0, 190 | "gr": 0 191 | }, 192 | { 193 | "uur": "15-02-2024 05:00", 194 | "timestamp": 1707969600, 195 | "image": "bewolkt", 196 | "temp": 10, 197 | "windbft": 3, 198 | "windkmh": 14, 199 | "windknp": 8, 200 | "windms": 4, 201 | "windrgr": 203, 202 | "windr": "Z", 203 | "neersl": 0, 204 | "gr": 0 205 | }, 206 | { 207 | "uur": "15-02-2024 06:00", 208 | "timestamp": 1707973200, 209 | "image": "regen", 210 | "temp": 10, 211 | "windbft": 3, 212 | "windkmh": 14, 213 | "windknp": 8, 214 | "windms": 4, 215 | "windrgr": 190, 216 | "windr": "Z", 217 | "neersl": 0.2, 218 | "gr": 0 219 | }, 220 | { 221 | "uur": "15-02-2024 07:00", 222 | "timestamp": 1707976800, 223 | "image": "regen", 224 | "temp": 10, 225 | "windbft": 3, 226 | "windkmh": 14, 227 | "windknp": 8, 228 | "windms": 4, 229 | "windrgr": 196, 230 | "windr": "Z", 231 | "neersl": 0.9, 232 | "gr": 0 233 | }, 234 | { 235 | "uur": "15-02-2024 08:00", 236 | "timestamp": 1707980400, 237 | "image": "regen", 238 | "temp": 11, 239 | "windbft": 3, 240 | "windkmh": 18, 241 | "windknp": 10, 242 | "windms": 5, 243 | "windrgr": 210, 244 | "windr": "Z", 245 | "neersl": 1, 246 | "gr": 0 247 | }, 248 | { 249 | "uur": "15-02-2024 09:00", 250 | "timestamp": 1707984000, 251 | "image": "regen", 252 | "temp": 11, 253 | "windbft": 3, 254 | "windkmh": 14, 255 | "windknp": 8, 256 | "windms": 4, 257 | "windrgr": 218, 258 | "windr": "ZW", 259 | "neersl": 0.1, 260 | "gr": 3 261 | }, 262 | { 263 | "uur": "15-02-2024 10:00", 264 | "timestamp": 1707987600, 265 | "image": "bewolkt", 266 | "temp": 11, 267 | "windbft": 3, 268 | "windkmh": 14, 269 | "windknp": 8, 270 | "windms": 4, 271 | "windrgr": 217, 272 | "windr": "ZW", 273 | "neersl": 0, 274 | "gr": 6 275 | }, 276 | { 277 | "uur": "15-02-2024 11:00", 278 | "timestamp": 1707991200, 279 | "image": "regen", 280 | "temp": 11, 281 | "windbft": 3, 282 | "windkmh": 14, 283 | "windknp": 8, 284 | "windms": 4, 285 | "windrgr": 214, 286 | "windr": "ZW", 287 | "neersl": 0.1, 288 | "gr": 11 289 | }, 290 | { 291 | "uur": "15-02-2024 12:00", 292 | "timestamp": 1707994800, 293 | "image": "bewolkt", 294 | "temp": 12, 295 | "windbft": 3, 296 | "windkmh": 14, 297 | "windknp": 8, 298 | "windms": 4, 299 | "windrgr": 206, 300 | "windr": "Z", 301 | "neersl": 0, 302 | "gr": 30 303 | }, 304 | { 305 | "uur": "15-02-2024 13:00", 306 | "timestamp": 1707998400, 307 | "image": "bewolkt", 308 | "temp": 13, 309 | "windbft": 3, 310 | "windkmh": 14, 311 | "windknp": 8, 312 | "windms": 4, 313 | "windrgr": 211, 314 | "windr": "Z", 315 | "neersl": 0, 316 | "gr": 172 317 | }, 318 | { 319 | "uur": "15-02-2024 14:00", 320 | "timestamp": 1708002000, 321 | "image": "bewolkt", 322 | "temp": 14, 323 | "windbft": 3, 324 | "windkmh": 14, 325 | "windknp": 8, 326 | "windms": 4, 327 | "windrgr": 200, 328 | "windr": "Z", 329 | "neersl": 0, 330 | "gr": 161 331 | }, 332 | { 333 | "uur": "15-02-2024 15:00", 334 | "timestamp": 1708005600, 335 | "image": "bewolkt", 336 | "temp": 14, 337 | "windbft": 2, 338 | "windkmh": 10, 339 | "windknp": 6, 340 | "windms": 3, 341 | "windrgr": 179, 342 | "windr": "Z", 343 | "neersl": 0, 344 | "gr": 58 345 | }, 346 | { 347 | "uur": "15-02-2024 16:00", 348 | "timestamp": 1708009200, 349 | "image": "bewolkt", 350 | "temp": 14, 351 | "windbft": 2, 352 | "windkmh": 10, 353 | "windknp": 6, 354 | "windms": 3, 355 | "windrgr": 167, 356 | "windr": "ZO", 357 | "neersl": 0, 358 | "gr": 114 359 | }, 360 | { 361 | "uur": "15-02-2024 17:00", 362 | "timestamp": 1708012800, 363 | "image": "bewolkt", 364 | "temp": 14, 365 | "windbft": 3, 366 | "windkmh": 14, 367 | "windknp": 8, 368 | "windms": 4, 369 | "windrgr": 168, 370 | "windr": "Z", 371 | "neersl": 0, 372 | "gr": 72 373 | }, 374 | { 375 | "uur": "15-02-2024 18:00", 376 | "timestamp": 1708016400, 377 | "image": "bewolkt", 378 | "temp": 13, 379 | "windbft": 3, 380 | "windkmh": 14, 381 | "windknp": 8, 382 | "windms": 4, 383 | "windrgr": 156, 384 | "windr": "ZO", 385 | "neersl": 0, 386 | "gr": 14 387 | }, 388 | { 389 | "uur": "15-02-2024 19:00", 390 | "timestamp": 1708020000, 391 | "image": "bewolkt", 392 | "temp": 12, 393 | "windbft": 3, 394 | "windkmh": 14, 395 | "windknp": 8, 396 | "windms": 4, 397 | "windrgr": 155, 398 | "windr": "ZO", 399 | "neersl": 0, 400 | "gr": 0 401 | }, 402 | { 403 | "uur": "15-02-2024 20:00", 404 | "timestamp": 1708023600, 405 | "image": "bewolkt", 406 | "temp": 12, 407 | "windbft": 3, 408 | "windkmh": 18, 409 | "windknp": 10, 410 | "windms": 5, 411 | "windrgr": 152, 412 | "windr": "ZO", 413 | "neersl": 0, 414 | "gr": 0 415 | }, 416 | { 417 | "uur": "15-02-2024 21:00", 418 | "timestamp": 1708027200, 419 | "image": "bewolkt", 420 | "temp": 12, 421 | "windbft": 3, 422 | "windkmh": 18, 423 | "windknp": 10, 424 | "windms": 5, 425 | "windrgr": 155, 426 | "windr": "ZO", 427 | "neersl": 0, 428 | "gr": 0 429 | }, 430 | { 431 | "uur": "15-02-2024 22:00", 432 | "timestamp": 1708030800, 433 | "image": "bewolkt", 434 | "temp": 12, 435 | "windbft": 3, 436 | "windkmh": 18, 437 | "windknp": 10, 438 | "windms": 5, 439 | "windrgr": 162, 440 | "windr": "ZO", 441 | "neersl": 0, 442 | "gr": 0 443 | } 444 | ], 445 | "api": [ 446 | { 447 | "bron": "Bron: Weerdata KNMI/NOAA via Weerlive.nl", 448 | "max_verz": 300, 449 | "rest_verz": 132 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /tests/fixtures/warm_snow.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveweer": [ 3 | { 4 | "plaats": "Purmerend", 5 | "timestamp": 1707944883, 6 | "time": "14-02-2024 22:08:03", 7 | "temp": 10.5, 8 | "gtemp": 6.8, 9 | "samenv": "Licht bewolkt", 10 | "lv": 97, 11 | "windr": "WZW", 12 | "windrgr": 226, 13 | "windms": 8.08, 14 | "windbft": 5, 15 | "windknp": 15.7, 16 | "windkmh": 29.1, 17 | "luchtd": 1015.03, 18 | "ldmmhg": 761, 19 | "dauwp": 10.1, 20 | "zicht": 6990, 21 | "gr": 0, 22 | "verw": "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht", 23 | "sup": "07:57", 24 | "sunder": "17:51", 25 | "image": "sneeuw", 26 | "alarm": 0, 27 | "lkop": "Vanavond (zeer) zware windstoten", 28 | "ltekst": "De eerstkomende uren zijn er geen waarschuwingen van kracht. Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden.", 29 | "wrschklr": "groen", 30 | "wrsch_g": "22-02-2024 18:00", 31 | "wrsch_gts": 1708621200, 32 | "wrsch_gc": "geel" 33 | } 34 | ], 35 | "wk_verw": [ 36 | { 37 | "dag": "14-02-2024", 38 | "image": "bewolkt", 39 | "max_temp": 10, 40 | "min_temp": 10, 41 | "windbft": 4, 42 | "windkmh": 25, 43 | "windknp": 14, 44 | "windms": 7, 45 | "windrgr": 221, 46 | "windr": "ZW", 47 | "neersl_perc_dag": 0, 48 | "zond_perc_dag": 0 49 | }, 50 | { 51 | "dag": "15-02-2024", 52 | "image": "bewolkt", 53 | "max_temp": 12, 54 | "min_temp": 10, 55 | "windbft": 3, 56 | "windkmh": 18, 57 | "windknp": 10, 58 | "windms": 5, 59 | "windrgr": 184, 60 | "windr": "Z", 61 | "neersl_perc_dag": 10, 62 | "zond_perc_dag": 8 63 | }, 64 | { 65 | "dag": "16-02-2024", 66 | "image": "buien", 67 | "max_temp": 10, 68 | "min_temp": 9, 69 | "windbft": 3, 70 | "windkmh": 18, 71 | "windknp": 10, 72 | "windms": 5, 73 | "windrgr": 199, 74 | "windr": "Z", 75 | "neersl_perc_dag": 40, 76 | "zond_perc_dag": 14 77 | }, 78 | { 79 | "dag": "17-02-2024", 80 | "image": "halfbewolkt", 81 | "max_temp": 8, 82 | "min_temp": 6, 83 | "windbft": 3, 84 | "windkmh": 18, 85 | "windknp": 10, 86 | "windms": 5, 87 | "windrgr": 228, 88 | "windr": "ZW", 89 | "neersl_perc_dag": 20, 90 | "zond_perc_dag": 46 91 | }, 92 | { 93 | "dag": "18-02-2024", 94 | "image": "bewolkt", 95 | "max_temp": 8, 96 | "min_temp": 7, 97 | "windbft": 3, 98 | "windkmh": 18, 99 | "windknp": 10, 100 | "windms": 5, 101 | "windrgr": 210, 102 | "windr": "Z", 103 | "neersl_perc_dag": 10, 104 | "zond_perc_dag": 0 105 | } 106 | ], 107 | "uur_verw": [ 108 | { 109 | "uur": "14-02-2024 23:00", 110 | "timestamp": 1707948000, 111 | "image": "nachtbewolkt", 112 | "temp": 10, 113 | "windbft": 4, 114 | "windkmh": 21, 115 | "windknp": 12, 116 | "windms": 6, 117 | "windrgr": 231, 118 | "windr": "ZW", 119 | "neersl": 0, 120 | "gr": 0 121 | }, 122 | { 123 | "uur": "15-02-2024 00:00", 124 | "timestamp": 1707951600, 125 | "image": "nachtbewolkt", 126 | "temp": 10, 127 | "windbft": 3, 128 | "windkmh": 18, 129 | "windknp": 10, 130 | "windms": 5, 131 | "windrgr": 233, 132 | "windr": "ZW", 133 | "neersl": 0, 134 | "gr": 0 135 | }, 136 | { 137 | "uur": "15-02-2024 01:00", 138 | "timestamp": 1707955200, 139 | "image": "regen", 140 | "temp": 10, 141 | "windbft": 3, 142 | "windkmh": 18, 143 | "windknp": 10, 144 | "windms": 5, 145 | "windrgr": 232, 146 | "windr": "ZW", 147 | "neersl": 0.1, 148 | "gr": 0 149 | }, 150 | { 151 | "uur": "15-02-2024 02:00", 152 | "timestamp": 1707958800, 153 | "image": "bewolkt", 154 | "temp": 10, 155 | "windbft": 3, 156 | "windkmh": 18, 157 | "windknp": 10, 158 | "windms": 5, 159 | "windrgr": 226, 160 | "windr": "ZW", 161 | "neersl": 0, 162 | "gr": 0 163 | }, 164 | { 165 | "uur": "15-02-2024 03:00", 166 | "timestamp": 1707962400, 167 | "image": "bewolkt", 168 | "temp": 10, 169 | "windbft": 3, 170 | "windkmh": 18, 171 | "windknp": 10, 172 | "windms": 5, 173 | "windrgr": 224, 174 | "windr": "ZW", 175 | "neersl": 0, 176 | "gr": 0 177 | }, 178 | { 179 | "uur": "15-02-2024 04:00", 180 | "timestamp": 1707966000, 181 | "image": "bewolkt", 182 | "temp": 10, 183 | "windbft": 3, 184 | "windkmh": 18, 185 | "windknp": 10, 186 | "windms": 5, 187 | "windrgr": 219, 188 | "windr": "ZW", 189 | "neersl": 0, 190 | "gr": 0 191 | }, 192 | { 193 | "uur": "15-02-2024 05:00", 194 | "timestamp": 1707969600, 195 | "image": "bewolkt", 196 | "temp": 10, 197 | "windbft": 3, 198 | "windkmh": 14, 199 | "windknp": 8, 200 | "windms": 4, 201 | "windrgr": 203, 202 | "windr": "Z", 203 | "neersl": 0, 204 | "gr": 0 205 | }, 206 | { 207 | "uur": "15-02-2024 06:00", 208 | "timestamp": 1707973200, 209 | "image": "regen", 210 | "temp": 10, 211 | "windbft": 3, 212 | "windkmh": 14, 213 | "windknp": 8, 214 | "windms": 4, 215 | "windrgr": 190, 216 | "windr": "Z", 217 | "neersl": 0.2, 218 | "gr": 0 219 | }, 220 | { 221 | "uur": "15-02-2024 07:00", 222 | "timestamp": 1707976800, 223 | "image": "regen", 224 | "temp": 10, 225 | "windbft": 3, 226 | "windkmh": 14, 227 | "windknp": 8, 228 | "windms": 4, 229 | "windrgr": 196, 230 | "windr": "Z", 231 | "neersl": 0.9, 232 | "gr": 0 233 | }, 234 | { 235 | "uur": "15-02-2024 08:00", 236 | "timestamp": 1707980400, 237 | "image": "regen", 238 | "temp": 11, 239 | "windbft": 3, 240 | "windkmh": 18, 241 | "windknp": 10, 242 | "windms": 5, 243 | "windrgr": 210, 244 | "windr": "Z", 245 | "neersl": 1, 246 | "gr": 0 247 | }, 248 | { 249 | "uur": "15-02-2024 09:00", 250 | "timestamp": 1707984000, 251 | "image": "regen", 252 | "temp": 11, 253 | "windbft": 3, 254 | "windkmh": 14, 255 | "windknp": 8, 256 | "windms": 4, 257 | "windrgr": 218, 258 | "windr": "ZW", 259 | "neersl": 0.1, 260 | "gr": 3 261 | }, 262 | { 263 | "uur": "15-02-2024 10:00", 264 | "timestamp": 1707987600, 265 | "image": "bewolkt", 266 | "temp": 11, 267 | "windbft": 3, 268 | "windkmh": 14, 269 | "windknp": 8, 270 | "windms": 4, 271 | "windrgr": 217, 272 | "windr": "ZW", 273 | "neersl": 0, 274 | "gr": 6 275 | }, 276 | { 277 | "uur": "15-02-2024 11:00", 278 | "timestamp": 1707991200, 279 | "image": "regen", 280 | "temp": 11, 281 | "windbft": 3, 282 | "windkmh": 14, 283 | "windknp": 8, 284 | "windms": 4, 285 | "windrgr": 214, 286 | "windr": "ZW", 287 | "neersl": 0.1, 288 | "gr": 11 289 | }, 290 | { 291 | "uur": "15-02-2024 12:00", 292 | "timestamp": 1707994800, 293 | "image": "bewolkt", 294 | "temp": 12, 295 | "windbft": 3, 296 | "windkmh": 14, 297 | "windknp": 8, 298 | "windms": 4, 299 | "windrgr": 206, 300 | "windr": "Z", 301 | "neersl": 0, 302 | "gr": 30 303 | }, 304 | { 305 | "uur": "15-02-2024 13:00", 306 | "timestamp": 1707998400, 307 | "image": "bewolkt", 308 | "temp": 13, 309 | "windbft": 3, 310 | "windkmh": 14, 311 | "windknp": 8, 312 | "windms": 4, 313 | "windrgr": 211, 314 | "windr": "Z", 315 | "neersl": 0, 316 | "gr": 172 317 | }, 318 | { 319 | "uur": "15-02-2024 14:00", 320 | "timestamp": 1708002000, 321 | "image": "bewolkt", 322 | "temp": 14, 323 | "windbft": 3, 324 | "windkmh": 14, 325 | "windknp": 8, 326 | "windms": 4, 327 | "windrgr": 200, 328 | "windr": "Z", 329 | "neersl": 0, 330 | "gr": 161 331 | }, 332 | { 333 | "uur": "15-02-2024 15:00", 334 | "timestamp": 1708005600, 335 | "image": "bewolkt", 336 | "temp": 14, 337 | "windbft": 2, 338 | "windkmh": 10, 339 | "windknp": 6, 340 | "windms": 3, 341 | "windrgr": 179, 342 | "windr": "Z", 343 | "neersl": 0, 344 | "gr": 58 345 | }, 346 | { 347 | "uur": "15-02-2024 16:00", 348 | "timestamp": 1708009200, 349 | "image": "bewolkt", 350 | "temp": 14, 351 | "windbft": 2, 352 | "windkmh": 10, 353 | "windknp": 6, 354 | "windms": 3, 355 | "windrgr": 167, 356 | "windr": "ZO", 357 | "neersl": 0, 358 | "gr": 114 359 | }, 360 | { 361 | "uur": "15-02-2024 17:00", 362 | "timestamp": 1708012800, 363 | "image": "bewolkt", 364 | "temp": 14, 365 | "windbft": 3, 366 | "windkmh": 14, 367 | "windknp": 8, 368 | "windms": 4, 369 | "windrgr": 168, 370 | "windr": "Z", 371 | "neersl": 0, 372 | "gr": 72 373 | }, 374 | { 375 | "uur": "15-02-2024 18:00", 376 | "timestamp": 1708016400, 377 | "image": "bewolkt", 378 | "temp": 13, 379 | "windbft": 3, 380 | "windkmh": 14, 381 | "windknp": 8, 382 | "windms": 4, 383 | "windrgr": 156, 384 | "windr": "ZO", 385 | "neersl": 0, 386 | "gr": 14 387 | }, 388 | { 389 | "uur": "15-02-2024 19:00", 390 | "timestamp": 1708020000, 391 | "image": "bewolkt", 392 | "temp": 12, 393 | "windbft": 3, 394 | "windkmh": 14, 395 | "windknp": 8, 396 | "windms": 4, 397 | "windrgr": 155, 398 | "windr": "ZO", 399 | "neersl": 0, 400 | "gr": 0 401 | }, 402 | { 403 | "uur": "15-02-2024 20:00", 404 | "timestamp": 1708023600, 405 | "image": "bewolkt", 406 | "temp": 12, 407 | "windbft": 3, 408 | "windkmh": 18, 409 | "windknp": 10, 410 | "windms": 5, 411 | "windrgr": 152, 412 | "windr": "ZO", 413 | "neersl": 0, 414 | "gr": 0 415 | }, 416 | { 417 | "uur": "15-02-2024 21:00", 418 | "timestamp": 1708027200, 419 | "image": "bewolkt", 420 | "temp": 12, 421 | "windbft": 3, 422 | "windkmh": 18, 423 | "windknp": 10, 424 | "windms": 5, 425 | "windrgr": 155, 426 | "windr": "ZO", 427 | "neersl": 0, 428 | "gr": 0 429 | }, 430 | { 431 | "uur": "15-02-2024 22:00", 432 | "timestamp": 1708030800, 433 | "image": "bewolkt", 434 | "temp": 12, 435 | "windbft": 3, 436 | "windkmh": 18, 437 | "windknp": 10, 438 | "windms": 5, 439 | "windrgr": 162, 440 | "windr": "ZO", 441 | "neersl": 0, 442 | "gr": 0 443 | } 444 | ], 445 | "api": [ 446 | { 447 | "bron": "Bron: Weerdata KNMI/NOAA via Weerlive.nl", 448 | "max_verz": 300, 449 | "rest_verz": 132 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /tests/fixtures/clear_night_fix.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveweer": [ 3 | { 4 | "plaats": "Purmerend", 5 | "timestamp": 1707944883, 6 | "time": "14-02-2024 22:08:03", 7 | "temp": 10.5, 8 | "gtemp": 6.8, 9 | "samenv": "Licht bewolkt", 10 | "lv": 97, 11 | "windr": "WZW", 12 | "windrgr": 226, 13 | "windms": 8.08, 14 | "windbft": 5, 15 | "windknp": 15.7, 16 | "windkmh": 29.1, 17 | "luchtd": 1015.03, 18 | "ldmmhg": 761, 19 | "dauwp": 10.1, 20 | "zicht": 6990, 21 | "gr": 0, 22 | "verw": "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht", 23 | "sup": "07:57", 24 | "sunder": "17:51", 25 | "image": "zonnig", 26 | "alarm": 0, 27 | "lkop": "Vanavond (zeer) zware windstoten", 28 | "ltekst": "De eerstkomende uren zijn er geen waarschuwingen van kracht. Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden.", 29 | "wrschklr": "groen", 30 | "wrsch_g": "22-02-2024 18:00", 31 | "wrsch_gts": 1708621200, 32 | "wrsch_gc": "geel" 33 | } 34 | ], 35 | "wk_verw": [ 36 | { 37 | "dag": "14-02-2024", 38 | "image": "bewolkt", 39 | "max_temp": 10, 40 | "min_temp": 10, 41 | "windbft": 4, 42 | "windkmh": 25, 43 | "windknp": 14, 44 | "windms": 7, 45 | "windrgr": 221, 46 | "windr": "ZW", 47 | "neersl_perc_dag": 0, 48 | "zond_perc_dag": 0 49 | }, 50 | { 51 | "dag": "15-02-2024", 52 | "image": "bewolkt", 53 | "max_temp": 12, 54 | "min_temp": 10, 55 | "windbft": 3, 56 | "windkmh": 18, 57 | "windknp": 10, 58 | "windms": 5, 59 | "windrgr": 184, 60 | "windr": "Z", 61 | "neersl_perc_dag": 10, 62 | "zond_perc_dag": 8 63 | }, 64 | { 65 | "dag": "16-02-2024", 66 | "image": "buien", 67 | "max_temp": 10, 68 | "min_temp": 9, 69 | "windbft": 3, 70 | "windkmh": 18, 71 | "windknp": 10, 72 | "windms": 5, 73 | "windrgr": 199, 74 | "windr": "Z", 75 | "neersl_perc_dag": 40, 76 | "zond_perc_dag": 14 77 | }, 78 | { 79 | "dag": "17-02-2024", 80 | "image": "halfbewolkt", 81 | "max_temp": 8, 82 | "min_temp": 6, 83 | "windbft": 3, 84 | "windkmh": 18, 85 | "windknp": 10, 86 | "windms": 5, 87 | "windrgr": 228, 88 | "windr": "ZW", 89 | "neersl_perc_dag": 20, 90 | "zond_perc_dag": 46 91 | }, 92 | { 93 | "dag": "18-02-2024", 94 | "image": "bewolkt", 95 | "max_temp": 8, 96 | "min_temp": 7, 97 | "windbft": 3, 98 | "windkmh": 18, 99 | "windknp": 10, 100 | "windms": 5, 101 | "windrgr": 210, 102 | "windr": "Z", 103 | "neersl_perc_dag": 10, 104 | "zond_perc_dag": 0 105 | } 106 | ], 107 | "uur_verw": [ 108 | { 109 | "uur": "14-02-2024 23:00", 110 | "timestamp": 1707948000, 111 | "image": "nachtbewolkt", 112 | "temp": 10, 113 | "windbft": 4, 114 | "windkmh": 21, 115 | "windknp": 12, 116 | "windms": 6, 117 | "windrgr": 231, 118 | "windr": "ZW", 119 | "neersl": 0, 120 | "gr": 0 121 | }, 122 | { 123 | "uur": "15-02-2024 00:00", 124 | "timestamp": 1707951600, 125 | "image": "nachtbewolkt", 126 | "temp": 10, 127 | "windbft": 3, 128 | "windkmh": 18, 129 | "windknp": 10, 130 | "windms": 5, 131 | "windrgr": 233, 132 | "windr": "ZW", 133 | "neersl": 0, 134 | "gr": 0 135 | }, 136 | { 137 | "uur": "15-02-2024 01:00", 138 | "timestamp": 1707955200, 139 | "image": "regen", 140 | "temp": 10, 141 | "windbft": 3, 142 | "windkmh": 18, 143 | "windknp": 10, 144 | "windms": 5, 145 | "windrgr": 232, 146 | "windr": "ZW", 147 | "neersl": 0.1, 148 | "gr": 0 149 | }, 150 | { 151 | "uur": "15-02-2024 02:00", 152 | "timestamp": 1707958800, 153 | "image": "bewolkt", 154 | "temp": 10, 155 | "windbft": 3, 156 | "windkmh": 18, 157 | "windknp": 10, 158 | "windms": 5, 159 | "windrgr": 226, 160 | "windr": "ZW", 161 | "neersl": 0, 162 | "gr": 0 163 | }, 164 | { 165 | "uur": "15-02-2024 03:00", 166 | "timestamp": 1707962400, 167 | "image": "bewolkt", 168 | "temp": 10, 169 | "windbft": 3, 170 | "windkmh": 18, 171 | "windknp": 10, 172 | "windms": 5, 173 | "windrgr": 224, 174 | "windr": "ZW", 175 | "neersl": 0, 176 | "gr": 0 177 | }, 178 | { 179 | "uur": "15-02-2024 04:00", 180 | "timestamp": 1707966000, 181 | "image": "bewolkt", 182 | "temp": 10, 183 | "windbft": 3, 184 | "windkmh": 18, 185 | "windknp": 10, 186 | "windms": 5, 187 | "windrgr": 219, 188 | "windr": "ZW", 189 | "neersl": 0, 190 | "gr": 0 191 | }, 192 | { 193 | "uur": "15-02-2024 05:00", 194 | "timestamp": 1707969600, 195 | "image": "bewolkt", 196 | "temp": 10, 197 | "windbft": 3, 198 | "windkmh": 14, 199 | "windknp": 8, 200 | "windms": 4, 201 | "windrgr": 203, 202 | "windr": "Z", 203 | "neersl": 0, 204 | "gr": 0 205 | }, 206 | { 207 | "uur": "15-02-2024 06:00", 208 | "timestamp": 1707973200, 209 | "image": "regen", 210 | "temp": 10, 211 | "windbft": 3, 212 | "windkmh": 14, 213 | "windknp": 8, 214 | "windms": 4, 215 | "windrgr": 190, 216 | "windr": "Z", 217 | "neersl": 0.2, 218 | "gr": 0 219 | }, 220 | { 221 | "uur": "15-02-2024 07:00", 222 | "timestamp": 1707976800, 223 | "image": "regen", 224 | "temp": 10, 225 | "windbft": 3, 226 | "windkmh": 14, 227 | "windknp": 8, 228 | "windms": 4, 229 | "windrgr": 196, 230 | "windr": "Z", 231 | "neersl": 0.9, 232 | "gr": 0 233 | }, 234 | { 235 | "uur": "15-02-2024 08:00", 236 | "timestamp": 1707980400, 237 | "image": "regen", 238 | "temp": 11, 239 | "windbft": 3, 240 | "windkmh": 18, 241 | "windknp": 10, 242 | "windms": 5, 243 | "windrgr": 210, 244 | "windr": "Z", 245 | "neersl": 1, 246 | "gr": 0 247 | }, 248 | { 249 | "uur": "15-02-2024 09:00", 250 | "timestamp": 1707984000, 251 | "image": "regen", 252 | "temp": 11, 253 | "windbft": 3, 254 | "windkmh": 14, 255 | "windknp": 8, 256 | "windms": 4, 257 | "windrgr": 218, 258 | "windr": "ZW", 259 | "neersl": 0.1, 260 | "gr": 3 261 | }, 262 | { 263 | "uur": "15-02-2024 10:00", 264 | "timestamp": 1707987600, 265 | "image": "bewolkt", 266 | "temp": 11, 267 | "windbft": 3, 268 | "windkmh": 14, 269 | "windknp": 8, 270 | "windms": 4, 271 | "windrgr": 217, 272 | "windr": "ZW", 273 | "neersl": 0, 274 | "gr": 6 275 | }, 276 | { 277 | "uur": "15-02-2024 11:00", 278 | "timestamp": 1707991200, 279 | "image": "regen", 280 | "temp": 11, 281 | "windbft": 3, 282 | "windkmh": 14, 283 | "windknp": 8, 284 | "windms": 4, 285 | "windrgr": 214, 286 | "windr": "ZW", 287 | "neersl": 0.1, 288 | "gr": 11 289 | }, 290 | { 291 | "uur": "15-02-2024 12:00", 292 | "timestamp": 1707994800, 293 | "image": "bewolkt", 294 | "temp": 12, 295 | "windbft": 3, 296 | "windkmh": 14, 297 | "windknp": 8, 298 | "windms": 4, 299 | "windrgr": 206, 300 | "windr": "Z", 301 | "neersl": 0, 302 | "gr": 30 303 | }, 304 | { 305 | "uur": "15-02-2024 13:00", 306 | "timestamp": 1707998400, 307 | "image": "bewolkt", 308 | "temp": 13, 309 | "windbft": 3, 310 | "windkmh": 14, 311 | "windknp": 8, 312 | "windms": 4, 313 | "windrgr": 211, 314 | "windr": "Z", 315 | "neersl": 0, 316 | "gr": 172 317 | }, 318 | { 319 | "uur": "15-02-2024 14:00", 320 | "timestamp": 1708002000, 321 | "image": "bewolkt", 322 | "temp": 14, 323 | "windbft": 3, 324 | "windkmh": 14, 325 | "windknp": 8, 326 | "windms": 4, 327 | "windrgr": 200, 328 | "windr": "Z", 329 | "neersl": 0, 330 | "gr": 161 331 | }, 332 | { 333 | "uur": "15-02-2024 15:00", 334 | "timestamp": 1708005600, 335 | "image": "bewolkt", 336 | "temp": 14, 337 | "windbft": 2, 338 | "windkmh": 10, 339 | "windknp": 6, 340 | "windms": 3, 341 | "windrgr": 179, 342 | "windr": "Z", 343 | "neersl": 0, 344 | "gr": 58 345 | }, 346 | { 347 | "uur": "15-02-2024 16:00", 348 | "timestamp": 1708009200, 349 | "image": "bewolkt", 350 | "temp": 14, 351 | "windbft": 2, 352 | "windkmh": 10, 353 | "windknp": 6, 354 | "windms": 3, 355 | "windrgr": 167, 356 | "windr": "ZO", 357 | "neersl": 0, 358 | "gr": 114 359 | }, 360 | { 361 | "uur": "15-02-2024 17:00", 362 | "timestamp": 1708012800, 363 | "image": "bewolkt", 364 | "temp": 14, 365 | "windbft": 3, 366 | "windkmh": 14, 367 | "windknp": 8, 368 | "windms": 4, 369 | "windrgr": 168, 370 | "windr": "Z", 371 | "neersl": 0, 372 | "gr": 72 373 | }, 374 | { 375 | "uur": "15-02-2024 18:00", 376 | "timestamp": 1708016400, 377 | "image": "bewolkt", 378 | "temp": 13, 379 | "windbft": 3, 380 | "windkmh": 14, 381 | "windknp": 8, 382 | "windms": 4, 383 | "windrgr": 156, 384 | "windr": "ZO", 385 | "neersl": 0, 386 | "gr": 14 387 | }, 388 | { 389 | "uur": "15-02-2024 19:00", 390 | "timestamp": 1708020000, 391 | "image": "bewolkt", 392 | "temp": 12, 393 | "windbft": 3, 394 | "windkmh": 14, 395 | "windknp": 8, 396 | "windms": 4, 397 | "windrgr": 155, 398 | "windr": "ZO", 399 | "neersl": 0, 400 | "gr": 0 401 | }, 402 | { 403 | "uur": "15-02-2024 20:00", 404 | "timestamp": 1708023600, 405 | "image": "bewolkt", 406 | "temp": 12, 407 | "windbft": 3, 408 | "windkmh": 18, 409 | "windknp": 10, 410 | "windms": 5, 411 | "windrgr": 152, 412 | "windr": "ZO", 413 | "neersl": 0, 414 | "gr": 0 415 | }, 416 | { 417 | "uur": "15-02-2024 21:00", 418 | "timestamp": 1708027200, 419 | "image": "bewolkt", 420 | "temp": 12, 421 | "windbft": 3, 422 | "windkmh": 18, 423 | "windknp": 10, 424 | "windms": 5, 425 | "windrgr": 155, 426 | "windr": "ZO", 427 | "neersl": 0, 428 | "gr": 0 429 | }, 430 | { 431 | "uur": "15-02-2024 22:00", 432 | "timestamp": 1708030800, 433 | "image": "bewolkt", 434 | "temp": 12, 435 | "windbft": 3, 436 | "windkmh": 18, 437 | "windknp": 10, 438 | "windms": 5, 439 | "windrgr": 162, 440 | "windr": "ZO", 441 | "neersl": 0, 442 | "gr": 0 443 | } 444 | ], 445 | "api": [ 446 | { 447 | "bron": "Bron: Weerdata KNMI/NOAA via Weerlive.nl", 448 | "max_verz": 300, 449 | "rest_verz": 132 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /tests/fixtures/response.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveweer": [ 3 | { 4 | "plaats": "Purmerend", 5 | "timestamp": 1707944883, 6 | "time": "14-02-2024 22:08:03", 7 | "temp": 10.5, 8 | "gtemp": 6.8, 9 | "samenv": "Licht bewolkt", 10 | "lv": 97, 11 | "windr": "WZW", 12 | "windrgr": 226, 13 | "windms": 8.08, 14 | "windbft": 5, 15 | "windknp": 15.7, 16 | "windkmh": 29.1, 17 | "luchtd": 1015.03, 18 | "ldmmhg": 761, 19 | "dauwp": 10.1, 20 | "zicht": 6990, 21 | "gr": 0, 22 | "verw": "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht", 23 | "sup": "07:57", 24 | "sunder": "17:51", 25 | "image": "wolkennacht", 26 | "alarm": 0, 27 | "lkop": "Vanavond (zeer) zware windstoten", 28 | "ltekst": "De eerstkomende uren zijn er geen waarschuwingen van kracht. Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden.", 29 | "wrschklr": "groen", 30 | "wrsch_g": "22-02-2024 18:00", 31 | "wrsch_gts": 1708621200, 32 | "wrsch_gc": "geel" 33 | } 34 | ], 35 | "wk_verw": [ 36 | { 37 | "dag": "14-02-2024", 38 | "image": "bewolkt", 39 | "max_temp": 10, 40 | "min_temp": 10, 41 | "windbft": 4, 42 | "windkmh": 25, 43 | "windknp": 14, 44 | "windms": 7, 45 | "windrgr": 221, 46 | "windr": "ZW", 47 | "neersl_perc_dag": 0, 48 | "zond_perc_dag": 0 49 | }, 50 | { 51 | "dag": "15-02-2024", 52 | "image": "bewolkt", 53 | "max_temp": 12, 54 | "min_temp": 10, 55 | "windbft": 3, 56 | "windkmh": 18, 57 | "windknp": 10, 58 | "windms": 5, 59 | "windrgr": 184, 60 | "windr": "Z", 61 | "neersl_perc_dag": 10, 62 | "zond_perc_dag": 8 63 | }, 64 | { 65 | "dag": "16-02-2024", 66 | "image": "buien", 67 | "max_temp": 10, 68 | "min_temp": 9, 69 | "windbft": 3, 70 | "windkmh": 18, 71 | "windknp": 10, 72 | "windms": 5, 73 | "windrgr": 199, 74 | "windr": "Z", 75 | "neersl_perc_dag": 40, 76 | "zond_perc_dag": 14 77 | }, 78 | { 79 | "dag": "17-02-2024", 80 | "image": "halfbewolkt", 81 | "max_temp": 8, 82 | "min_temp": 6, 83 | "windbft": 3, 84 | "windkmh": 18, 85 | "windknp": 10, 86 | "windms": 5, 87 | "windrgr": 228, 88 | "windr": "ZW", 89 | "neersl_perc_dag": 20, 90 | "zond_perc_dag": 46 91 | }, 92 | { 93 | "dag": "18-02-2024", 94 | "image": "bewolkt", 95 | "max_temp": 8, 96 | "min_temp": 7, 97 | "windbft": 3, 98 | "windkmh": 18, 99 | "windknp": 10, 100 | "windms": 5, 101 | "windrgr": 210, 102 | "windr": "Z", 103 | "neersl_perc_dag": 10, 104 | "zond_perc_dag": 0 105 | } 106 | ], 107 | "uur_verw": [ 108 | { 109 | "uur": "14-02-2024 23:00", 110 | "timestamp": 1707948000, 111 | "image": "nachtbewolkt", 112 | "temp": 10, 113 | "windbft": 4, 114 | "windkmh": 21, 115 | "windknp": 12, 116 | "windms": 6, 117 | "windrgr": 231, 118 | "windr": "ZW", 119 | "neersl": 0, 120 | "gr": 0 121 | }, 122 | { 123 | "uur": "15-02-2024 00:00", 124 | "timestamp": 1707951600, 125 | "image": "nachtbewolkt", 126 | "temp": 10, 127 | "windbft": 3, 128 | "windkmh": 18, 129 | "windknp": 10, 130 | "windms": 5, 131 | "windrgr": 233, 132 | "windr": "ZW", 133 | "neersl": 0, 134 | "gr": 0 135 | }, 136 | { 137 | "uur": "15-02-2024 01:00", 138 | "timestamp": 1707955200, 139 | "image": "regen", 140 | "temp": 10, 141 | "windbft": 3, 142 | "windkmh": 18, 143 | "windknp": 10, 144 | "windms": 5, 145 | "windrgr": 232, 146 | "windr": "ZW", 147 | "neersl": 0.1, 148 | "gr": 0 149 | }, 150 | { 151 | "uur": "15-02-2024 02:00", 152 | "timestamp": 1707958800, 153 | "image": "bewolkt", 154 | "temp": 10, 155 | "windbft": 3, 156 | "windkmh": 18, 157 | "windknp": 10, 158 | "windms": 5, 159 | "windrgr": 226, 160 | "windr": "ZW", 161 | "neersl": 0, 162 | "gr": 0 163 | }, 164 | { 165 | "uur": "15-02-2024 03:00", 166 | "timestamp": 1707962400, 167 | "image": "bewolkt", 168 | "temp": 10, 169 | "windbft": 3, 170 | "windkmh": 18, 171 | "windknp": 10, 172 | "windms": 5, 173 | "windrgr": 224, 174 | "windr": "ZW", 175 | "neersl": 0, 176 | "gr": 0 177 | }, 178 | { 179 | "uur": "15-02-2024 04:00", 180 | "timestamp": 1707966000, 181 | "image": "bewolkt", 182 | "temp": 10, 183 | "windbft": 3, 184 | "windkmh": 18, 185 | "windknp": 10, 186 | "windms": 5, 187 | "windrgr": 219, 188 | "windr": "ZW", 189 | "neersl": 0, 190 | "gr": 0 191 | }, 192 | { 193 | "uur": "15-02-2024 05:00", 194 | "timestamp": 1707969600, 195 | "image": "bewolkt", 196 | "temp": 10, 197 | "windbft": 3, 198 | "windkmh": 14, 199 | "windknp": 8, 200 | "windms": 4, 201 | "windrgr": 203, 202 | "windr": "Z", 203 | "neersl": 0, 204 | "gr": 0 205 | }, 206 | { 207 | "uur": "15-02-2024 06:00", 208 | "timestamp": 1707973200, 209 | "image": "regen", 210 | "temp": 10, 211 | "windbft": 3, 212 | "windkmh": 14, 213 | "windknp": 8, 214 | "windms": 4, 215 | "windrgr": 190, 216 | "windr": "Z", 217 | "neersl": 0.2, 218 | "gr": 0 219 | }, 220 | { 221 | "uur": "15-02-2024 07:00", 222 | "timestamp": 1707976800, 223 | "image": "regen", 224 | "temp": 10, 225 | "windbft": 3, 226 | "windkmh": 14, 227 | "windknp": 8, 228 | "windms": 4, 229 | "windrgr": 196, 230 | "windr": "Z", 231 | "neersl": 0.9, 232 | "gr": 0 233 | }, 234 | { 235 | "uur": "15-02-2024 08:00", 236 | "timestamp": 1707980400, 237 | "image": "regen", 238 | "temp": 11, 239 | "windbft": 3, 240 | "windkmh": 18, 241 | "windknp": 10, 242 | "windms": 5, 243 | "windrgr": 210, 244 | "windr": "Z", 245 | "neersl": 1, 246 | "gr": 0 247 | }, 248 | { 249 | "uur": "15-02-2024 09:00", 250 | "timestamp": 1707984000, 251 | "image": "regen", 252 | "temp": 11, 253 | "windbft": 3, 254 | "windkmh": 14, 255 | "windknp": 8, 256 | "windms": 4, 257 | "windrgr": 218, 258 | "windr": "ZW", 259 | "neersl": 0.1, 260 | "gr": 3 261 | }, 262 | { 263 | "uur": "15-02-2024 10:00", 264 | "timestamp": 1707987600, 265 | "image": "bewolkt", 266 | "temp": 11, 267 | "windbft": 3, 268 | "windkmh": 14, 269 | "windknp": 8, 270 | "windms": 4, 271 | "windrgr": 217, 272 | "windr": "ZW", 273 | "neersl": 0, 274 | "gr": 6 275 | }, 276 | { 277 | "uur": "15-02-2024 11:00", 278 | "timestamp": 1707991200, 279 | "image": "regen", 280 | "temp": 11, 281 | "windbft": 3, 282 | "windkmh": 14, 283 | "windknp": 8, 284 | "windms": 4, 285 | "windrgr": 214, 286 | "windr": "ZW", 287 | "neersl": 0.1, 288 | "gr": 11 289 | }, 290 | { 291 | "uur": "15-02-2024 12:00", 292 | "timestamp": 1707994800, 293 | "image": "bewolkt", 294 | "temp": 12, 295 | "windbft": 3, 296 | "windkmh": 14, 297 | "windknp": 8, 298 | "windms": 4, 299 | "windrgr": 206, 300 | "windr": "Z", 301 | "neersl": 0, 302 | "gr": 30 303 | }, 304 | { 305 | "uur": "15-02-2024 13:00", 306 | "timestamp": 1707998400, 307 | "image": "bewolkt", 308 | "temp": 13, 309 | "windbft": 3, 310 | "windkmh": 14, 311 | "windknp": 8, 312 | "windms": 4, 313 | "windrgr": 211, 314 | "windr": "Z", 315 | "neersl": 0, 316 | "gr": 172 317 | }, 318 | { 319 | "uur": "15-02-2024 14:00", 320 | "timestamp": 1708002000, 321 | "image": "bewolkt", 322 | "temp": 14, 323 | "windbft": 3, 324 | "windkmh": 14, 325 | "windknp": 8, 326 | "windms": 4, 327 | "windrgr": 200, 328 | "windr": "Z", 329 | "neersl": 0, 330 | "gr": 161 331 | }, 332 | { 333 | "uur": "15-02-2024 15:00", 334 | "timestamp": 1708005600, 335 | "image": "bewolkt", 336 | "temp": 14, 337 | "windbft": 2, 338 | "windkmh": 10, 339 | "windknp": 6, 340 | "windms": 3, 341 | "windrgr": 179, 342 | "windr": "Z", 343 | "neersl": 0, 344 | "gr": 58 345 | }, 346 | { 347 | "uur": "15-02-2024 16:00", 348 | "timestamp": 1708009200, 349 | "image": "bewolkt", 350 | "temp": 14, 351 | "windbft": 2, 352 | "windkmh": 10, 353 | "windknp": 6, 354 | "windms": 3, 355 | "windrgr": 167, 356 | "windr": "ZO", 357 | "neersl": 0, 358 | "gr": 114 359 | }, 360 | { 361 | "uur": "15-02-2024 17:00", 362 | "timestamp": 1708012800, 363 | "image": "bewolkt", 364 | "temp": 14, 365 | "windbft": 3, 366 | "windkmh": 14, 367 | "windknp": 8, 368 | "windms": 4, 369 | "windrgr": 168, 370 | "windr": "Z", 371 | "neersl": 0, 372 | "gr": 72 373 | }, 374 | { 375 | "uur": "15-02-2024 18:00", 376 | "timestamp": 1708016400, 377 | "image": "bewolkt", 378 | "temp": 13, 379 | "windbft": 3, 380 | "windkmh": 14, 381 | "windknp": 8, 382 | "windms": 4, 383 | "windrgr": 156, 384 | "windr": "ZO", 385 | "neersl": 0, 386 | "gr": 14 387 | }, 388 | { 389 | "uur": "15-02-2024 19:00", 390 | "timestamp": 1708020000, 391 | "image": "bewolkt", 392 | "temp": 12, 393 | "windbft": 3, 394 | "windkmh": 14, 395 | "windknp": 8, 396 | "windms": 4, 397 | "windrgr": 155, 398 | "windr": "ZO", 399 | "neersl": 0, 400 | "gr": 0 401 | }, 402 | { 403 | "uur": "15-02-2024 20:00", 404 | "timestamp": 1708023600, 405 | "image": "bewolkt", 406 | "temp": 12, 407 | "windbft": 3, 408 | "windkmh": 18, 409 | "windknp": 10, 410 | "windms": 5, 411 | "windrgr": 152, 412 | "windr": "ZO", 413 | "neersl": 0, 414 | "gr": 0 415 | }, 416 | { 417 | "uur": "15-02-2024 21:00", 418 | "timestamp": 1708027200, 419 | "image": "bewolkt", 420 | "temp": 12, 421 | "windbft": 3, 422 | "windkmh": 18, 423 | "windknp": 10, 424 | "windms": 5, 425 | "windrgr": 155, 426 | "windr": "ZO", 427 | "neersl": 0, 428 | "gr": 0 429 | }, 430 | { 431 | "uur": "15-02-2024 22:00", 432 | "timestamp": 1708030800, 433 | "image": "bewolkt", 434 | "temp": 12, 435 | "windbft": 3, 436 | "windkmh": 18, 437 | "windknp": 10, 438 | "windms": 5, 439 | "windrgr": 162, 440 | "windr": "ZO", 441 | "neersl": 0, 442 | "gr": 0 443 | } 444 | ], 445 | "api": [ 446 | { 447 | "bron": "Bron: Weerdata KNMI/NOAA via Weerlive.nl", 448 | "max_verz": 300, 449 | "rest_verz": 132 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /tests/fixtures/response_alarm.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveweer": [ 3 | { 4 | "plaats": "Purmerend", 5 | "timestamp": 1707944883, 6 | "time": "14-02-2024 22:08:03", 7 | "temp": 10.5, 8 | "gtemp": 6.8, 9 | "samenv": "Licht bewolkt", 10 | "lv": 97, 11 | "windr": "WZW", 12 | "windrgr": 226, 13 | "windms": 8.08, 14 | "windbft": 5, 15 | "windknp": 15.7, 16 | "windkmh": 29.1, 17 | "luchtd": 1015.03, 18 | "ldmmhg": 761, 19 | "dauwp": 10.1, 20 | "zicht": 6990, 21 | "gr": 0, 22 | "verw": "Bewolkt en perioden met regen. Morgen in de middag droog en zeer zacht", 23 | "sup": "07:57", 24 | "sunder": "17:51", 25 | "image": "wolkennacht", 26 | "alarm": 1, 27 | "lkop": "Vanavond (zeer) zware windstoten", 28 | "ltekst": "De eerstkomende uren zijn er geen waarschuwingen van kracht. Vanavond komen er (zeer) zware windstoten voor. Landinwaarts tot 90 km/u, aan de kust tot 110 km/u. De wind komt uit een zuidwestelijke richting. Verkeer en buitenactiviteiten kunnen hinder ondervinden.", 29 | "wrschklr": "groen", 30 | "wrsch_g": "22-02-2024 18:00", 31 | "wrsch_gts": 1708621200, 32 | "wrsch_gc": "geel" 33 | } 34 | ], 35 | "wk_verw": [ 36 | { 37 | "dag": "14-02-2024", 38 | "image": "bewolkt", 39 | "max_temp": 10, 40 | "min_temp": 10, 41 | "windbft": 4, 42 | "windkmh": 25, 43 | "windknp": 14, 44 | "windms": 7, 45 | "windrgr": 221, 46 | "windr": "ZW", 47 | "neersl_perc_dag": 0, 48 | "zond_perc_dag": 0 49 | }, 50 | { 51 | "dag": "15-02-2024", 52 | "image": "bewolkt", 53 | "max_temp": 12, 54 | "min_temp": 10, 55 | "windbft": 3, 56 | "windkmh": 18, 57 | "windknp": 10, 58 | "windms": 5, 59 | "windrgr": 184, 60 | "windr": "Z", 61 | "neersl_perc_dag": 10, 62 | "zond_perc_dag": 8 63 | }, 64 | { 65 | "dag": "16-02-2024", 66 | "image": "buien", 67 | "max_temp": 10, 68 | "min_temp": 9, 69 | "windbft": 3, 70 | "windkmh": 18, 71 | "windknp": 10, 72 | "windms": 5, 73 | "windrgr": 199, 74 | "windr": "Z", 75 | "neersl_perc_dag": 40, 76 | "zond_perc_dag": 14 77 | }, 78 | { 79 | "dag": "17-02-2024", 80 | "image": "halfbewolkt", 81 | "max_temp": 8, 82 | "min_temp": 6, 83 | "windbft": 3, 84 | "windkmh": 18, 85 | "windknp": 10, 86 | "windms": 5, 87 | "windrgr": 228, 88 | "windr": "ZW", 89 | "neersl_perc_dag": 20, 90 | "zond_perc_dag": 46 91 | }, 92 | { 93 | "dag": "18-02-2024", 94 | "image": "bewolkt", 95 | "max_temp": 8, 96 | "min_temp": 7, 97 | "windbft": 3, 98 | "windkmh": 18, 99 | "windknp": 10, 100 | "windms": 5, 101 | "windrgr": 210, 102 | "windr": "Z", 103 | "neersl_perc_dag": 10, 104 | "zond_perc_dag": 0 105 | } 106 | ], 107 | "uur_verw": [ 108 | { 109 | "uur": "14-02-2024 23:00", 110 | "timestamp": 1707948000, 111 | "image": "nachtbewolkt", 112 | "temp": 10, 113 | "windbft": 4, 114 | "windkmh": 21, 115 | "windknp": 12, 116 | "windms": 6, 117 | "windrgr": 231, 118 | "windr": "ZW", 119 | "neersl": 0, 120 | "gr": 0 121 | }, 122 | { 123 | "uur": "15-02-2024 00:00", 124 | "timestamp": 1707951600, 125 | "image": "nachtbewolkt", 126 | "temp": 10, 127 | "windbft": 3, 128 | "windkmh": 18, 129 | "windknp": 10, 130 | "windms": 5, 131 | "windrgr": 233, 132 | "windr": "ZW", 133 | "neersl": 0, 134 | "gr": 0 135 | }, 136 | { 137 | "uur": "15-02-2024 01:00", 138 | "timestamp": 1707955200, 139 | "image": "regen", 140 | "temp": 10, 141 | "windbft": 3, 142 | "windkmh": 18, 143 | "windknp": 10, 144 | "windms": 5, 145 | "windrgr": 232, 146 | "windr": "ZW", 147 | "neersl": 0.1, 148 | "gr": 0 149 | }, 150 | { 151 | "uur": "15-02-2024 02:00", 152 | "timestamp": 1707958800, 153 | "image": "bewolkt", 154 | "temp": 10, 155 | "windbft": 3, 156 | "windkmh": 18, 157 | "windknp": 10, 158 | "windms": 5, 159 | "windrgr": 226, 160 | "windr": "ZW", 161 | "neersl": 0, 162 | "gr": 0 163 | }, 164 | { 165 | "uur": "15-02-2024 03:00", 166 | "timestamp": 1707962400, 167 | "image": "bewolkt", 168 | "temp": 10, 169 | "windbft": 3, 170 | "windkmh": 18, 171 | "windknp": 10, 172 | "windms": 5, 173 | "windrgr": 224, 174 | "windr": "ZW", 175 | "neersl": 0, 176 | "gr": 0 177 | }, 178 | { 179 | "uur": "15-02-2024 04:00", 180 | "timestamp": 1707966000, 181 | "image": "bewolkt", 182 | "temp": 10, 183 | "windbft": 3, 184 | "windkmh": 18, 185 | "windknp": 10, 186 | "windms": 5, 187 | "windrgr": 219, 188 | "windr": "ZW", 189 | "neersl": 0, 190 | "gr": 0 191 | }, 192 | { 193 | "uur": "15-02-2024 05:00", 194 | "timestamp": 1707969600, 195 | "image": "bewolkt", 196 | "temp": 10, 197 | "windbft": 3, 198 | "windkmh": 14, 199 | "windknp": 8, 200 | "windms": 4, 201 | "windrgr": 203, 202 | "windr": "Z", 203 | "neersl": 0, 204 | "gr": 0 205 | }, 206 | { 207 | "uur": "15-02-2024 06:00", 208 | "timestamp": 1707973200, 209 | "image": "regen", 210 | "temp": 10, 211 | "windbft": 3, 212 | "windkmh": 14, 213 | "windknp": 8, 214 | "windms": 4, 215 | "windrgr": 190, 216 | "windr": "Z", 217 | "neersl": 0.2, 218 | "gr": 0 219 | }, 220 | { 221 | "uur": "15-02-2024 07:00", 222 | "timestamp": 1707976800, 223 | "image": "regen", 224 | "temp": 10, 225 | "windbft": 3, 226 | "windkmh": 14, 227 | "windknp": 8, 228 | "windms": 4, 229 | "windrgr": 196, 230 | "windr": "Z", 231 | "neersl": 0.9, 232 | "gr": 0 233 | }, 234 | { 235 | "uur": "15-02-2024 08:00", 236 | "timestamp": 1707980400, 237 | "image": "regen", 238 | "temp": 11, 239 | "windbft": 3, 240 | "windkmh": 18, 241 | "windknp": 10, 242 | "windms": 5, 243 | "windrgr": 210, 244 | "windr": "Z", 245 | "neersl": 1, 246 | "gr": 0 247 | }, 248 | { 249 | "uur": "15-02-2024 09:00", 250 | "timestamp": 1707984000, 251 | "image": "regen", 252 | "temp": 11, 253 | "windbft": 3, 254 | "windkmh": 14, 255 | "windknp": 8, 256 | "windms": 4, 257 | "windrgr": 218, 258 | "windr": "ZW", 259 | "neersl": 0.1, 260 | "gr": 3 261 | }, 262 | { 263 | "uur": "15-02-2024 10:00", 264 | "timestamp": 1707987600, 265 | "image": "bewolkt", 266 | "temp": 11, 267 | "windbft": 3, 268 | "windkmh": 14, 269 | "windknp": 8, 270 | "windms": 4, 271 | "windrgr": 217, 272 | "windr": "ZW", 273 | "neersl": 0, 274 | "gr": 6 275 | }, 276 | { 277 | "uur": "15-02-2024 11:00", 278 | "timestamp": 1707991200, 279 | "image": "regen", 280 | "temp": 11, 281 | "windbft": 3, 282 | "windkmh": 14, 283 | "windknp": 8, 284 | "windms": 4, 285 | "windrgr": 214, 286 | "windr": "ZW", 287 | "neersl": 0.1, 288 | "gr": 11 289 | }, 290 | { 291 | "uur": "15-02-2024 12:00", 292 | "timestamp": 1707994800, 293 | "image": "bewolkt", 294 | "temp": 12, 295 | "windbft": 3, 296 | "windkmh": 14, 297 | "windknp": 8, 298 | "windms": 4, 299 | "windrgr": 206, 300 | "windr": "Z", 301 | "neersl": 0, 302 | "gr": 30 303 | }, 304 | { 305 | "uur": "15-02-2024 13:00", 306 | "timestamp": 1707998400, 307 | "image": "bewolkt", 308 | "temp": 13, 309 | "windbft": 3, 310 | "windkmh": 14, 311 | "windknp": 8, 312 | "windms": 4, 313 | "windrgr": 211, 314 | "windr": "Z", 315 | "neersl": 0, 316 | "gr": 172 317 | }, 318 | { 319 | "uur": "15-02-2024 14:00", 320 | "timestamp": 1708002000, 321 | "image": "bewolkt", 322 | "temp": 14, 323 | "windbft": 3, 324 | "windkmh": 14, 325 | "windknp": 8, 326 | "windms": 4, 327 | "windrgr": 200, 328 | "windr": "Z", 329 | "neersl": 0, 330 | "gr": 161 331 | }, 332 | { 333 | "uur": "15-02-2024 15:00", 334 | "timestamp": 1708005600, 335 | "image": "bewolkt", 336 | "temp": 14, 337 | "windbft": 2, 338 | "windkmh": 10, 339 | "windknp": 6, 340 | "windms": 3, 341 | "windrgr": 179, 342 | "windr": "Z", 343 | "neersl": 0, 344 | "gr": 58 345 | }, 346 | { 347 | "uur": "15-02-2024 16:00", 348 | "timestamp": 1708009200, 349 | "image": "bewolkt", 350 | "temp": 14, 351 | "windbft": 2, 352 | "windkmh": 10, 353 | "windknp": 6, 354 | "windms": 3, 355 | "windrgr": 167, 356 | "windr": "ZO", 357 | "neersl": 0, 358 | "gr": 114 359 | }, 360 | { 361 | "uur": "15-02-2024 17:00", 362 | "timestamp": 1708012800, 363 | "image": "bewolkt", 364 | "temp": 14, 365 | "windbft": 3, 366 | "windkmh": 14, 367 | "windknp": 8, 368 | "windms": 4, 369 | "windrgr": 168, 370 | "windr": "Z", 371 | "neersl": 0, 372 | "gr": 72 373 | }, 374 | { 375 | "uur": "15-02-2024 18:00", 376 | "timestamp": 1708016400, 377 | "image": "bewolkt", 378 | "temp": 13, 379 | "windbft": 3, 380 | "windkmh": 14, 381 | "windknp": 8, 382 | "windms": 4, 383 | "windrgr": 156, 384 | "windr": "ZO", 385 | "neersl": 0, 386 | "gr": 14 387 | }, 388 | { 389 | "uur": "15-02-2024 19:00", 390 | "timestamp": 1708020000, 391 | "image": "bewolkt", 392 | "temp": 12, 393 | "windbft": 3, 394 | "windkmh": 14, 395 | "windknp": 8, 396 | "windms": 4, 397 | "windrgr": 155, 398 | "windr": "ZO", 399 | "neersl": 0, 400 | "gr": 0 401 | }, 402 | { 403 | "uur": "15-02-2024 20:00", 404 | "timestamp": 1708023600, 405 | "image": "bewolkt", 406 | "temp": 12, 407 | "windbft": 3, 408 | "windkmh": 18, 409 | "windknp": 10, 410 | "windms": 5, 411 | "windrgr": 152, 412 | "windr": "ZO", 413 | "neersl": 0, 414 | "gr": 0 415 | }, 416 | { 417 | "uur": "15-02-2024 21:00", 418 | "timestamp": 1708027200, 419 | "image": "bewolkt", 420 | "temp": 12, 421 | "windbft": 3, 422 | "windkmh": 18, 423 | "windknp": 10, 424 | "windms": 5, 425 | "windrgr": 155, 426 | "windr": "ZO", 427 | "neersl": 0, 428 | "gr": 0 429 | }, 430 | { 431 | "uur": "15-02-2024 22:00", 432 | "timestamp": 1708030800, 433 | "image": "bewolkt", 434 | "temp": 12, 435 | "windbft": 3, 436 | "windkmh": 18, 437 | "windknp": 10, 438 | "windms": 5, 439 | "windrgr": 162, 440 | "windr": "ZO", 441 | "neersl": 0, 442 | "gr": 0 443 | } 444 | ], 445 | "api": [ 446 | { 447 | "bron": "Bron: Weerdata KNMI/NOAA via Weerlive.nl", 448 | "max_verz": 300, 449 | "rest_verz": 132 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KNMI 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Repo stars][stars-shield]][stars] 5 | [![License][license-shield]](LICENSE) 6 | [![GitHub Activity][commits-shield]][commits] 7 | [![Code coverage][codecov-shield]][codecov] 8 | [![hacs][hacs-shield]][hacs] 9 | [![installs][hacs-installs-shield]][ha-active-installation-badges] 10 | [![Project Maintenance][maintenance-shield]][maintainer] 11 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 12 | 13 | KNMI custom component for Home Assistant.
14 | Weather data provided by KNMI, https://weerlive.nl. 15 | 16 | ## Installation 17 | 18 | ### HACS installation 19 | 20 | The most convenient method for installing this custom component is via HACS. Simply search for the name, and you should be able to locate and install it seamlessly. 21 | 22 | ### Manual installation guide: 23 | 24 | 1. Utilize your preferred tool to access the directory in your Home Assistant (HA) configuration, where you can locate the `configuration.yaml` file. 25 | 2. Should there be no existing `custom_components` directory, you must create one. 26 | 3. Inside the newly created `custom_components` directory, generate a new directory named `knmi`. 27 | 4. Retrieve and download all files from the `custom_components/knmi/` directory in this repository. 28 | 5. Place the downloaded files into the newly created `knmi` directory. 29 | 6. Restart Home Assistant. 30 | 31 | ## Configuration is done in the UI 32 | 33 | Within the HA user interface, navigate to "Configuration" -> "Integrations", click the "+" button, and search for "KNMI" to add the integration. 34 | 35 | ## Known limitations 36 | 37 | This integration is translated into English and Dutch, including entity names and attributes, the data (from the API) is only available in Dutch. 38 | Feel free to create a pull request with your language, see [translations](custom_components/knmi/translations/). Non-numerical values are only available in Dutch 39 | 40 | ## Entities 41 | 42 | This integration comes with multiple pre-built entities, most of which are initially deactivated. To enable additional entities, follow these steps: 43 | 44 | 1. Navigate to "Configuration" -> "Integrations" -> "KNMI" in the Home Assistant interface. 45 | 2. Click on the specific integration with "1 service" that you desire. 46 | 3. Click on "X entities hidden", and a summary of all entities in this integration will be displayed. 47 | 4. Choose the desired entity, click on the cogwheel icon, and access its settings. 48 | 5. Toggle the "Enabled" switch to activate the entity. 49 | 6. Click "Update" to save the changes. Repeat these steps for each entity you wish to enable. 50 | 51 | After completing this process, the newly enabled entities will receive values during the next update. 52 | 53 | ### Binary sensors 54 | 55 | | Name (EN) | Name (NL) | Attributes | Notes | 56 | | --------- | ------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | 57 | | Sun | Zon | Sunrise, Sunset, Sun chance today, tomorrow and day after tomorrow | Times of today, in UTC, frontend will convert this into your local time | 58 | | Warning | Waarschuwing | Title, Description, Code, Next code, Next warning | Code has its own entity, see Weather code below | 59 | 60 | ### Sensors 61 | 62 | Normal sensors: 63 | 64 | | Name (EN) | Name (NL) | Attributes | Notes | 65 | | ------------------------ | ---------------------------- | ----------------------------------- | --------------------- | 66 | | Dew point | Dauwpunt | | Unit configurable | 67 | | Solar irradiance | Globale stralingsintensiteit | | | 68 | | Wind chill | Gevoelstemperatuur | | Unit configurable | 69 | | Air pressure | Luchtdruk | | Unit configurable | 70 | | Humidity | Luchtvochtigheid | | | 71 | | Max temperature today | Max temperatuur vandaag | | Unit configurable | 72 | | Max temperature tomorrow | Max temperatuur morgen | | Unit configurable | 73 | | Min temperature today | Min temperatuur vandaag | | Unit configurable | 74 | | Min temperature tomorrow | Min temperatuur morgen | | Unit configurable | 75 | | Precipitation today | Neerslag vandaag | | | 76 | | Precipitation tomorrow | Neerslag morgen | | | 77 | | Description | Omschrijving | | State is in Dutch | 78 | | Temperature | Temperatuur | | Unit configurable | 79 | | Weather forecast | Weersverwachting | | State is in Dutch | 80 | | Wind speed | Windsnelheid | Bearing, Degree, Beaufort and Knots | Unit configurable | 81 | | Weather code | Weercode | | Raw state is in Dutch | 82 | | Visibility | Zicht | | Unit configurable | 83 | 84 | Diagnostic sensors: 85 | 86 | | Name (EN) | Name (NL) | Notes | 87 | | ---------------------- | ------------------------ | ----------------------- | 88 | | Location | Plaats | | 89 | | Remaining API requests | Resterende API verzoeken | | 90 | | Latest update | Laatste update | Server side update time | 91 | 92 | ### Weather 93 | 94 | The weather entity contains all the weather information, ideal for displaying a comprehensive overview in the Home Assistant frontend. It includes both a daily forecast spanning up to 5 days and an hourly forecast covering up to 24 hours. 95 | 96 | Daily forecast attributes: 97 | 98 | | Attribute | Notes | 99 | | ------------------------- | ------------------------------------------------------------- | 100 | | datetime | Times in UTC, frontend will convert this into your local time | 101 | | condition | | 102 | | templow | | 103 | | temperature | | 104 | | precipitation_probability | in a percentage | 105 | | wind_bearing | | 106 | | wind_speed | | 107 | | wind_speed_bft | Not officially supported, but nice addition | 108 | | sun_chance | Not officially supported, but nice addition | 109 | 110 | Hourly forecast attributes: 111 | 112 | | Attribute | Notes | 113 | | ---------------- | ------------------------------------------------------------- | 114 | | datetime | Times in UTC, frontend will convert this into your local time | 115 | | condition | | 116 | | temperature | | 117 | | precipitation | in millimeters | 118 | | wind_bearing | | 119 | | wind_speed | | 120 | | wind_speed_bft | Not officially supported, but nice addition | 121 | | solar_irradiance | Not officially supported, but nice addition | 122 | 123 | ## Examples 124 | 125 | Integration with entities, notice the hidden ones:
126 | Integration 127 | 128 | Weather cards (hourly and daily forecast):
129 | Weather cards 130 | 131 | Weather entity (with daily and hourly forecast tabs):
132 | Weather entity 133 | 134 | Sun entity:
135 | Sun entity 136 | 137 | Warning entity:
138 | Warning entity 139 | 140 | ## Collect logs 141 | 142 | To activate the debug log necessary for issue reporting, follow these steps: 143 | 144 | 1. Go to "Configuration" -> "Integrations" -> "KNMI" within the Home Assistant interface. 145 | 2. On the left side, locate the "Enable debug logging" button and click on it. 146 | 3. Once you collected enough information, Stop debug logging, this will download the log file as well. 147 | 4. Share the log file in an issue. 148 | 149 | Additionally, logging for this component can be enabled by configuring the logger in Home Assistant with the following steps: 150 | 151 | ```yaml 152 | logger: 153 | default: warn 154 | logs: 155 | custom_components.knmi: debug 156 | ``` 157 | 158 | More info can be found on the [Home Assistant logger integration page](https://www.home-assistant.io/integrations/logger) 159 | 160 | ## Contributions are welcome! 161 | 162 | If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) 163 | 164 | [buymecoffee]: https://www.buymeacoffee.com/golles 165 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 166 | [codecov]: https://app.codecov.io/gh/golles/ha-knmi 167 | [codecov-shield]: https://img.shields.io/codecov/c/github/golles/ha-knmi?style=for-the-badge 168 | [commits-shield]: https://img.shields.io/github/commit-activity/y/golles/ha-knmi.svg?style=for-the-badge 169 | [commits]: https://github.com/golles/ha-knmi/commits/main 170 | [hacs]: https://github.com/custom-components/hacs 171 | [hacs-shield]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge 172 | [ha-active-installation-badges]: https://github.com/golles/ha-active-installation-badges 173 | [hacs-installs-shield]: https://raw.githubusercontent.com/golles/ha-active-installation-badges/main/badges/knmi.svg 174 | [license-shield]: https://img.shields.io/github/license/golles/ha-knmi.svg?style=for-the-badge 175 | [maintainer]: https://github.com/golles 176 | [maintenance-shield]: https://img.shields.io/badge/maintainer-golles-blue.svg?style=for-the-badge 177 | [releases-shield]: https://img.shields.io/github/release/golles/ha-knmi.svg?style=for-the-badge 178 | [releases]: https://github.com/golles/ha-knmi/releases 179 | [stars-shield]: https://img.shields.io/github/stars/golles/ha-knmi?style=for-the-badge 180 | [stars]: https://github.com/golles/ha-knmi/stargazers 181 | -------------------------------------------------------------------------------- /tests/test_weather.py: -------------------------------------------------------------------------------- 1 | """Tests for weather.""" 2 | 3 | from decimal import Decimal 4 | 5 | import pytest 6 | from _pytest.logging import LogCaptureFixture 7 | from homeassistant.components.weather import ( 8 | ATTR_CONDITION_CLEAR_NIGHT, 9 | ATTR_CONDITION_CLOUDY, 10 | ATTR_CONDITION_FOG, 11 | ATTR_CONDITION_HAIL, 12 | ATTR_CONDITION_LIGHTNING, 13 | ATTR_CONDITION_PARTLYCLOUDY, 14 | ATTR_CONDITION_POURING, 15 | ATTR_CONDITION_RAINY, 16 | ATTR_CONDITION_SNOWY, 17 | ATTR_CONDITION_SUNNY, 18 | ATTR_FORECAST_CONDITION, 19 | ATTR_FORECAST_NATIVE_PRECIPITATION, 20 | ATTR_FORECAST_NATIVE_TEMP, 21 | ATTR_FORECAST_NATIVE_TEMP_LOW, 22 | ATTR_FORECAST_NATIVE_WIND_SPEED, 23 | ATTR_FORECAST_PRECIPITATION_PROBABILITY, 24 | ATTR_FORECAST_TIME, 25 | ATTR_FORECAST_WIND_BEARING, 26 | ATTR_WEATHER_APPARENT_TEMPERATURE, 27 | ATTR_WEATHER_DEW_POINT, 28 | ATTR_WEATHER_HUMIDITY, 29 | ATTR_WEATHER_PRESSURE, 30 | ATTR_WEATHER_TEMPERATURE, 31 | ATTR_WEATHER_VISIBILITY, 32 | ATTR_WEATHER_WIND_BEARING, 33 | ATTR_WEATHER_WIND_SPEED, 34 | ) 35 | from homeassistant.core import HomeAssistant 36 | 37 | from custom_components.knmi.coordinator import KnmiDataUpdateCoordinator 38 | from custom_components.knmi.weather import KnmiWeather, KnmiWeatherDescription 39 | 40 | from . import setup_integration, unload_integration 41 | 42 | 43 | @pytest.mark.parametrize( 44 | ("input_value", "expected_output"), 45 | [ 46 | ("zonnig", ATTR_CONDITION_SUNNY), 47 | ("bliksem", ATTR_CONDITION_LIGHTNING), 48 | ("buien", ATTR_CONDITION_POURING), 49 | ("regen", ATTR_CONDITION_RAINY), 50 | ("hagel", ATTR_CONDITION_HAIL), 51 | ("mist", ATTR_CONDITION_FOG), 52 | ("sneeuw", ATTR_CONDITION_SNOWY), 53 | ("bewolkt", ATTR_CONDITION_CLOUDY), 54 | ("lichtbewolkt", ATTR_CONDITION_PARTLYCLOUDY), 55 | ("halfbewolkt", ATTR_CONDITION_PARTLYCLOUDY), 56 | ("halfbewolkt_regen", ATTR_CONDITION_RAINY), 57 | ("zwaarbewolkt", ATTR_CONDITION_CLOUDY), 58 | ("nachtmist", ATTR_CONDITION_FOG), 59 | ("helderenacht", ATTR_CONDITION_CLEAR_NIGHT), 60 | ("nachtbewolkt", ATTR_CONDITION_CLOUDY), 61 | ("wolkennacht", ATTR_CONDITION_CLOUDY), # Undocumented condition 62 | ("-", None), # Unavailable condition 63 | ("_", None), # Unavailable condition 64 | ], 65 | ) 66 | async def test_map_conditions(hass: HomeAssistant, input_value: str, expected_output: str) -> None: 67 | """Test map condition.""" 68 | config_entry = await setup_integration(hass) 69 | coordinator: KnmiDataUpdateCoordinator = config_entry.runtime_data 70 | description = KnmiWeatherDescription(key="weer") 71 | weather = KnmiWeather(config_entry, coordinator, description) 72 | 73 | assert weather.map_condition(input_value) == expected_output 74 | 75 | 76 | async def test_map_conditions_error(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: 77 | """Test map condition error cases.""" 78 | config_entry = await setup_integration(hass) 79 | coordinator: KnmiDataUpdateCoordinator = config_entry.runtime_data 80 | description = KnmiWeatherDescription(key="weer") 81 | weather = KnmiWeather(config_entry, coordinator, description) 82 | 83 | assert weather.map_condition(None) is None 84 | assert 'Weather condition "None" can\'t be mapped, please raise a bug' in caplog.text 85 | assert weather.map_condition("") is None 86 | assert 'Weather condition "" can\'t be mapped, please raise a bug' in caplog.text 87 | assert weather.map_condition("hondenweer") is None 88 | assert 'Weather condition "hondenweer" can\'t be mapped, please raise a bug' in caplog.text 89 | 90 | 91 | @pytest.mark.usefixtures("mocked_data") 92 | async def test_state(hass: HomeAssistant) -> None: 93 | """Test state.""" 94 | config_entry = await setup_integration(hass) 95 | 96 | state = hass.states.get("weather.knmi_home") 97 | assert state 98 | 99 | assert state.state == "cloudy" 100 | 101 | assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 97 102 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_PRESSURE))) == Decimal("1015.03") 103 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_TEMPERATURE))) == Decimal("10.5") 104 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_VISIBILITY))) == Decimal("6.99") 105 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_WIND_BEARING))) == 226 106 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_WIND_SPEED))) == Decimal("29.1") 107 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_DEW_POINT))) == Decimal("10.1") 108 | assert Decimal(str(state.attributes.get(ATTR_WEATHER_APPARENT_TEMPERATURE))) == Decimal("6.8") 109 | 110 | await unload_integration(hass, config_entry) 111 | 112 | 113 | @pytest.mark.usefixtures("mocked_data") 114 | async def test_async_forecast_daily(hass: HomeAssistant) -> None: # pylint: disable=too-many-statements # noqa: PLR0915 115 | """Test daily forecast.""" 116 | config_entry = await setup_integration(hass) 117 | coordinator: KnmiDataUpdateCoordinator = config_entry.runtime_data 118 | description = KnmiWeatherDescription(key="weer") 119 | weather = KnmiWeather(config_entry, coordinator, description) 120 | 121 | forecast = await weather.async_forecast_daily() 122 | assert forecast 123 | assert len(forecast) == 5 124 | 125 | assert forecast[0][ATTR_FORECAST_TIME] == "2024-02-14T00:00:00+01:00" 126 | assert forecast[0][ATTR_FORECAST_CONDITION] == "cloudy" 127 | assert forecast[0][ATTR_FORECAST_NATIVE_TEMP_LOW] == 10 128 | assert forecast[0][ATTR_FORECAST_NATIVE_TEMP] == 10 129 | assert forecast[0][ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 0 130 | assert forecast[0][ATTR_FORECAST_WIND_BEARING] == 221 131 | assert forecast[0][ATTR_FORECAST_NATIVE_WIND_SPEED] == 25 132 | assert forecast[0]["wind_speed_bft"] == 4 # type: ignore # noqa: PGH003 133 | assert forecast[0]["sun_chance"] == 0 # type: ignore # noqa: PGH003 134 | 135 | assert forecast[1][ATTR_FORECAST_TIME] == "2024-02-15T00:00:00+01:00" 136 | assert forecast[1][ATTR_FORECAST_CONDITION] == "cloudy" 137 | assert forecast[1][ATTR_FORECAST_NATIVE_TEMP_LOW] == 10 138 | assert forecast[1][ATTR_FORECAST_NATIVE_TEMP] == 12 139 | assert forecast[1][ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 10 140 | assert forecast[1][ATTR_FORECAST_WIND_BEARING] == 184 141 | assert forecast[1][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 142 | assert forecast[1]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 143 | assert forecast[1]["sun_chance"] == 8 # type: ignore # noqa: PGH003 144 | 145 | assert forecast[2][ATTR_FORECAST_TIME] == "2024-02-16T00:00:00+01:00" 146 | assert forecast[2][ATTR_FORECAST_CONDITION] == "pouring" 147 | assert forecast[2][ATTR_FORECAST_NATIVE_TEMP_LOW] == 9 148 | assert forecast[2][ATTR_FORECAST_NATIVE_TEMP] == 10 149 | assert forecast[2][ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 40 150 | assert forecast[2][ATTR_FORECAST_WIND_BEARING] == 199 151 | assert forecast[2][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 152 | assert forecast[2]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 153 | assert forecast[2]["sun_chance"] == 14 # type: ignore # noqa: PGH003 154 | 155 | assert forecast[3][ATTR_FORECAST_TIME] == "2024-02-17T00:00:00+01:00" 156 | assert forecast[3][ATTR_FORECAST_CONDITION] == "partlycloudy" 157 | assert forecast[3][ATTR_FORECAST_NATIVE_TEMP_LOW] == 6 158 | assert forecast[3][ATTR_FORECAST_NATIVE_TEMP] == 8 159 | assert forecast[3][ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 20 160 | assert forecast[3][ATTR_FORECAST_WIND_BEARING] == 228 161 | assert forecast[3][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 162 | assert forecast[3]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 163 | assert forecast[3]["sun_chance"] == 46 # type: ignore # noqa: PGH003 164 | 165 | assert forecast[4][ATTR_FORECAST_TIME] == "2024-02-18T00:00:00+01:00" 166 | assert forecast[4][ATTR_FORECAST_CONDITION] == "cloudy" 167 | assert forecast[4][ATTR_FORECAST_NATIVE_TEMP_LOW] == 7 168 | assert forecast[4][ATTR_FORECAST_NATIVE_TEMP] == 8 169 | assert forecast[4][ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 10 170 | assert forecast[4][ATTR_FORECAST_WIND_BEARING] == 210 171 | assert forecast[4][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 172 | assert forecast[4]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 173 | assert forecast[4]["sun_chance"] == 0 # type: ignore # noqa: PGH003 174 | 175 | await unload_integration(hass, config_entry) 176 | 177 | 178 | @pytest.mark.usefixtures("mocked_data") 179 | async def test_async_forecast_hourly(hass: HomeAssistant) -> None: # pylint: disable=too-many-statements # noqa: PLR0915 180 | """Test hourly forecast.""" 181 | config_entry = await setup_integration(hass) 182 | coordinator: KnmiDataUpdateCoordinator = config_entry.runtime_data 183 | description = KnmiWeatherDescription(key="weer") 184 | weather = KnmiWeather(config_entry, coordinator, description) 185 | 186 | forecast = await weather.async_forecast_hourly() 187 | assert forecast 188 | assert len(forecast) == 24 189 | 190 | assert forecast[0][ATTR_FORECAST_TIME] == "2024-02-14T23:00:00+01:00" 191 | assert forecast[0][ATTR_FORECAST_CONDITION] == "cloudy" 192 | assert forecast[0][ATTR_FORECAST_NATIVE_TEMP] == 10 193 | assert forecast[0][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 194 | assert forecast[0][ATTR_FORECAST_WIND_BEARING] == 231 195 | assert forecast[0][ATTR_FORECAST_NATIVE_WIND_SPEED] == 21 196 | assert forecast[0]["wind_speed_bft"] == 4 # type: ignore # noqa: PGH003 197 | assert forecast[0]["solar_irradiance"] == 0 # type: ignore # noqa: PGH003 198 | 199 | assert forecast[3][ATTR_FORECAST_TIME] == "2024-02-15T02:00:00+01:00" 200 | assert forecast[3][ATTR_FORECAST_CONDITION] == "cloudy" 201 | assert forecast[3][ATTR_FORECAST_NATIVE_TEMP] == 10 202 | assert forecast[3][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 203 | assert forecast[3][ATTR_FORECAST_WIND_BEARING] == 226 204 | assert forecast[3][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 205 | assert forecast[3]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 206 | assert forecast[3]["solar_irradiance"] == 0 # type: ignore # noqa: PGH003 207 | 208 | assert forecast[5][ATTR_FORECAST_TIME] == "2024-02-15T04:00:00+01:00" 209 | assert forecast[5][ATTR_FORECAST_CONDITION] == "cloudy" 210 | assert forecast[5][ATTR_FORECAST_NATIVE_TEMP] == 10 211 | assert forecast[5][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 212 | assert forecast[5][ATTR_FORECAST_WIND_BEARING] == 219 213 | assert forecast[5][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 214 | assert forecast[5]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 215 | assert forecast[5]["solar_irradiance"] == 0 # type: ignore # noqa: PGH003 216 | 217 | assert forecast[8][ATTR_FORECAST_TIME] == "2024-02-15T07:00:00+01:00" 218 | assert forecast[8][ATTR_FORECAST_CONDITION] == "rainy" 219 | assert forecast[8][ATTR_FORECAST_NATIVE_TEMP] == 10 220 | assert Decimal(str(forecast[8][ATTR_FORECAST_NATIVE_PRECIPITATION])) == Decimal("0.9") 221 | assert forecast[8][ATTR_FORECAST_WIND_BEARING] == 196 222 | assert forecast[8][ATTR_FORECAST_NATIVE_WIND_SPEED] == 14 223 | assert forecast[8]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 224 | assert forecast[8]["solar_irradiance"] == 0 # type: ignore # noqa: PGH003 225 | 226 | assert forecast[13][ATTR_FORECAST_TIME] == "2024-02-15T12:00:00+01:00" 227 | assert forecast[13][ATTR_FORECAST_CONDITION] == "cloudy" 228 | assert forecast[13][ATTR_FORECAST_NATIVE_TEMP] == 12 229 | assert forecast[13][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 230 | assert forecast[13][ATTR_FORECAST_WIND_BEARING] == 206 231 | assert forecast[13][ATTR_FORECAST_NATIVE_WIND_SPEED] == 14 232 | assert forecast[13]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 233 | assert forecast[13]["solar_irradiance"] == 30 # type: ignore # noqa: PGH003 234 | 235 | assert forecast[18][ATTR_FORECAST_TIME] == "2024-02-15T17:00:00+01:00" 236 | assert forecast[18][ATTR_FORECAST_CONDITION] == "cloudy" 237 | assert forecast[18][ATTR_FORECAST_NATIVE_TEMP] == 14 238 | assert forecast[18][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 239 | assert forecast[18][ATTR_FORECAST_WIND_BEARING] == 168 240 | assert forecast[18][ATTR_FORECAST_NATIVE_WIND_SPEED] == 14 241 | assert forecast[18]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 242 | assert forecast[18]["solar_irradiance"] == 72 # type: ignore # noqa: PGH003 243 | 244 | assert forecast[23][ATTR_FORECAST_TIME] == "2024-02-15T22:00:00+01:00" 245 | assert forecast[23][ATTR_FORECAST_CONDITION] == "cloudy" 246 | assert forecast[23][ATTR_FORECAST_NATIVE_TEMP] == 12 247 | assert forecast[23][ATTR_FORECAST_NATIVE_PRECIPITATION] == 0 248 | assert forecast[23][ATTR_FORECAST_WIND_BEARING] == 162 249 | assert forecast[23][ATTR_FORECAST_NATIVE_WIND_SPEED] == 18 250 | assert forecast[23]["wind_speed_bft"] == 3 # type: ignore # noqa: PGH003 251 | assert forecast[23]["solar_irradiance"] == 0 # type: ignore # noqa: PGH003 252 | 253 | await unload_integration(hass, config_entry) 254 | 255 | 256 | @pytest.mark.usefixtures("mocked_data") 257 | async def test_async_forecast_twice_daily(hass: HomeAssistant) -> None: 258 | """Test twice daily forecast.""" 259 | config_entry = await setup_integration(hass) 260 | coordinator: KnmiDataUpdateCoordinator = config_entry.runtime_data 261 | description = KnmiWeatherDescription(key="weer") 262 | weather = KnmiWeather(config_entry, coordinator, description) 263 | 264 | with pytest.raises(NotImplementedError): 265 | await weather.async_forecast_twice_daily() 266 | 267 | await unload_integration(hass, config_entry) 268 | 269 | 270 | @pytest.mark.usefixtures("mocked_data") 271 | @pytest.mark.parametrize("mocked_data", ["warm_snow.json"], indirect=True) 272 | async def test_warm_snow_fix(hass: HomeAssistant) -> None: 273 | """Test if we return rainy if the API returns snowy and a temp higher than 6.""" 274 | config_entry = await setup_integration(hass) 275 | 276 | state = hass.states.get("weather.knmi_home") 277 | assert state 278 | assert state.state == ATTR_CONDITION_RAINY 279 | 280 | await unload_integration(hass, config_entry) 281 | 282 | 283 | @pytest.mark.usefixtures("mocked_data") 284 | @pytest.mark.parametrize("mocked_data", ["cold_snow.json"], indirect=True) 285 | async def test_real_snow(hass: HomeAssistant) -> None: 286 | """Test if we return snowy if the API returns snowy and a temp lower than 6.""" 287 | config_entry = await setup_integration(hass) 288 | 289 | state = hass.states.get("weather.knmi_home") 290 | assert state 291 | assert state.state == ATTR_CONDITION_SNOWY 292 | 293 | await unload_integration(hass, config_entry) 294 | 295 | 296 | @pytest.mark.freeze_time("2023-02-05T15:30:00+00:00") 297 | @pytest.mark.usefixtures("mocked_data") 298 | @pytest.mark.parametrize("mocked_data", ["clear_night_fix.json"], indirect=True) 299 | async def test_sunny_during_day(hass: HomeAssistant) -> None: 300 | """When the API returns sunny when the sun isn't set, the weather state should be sunny.""" 301 | config_entry = await setup_integration(hass) 302 | 303 | state = hass.states.get("weather.knmi_home") 304 | assert state 305 | assert state.state == ATTR_CONDITION_SUNNY 306 | 307 | await unload_integration(hass, config_entry) 308 | 309 | 310 | @pytest.mark.freeze_time("2023-02-05T03:30:00+01:00") 311 | @pytest.mark.usefixtures("mocked_data") 312 | @pytest.mark.parametrize("mocked_data", ["clear_night_fix.json"], indirect=True) 313 | async def test_clear_night_during_night(hass: HomeAssistant) -> None: 314 | """When the API returns sunny when the sun is set, the weather state should be clear night.""" 315 | config_entry = await setup_integration(hass) 316 | 317 | state = hass.states.get("weather.knmi_home") 318 | assert state 319 | assert state.state == ATTR_CONDITION_CLEAR_NIGHT 320 | 321 | await unload_integration(hass, config_entry) 322 | --------------------------------------------------------------------------------