Add integration for Belgian weather provider meteo.be (#144689)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Jules Dejaeghere
2025-09-22 13:28:41 +02:00
committed by GitHub
parent a4f2c88c7f
commit 86dc453c55
26 changed files with 7957 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -772,6 +772,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iqvia/ @bachya
/tests/components/iqvia/ @bachya
/homeassistant/components/irish_rail_transport/ @ttroy50
/homeassistant/components/irm_kmi/ @jdejaegh
/tests/components/irm_kmi/ @jdejaegh
/homeassistant/components/iron_os/ @tr4nt0r
/tests/components/iron_os/ @tr4nt0r
/homeassistant/components/isal/ @bdraco

View File

@@ -0,0 +1,40 @@
"""Integration for IRM KMI weather."""
import logging
from irm_kmi_api import IrmKmiApiClientHa
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import IRM_KMI_TO_HA_CONDITION_MAP, PLATFORMS, USER_AGENT
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
"""Set up this integration using UI."""
api_client = IrmKmiApiClientHa(
session=async_get_clientsession(hass),
user_agent=USER_AGENT,
cdt_map=IRM_KMI_TO_HA_CONDITION_MAP,
)
entry.runtime_data = IrmKmiCoordinator(hass, entry, api_client)
await entry.runtime_data.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> bool:
"""Handle removal of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_reload_entry(hass: HomeAssistant, entry: IrmKmiConfigEntry) -> None:
"""Reload config entry."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,132 @@
"""Config flow to set up IRM KMI integration via the UI."""
import logging
from irm_kmi_api import IrmKmiApiClient, IrmKmiApiError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithReload,
)
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_LOCATION,
CONF_UNIQUE_ID,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
LocationSelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_LANGUAGE_OVERRIDE,
CONF_LANGUAGE_OVERRIDE_OPTIONS,
DOMAIN,
OUT_OF_BENELUX,
USER_AGENT,
)
from .coordinator import IrmKmiConfigEntry
_LOGGER = logging.getLogger(__name__)
class IrmKmiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Configuration flow for the IRM KMI integration."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(_config_entry: IrmKmiConfigEntry) -> OptionsFlow:
"""Create the options flow."""
return IrmKmiOptionFlow()
async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
"""Define the user step of the configuration flow."""
errors: dict = {}
default_location = {
ATTR_LATITUDE: self.hass.config.latitude,
ATTR_LONGITUDE: self.hass.config.longitude,
}
if user_input:
_LOGGER.debug("Provided config user is: %s", user_input)
lat: float = user_input[CONF_LOCATION][ATTR_LATITUDE]
lon: float = user_input[CONF_LOCATION][ATTR_LONGITUDE]
try:
api_data = await IrmKmiApiClient(
session=async_get_clientsession(self.hass),
user_agent=USER_AGENT,
).get_forecasts_coord({"lat": lat, "long": lon})
except IrmKmiApiError:
_LOGGER.exception(
"Encountered an unexpected error while configuring the integration"
)
return self.async_abort(reason="api_error")
if api_data["cityName"] in OUT_OF_BENELUX:
errors[CONF_LOCATION] = "out_of_benelux"
if not errors:
name: str = api_data["cityName"]
country: str = api_data["country"]
unique_id: str = f"{name.lower()} {country.lower()}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
user_input[CONF_UNIQUE_ID] = unique_id
return self.async_create_entry(title=name, data=user_input)
default_location = user_input[CONF_LOCATION]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_LOCATION, default=default_location
): LocationSelector()
}
),
errors=errors,
)
class IrmKmiOptionFlow(OptionsFlowWithReload):
"""Option flow for the IRM KMI integration, help change the options once the integration was configured."""
async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
_LOGGER.debug("Provided config user is: %s", user_input)
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_LANGUAGE_OVERRIDE,
default=self.config_entry.options.get(
CONF_LANGUAGE_OVERRIDE, "none"
),
): SelectSelector(
SelectSelectorConfig(
options=CONF_LANGUAGE_OVERRIDE_OPTIONS,
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_LANGUAGE_OVERRIDE,
)
)
}
),
)

View File

@@ -0,0 +1,102 @@
"""Constants for the IRM KMI integration."""
from typing import Final
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_FOG,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
)
from homeassistant.const import Platform, __version__
DOMAIN: Final = "irm_kmi"
PLATFORMS: Final = [Platform.WEATHER]
OUT_OF_BENELUX: Final = [
"außerhalb der Benelux (Brussels)",
"Hors de Belgique (Bxl)",
"Outside the Benelux (Brussels)",
"Buiten de Benelux (Brussel)",
]
LANGS: Final = ["en", "fr", "nl", "de"]
CONF_LANGUAGE_OVERRIDE: Final = "language_override"
CONF_LANGUAGE_OVERRIDE_OPTIONS: Final = ["none", "fr", "nl", "de", "en"]
# Dict to map ('ww', 'dayNight') tuple from IRM KMI to HA conditions.
IRM_KMI_TO_HA_CONDITION_MAP: Final = {
(0, "d"): ATTR_CONDITION_SUNNY,
(0, "n"): ATTR_CONDITION_CLEAR_NIGHT,
(1, "d"): ATTR_CONDITION_SUNNY,
(1, "n"): ATTR_CONDITION_CLEAR_NIGHT,
(2, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(2, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(3, "d"): ATTR_CONDITION_PARTLYCLOUDY,
(3, "n"): ATTR_CONDITION_PARTLYCLOUDY,
(4, "d"): ATTR_CONDITION_POURING,
(4, "n"): ATTR_CONDITION_POURING,
(5, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(5, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(6, "d"): ATTR_CONDITION_POURING,
(6, "n"): ATTR_CONDITION_POURING,
(7, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(7, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(8, "d"): ATTR_CONDITION_SNOWY_RAINY,
(8, "n"): ATTR_CONDITION_SNOWY_RAINY,
(9, "d"): ATTR_CONDITION_SNOWY_RAINY,
(9, "n"): ATTR_CONDITION_SNOWY_RAINY,
(10, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(10, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(11, "d"): ATTR_CONDITION_SNOWY,
(11, "n"): ATTR_CONDITION_SNOWY,
(12, "d"): ATTR_CONDITION_SNOWY,
(12, "n"): ATTR_CONDITION_SNOWY,
(13, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(13, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(14, "d"): ATTR_CONDITION_CLOUDY,
(14, "n"): ATTR_CONDITION_CLOUDY,
(15, "d"): ATTR_CONDITION_CLOUDY,
(15, "n"): ATTR_CONDITION_CLOUDY,
(16, "d"): ATTR_CONDITION_POURING,
(16, "n"): ATTR_CONDITION_POURING,
(17, "d"): ATTR_CONDITION_LIGHTNING_RAINY,
(17, "n"): ATTR_CONDITION_LIGHTNING_RAINY,
(18, "d"): ATTR_CONDITION_RAINY,
(18, "n"): ATTR_CONDITION_RAINY,
(19, "d"): ATTR_CONDITION_POURING,
(19, "n"): ATTR_CONDITION_POURING,
(20, "d"): ATTR_CONDITION_SNOWY_RAINY,
(20, "n"): ATTR_CONDITION_SNOWY_RAINY,
(21, "d"): ATTR_CONDITION_RAINY,
(21, "n"): ATTR_CONDITION_RAINY,
(22, "d"): ATTR_CONDITION_SNOWY,
(22, "n"): ATTR_CONDITION_SNOWY,
(23, "d"): ATTR_CONDITION_SNOWY,
(23, "n"): ATTR_CONDITION_SNOWY,
(24, "d"): ATTR_CONDITION_FOG,
(24, "n"): ATTR_CONDITION_FOG,
(25, "d"): ATTR_CONDITION_FOG,
(25, "n"): ATTR_CONDITION_FOG,
(26, "d"): ATTR_CONDITION_FOG,
(26, "n"): ATTR_CONDITION_FOG,
(27, "d"): ATTR_CONDITION_FOG,
(27, "n"): ATTR_CONDITION_FOG,
}
IRM_KMI_NAME: Final = {
"fr": "Institut Royal Météorologique de Belgique",
"nl": "Koninklijk Meteorologisch Instituut van België",
"de": "Königliche Meteorologische Institut von Belgien",
"en": "Royal Meteorological Institute of Belgium",
}
USER_AGENT: Final = (
f"https://www.home-assistant.io/integrations/irm_kmi (version {__version__})"
)

View File

@@ -0,0 +1,95 @@
"""DataUpdateCoordinator for the IRM KMI integration."""
from datetime import timedelta
import logging
from irm_kmi_api import IrmKmiApiClientHa, IrmKmiApiError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_LOCATION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util import dt as dt_util
from homeassistant.util.dt import utcnow
from .data import ProcessedCoordinatorData
from .utils import preferred_language
_LOGGER = logging.getLogger(__name__)
type IrmKmiConfigEntry = ConfigEntry[IrmKmiCoordinator]
class IrmKmiCoordinator(TimestampDataUpdateCoordinator[ProcessedCoordinatorData]):
"""Coordinator to update data from IRM KMI."""
def __init__(
self,
hass: HomeAssistant,
entry: IrmKmiConfigEntry,
api_client: IrmKmiApiClientHa,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name="IRM KMI weather",
update_interval=timedelta(minutes=7),
)
self._api = api_client
self._location = entry.data[CONF_LOCATION]
async def _async_update_data(self) -> ProcessedCoordinatorData:
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables so entities can quickly look up their data.
:return: ProcessedCoordinatorData
"""
self._api.expire_cache()
try:
await self._api.refresh_forecasts_coord(
{
"lat": self._location[ATTR_LATITUDE],
"long": self._location[ATTR_LONGITUDE],
}
)
except IrmKmiApiError as err:
if (
self.last_update_success_time is not None
and self.update_interval is not None
and self.last_update_success_time - utcnow()
< timedelta(seconds=2.5 * self.update_interval.seconds)
):
return self.data
_LOGGER.warning(
"Could not connect to the API since %s", self.last_update_success_time
)
raise UpdateFailed(
f"Error communicating with API for general forecast: {err}. "
f"Last success time is: {self.last_update_success_time}"
) from err
if not self.last_update_success:
_LOGGER.warning("Successfully reconnected to the API")
return await self.process_api_data()
async def process_api_data(self) -> ProcessedCoordinatorData:
"""From the API data, create the object that will be used in the entities."""
tz = await dt_util.async_get_time_zone("Europe/Brussels")
lang = preferred_language(self.hass, self.config_entry)
return ProcessedCoordinatorData(
current_weather=self._api.get_current_weather(tz),
daily_forecast=self._api.get_daily_forecast(tz, lang),
hourly_forecast=self._api.get_hourly_forecast(tz),
country=self._api.get_country(),
)

View File

@@ -0,0 +1,17 @@
"""Define data classes for the IRM KMI integration."""
from dataclasses import dataclass, field
from irm_kmi_api import CurrentWeatherData, ExtendedForecast
from homeassistant.components.weather import Forecast
@dataclass
class ProcessedCoordinatorData:
"""Dataclass that will be exposed to the entities consuming data from an IrmKmiCoordinator."""
current_weather: CurrentWeatherData
country: str
hourly_forecast: list[Forecast] = field(default_factory=list)
daily_forecast: list[ExtendedForecast] = field(default_factory=list)

View File

@@ -0,0 +1,28 @@
"""Base class shared among IRM KMI entities."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, IRM_KMI_NAME
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
from .utils import preferred_language
class IrmKmiBaseEntity(CoordinatorEntity[IrmKmiCoordinator]):
"""Base methods for IRM KMI entities."""
_attr_attribution = (
"Weather data from the Royal Meteorological Institute of Belgium meteo.be"
)
_attr_has_entity_name = True
def __init__(self, entry: IrmKmiConfigEntry) -> None:
"""Init base properties for IRM KMI entities."""
coordinator = entry.runtime_data
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer=IRM_KMI_NAME.get(preferred_language(self.hass, entry)),
)

View File

@@ -0,0 +1,13 @@
{
"domain": "irm_kmi",
"name": "IRM KMI Weather Belgium",
"codeowners": ["@jdejaegh"],
"config_flow": true,
"dependencies": ["zone"],
"documentation": "https://www.home-assistant.io/integrations/irm_kmi",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["irm_kmi_api"],
"quality_scale": "bronze",
"requirements": ["irm-kmi-api==1.1.0"]
}

View File

@@ -0,0 +1,86 @@
rules:
# Bronze
action-setup:
status: exempt
comment: >
No service action implemented in this integration at the moment.
appropriate-polling:
status: done
comment: >
Polling interval is set to 7 minutes.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: >
No service action implemented in this integration at the moment.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: >
No service action implemented in this integration at the moment.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: >
There is no authentication for this integration
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: >
The integration does not look for devices on the network. It uses an online API.
discovery:
status: exempt
comment: >
The integration does not look for devices on the network. It uses an online API.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: >
This integration does not integrate physical devices.
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices: done
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow:
status: exempt
comment: >
There is no configuration per se, just a zone to pick.
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,50 @@
{
"title": "Royal Meteorological Institute of Belgium",
"common": {
"language_override_description": "Override the Home Assistant language for the textual weather forecast."
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"api_error": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"location": "[%key:common::config_flow::data::location%]"
},
"data_description": {
"location": "[%key:common::config_flow::data::location%]"
}
}
},
"error": {
"out_of_benelux": "The location is outside of Benelux. Pick a location in Benelux."
}
},
"selector": {
"language_override": {
"options": {
"none": "Follow Home Assistant server language",
"fr": "French",
"nl": "Dutch",
"de": "German",
"en": "English"
}
}
},
"options": {
"step": {
"init": {
"title": "Options",
"data": {
"language_override": "[%key:common::config_flow::data::language%]"
},
"data_description": {
"language_override": "[%key:component::irm_kmi::common::language_override_description%]"
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
"""Helper functions for use with IRM KMI integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_LANGUAGE_OVERRIDE, LANGS
def preferred_language(hass: HomeAssistant, config_entry: ConfigEntry | None) -> str:
"""Get the preferred language for the integration if it was overridden by the configuration."""
if (
config_entry is None
or config_entry.options.get(CONF_LANGUAGE_OVERRIDE) == "none"
):
return hass.config.language if hass.config.language in LANGS else "en"
return config_entry.options.get(CONF_LANGUAGE_OVERRIDE, "en")

View File

@@ -0,0 +1,158 @@
"""Support for IRM KMI weather."""
from irm_kmi_api import CurrentWeatherData
from homeassistant.components.weather import (
Forecast,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
from homeassistant.const import (
CONF_UNIQUE_ID,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IrmKmiConfigEntry, IrmKmiCoordinator
from .entity import IrmKmiBaseEntity
async def async_setup_entry(
_hass: HomeAssistant,
entry: IrmKmiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the weather entry."""
async_add_entities([IrmKmiWeather(entry)])
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class IrmKmiWeather(
IrmKmiBaseEntity, # WeatherEntity
SingleCoordinatorWeatherEntity[IrmKmiCoordinator],
):
"""Weather entity for IRM KMI weather."""
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_TWICE_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
)
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_native_pressure_unit = UnitOfPressure.HPA
def __init__(self, entry: IrmKmiConfigEntry) -> None:
"""Create a new instance of the weather entity from a configuration entry."""
IrmKmiBaseEntity.__init__(self, entry)
SingleCoordinatorWeatherEntity.__init__(self, entry.runtime_data)
self._attr_unique_id = entry.data[CONF_UNIQUE_ID]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available
@property
def current_weather(self) -> CurrentWeatherData:
"""Return the current weather."""
return self.coordinator.data.current_weather
@property
def condition(self) -> str | None:
"""Return the current condition."""
return self.current_weather.get("condition")
@property
def native_temperature(self) -> float | None:
"""Return the temperature in native units."""
return self.current_weather.get("temperature")
@property
def native_wind_speed(self) -> float | None:
"""Return the wind speed in native units."""
return self.current_weather.get("wind_speed")
@property
def native_wind_gust_speed(self) -> float | None:
"""Return the wind gust speed in native units."""
return self.current_weather.get("wind_gust_speed")
@property
def wind_bearing(self) -> float | str | None:
"""Return the wind bearing."""
return self.current_weather.get("wind_bearing")
@property
def native_pressure(self) -> float | None:
"""Return the pressure in native units."""
return self.current_weather.get("pressure")
@property
def uv_index(self) -> float | None:
"""Return the UV index."""
return self.current_weather.get("uv_index")
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return self.coordinator.data.daily_forecast
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return self.daily_forecast()
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
return self.coordinator.data.hourly_forecast
def daily_forecast(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
data: list[Forecast] = self.coordinator.data.daily_forecast
# The data in daily_forecast might contain nighttime forecast.
# The following handle the lowest temperature attribute to be displayed correctly.
if (
len(data) > 1
and not data[0].get("is_daytime")
and data[1].get("native_templow") is None
):
data[1]["native_templow"] = data[0].get("native_templow")
if (
data[1]["native_templow"] is not None
and data[1]["native_temperature"] is not None
and data[1]["native_templow"] > data[1]["native_temperature"]
):
(data[1]["native_templow"], data[1]["native_temperature"]) = (
data[1]["native_temperature"],
data[1]["native_templow"],
)
if len(data) > 0 and not data[0].get("is_daytime"):
return data
if (
len(data) > 1
and data[0].get("native_templow") is None
and not data[1].get("is_daytime")
):
data[0]["native_templow"] = data[1].get("native_templow")
if (
data[0]["native_templow"] is not None
and data[0]["native_temperature"] is not None
and data[0]["native_templow"] > data[0]["native_temperature"]
):
(data[0]["native_templow"], data[0]["native_temperature"]) = (
data[0]["native_temperature"],
data[0]["native_templow"],
)
return [f for f in data if f.get("is_daytime")]

View File

@@ -310,6 +310,7 @@ FLOWS = {
"ipma",
"ipp",
"iqvia",
"irm_kmi",
"iron_os",
"iskra",
"islamic_prayer_times",

View File

@@ -3118,6 +3118,11 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"irm_kmi": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"iron_os": {
"name": "IronOS",
"integration_type": "hub",
@@ -7969,6 +7974,7 @@
"input_select",
"input_text",
"integration",
"irm_kmi",
"islamic_prayer_times",
"local_calendar",
"local_ip",

3
requirements_all.txt generated
View File

@@ -1278,6 +1278,9 @@ iottycloud==0.3.0
# homeassistant.components.iperf3
iperf3==0.1.11
# homeassistant.components.irm_kmi
irm-kmi-api==1.1.0
# homeassistant.components.isal
isal==1.8.0

View File

@@ -1109,6 +1109,9 @@ iometer==0.1.0
# homeassistant.components.iotty
iottycloud==0.3.0
# homeassistant.components.irm_kmi
irm-kmi-api==1.1.0
# homeassistant.components.isal
isal==1.8.0

View File

@@ -0,0 +1 @@
"""Tests of IRM KMI integration."""

View File

@@ -0,0 +1,123 @@
"""Fixtures for the IRM KMI integration tests."""
from collections.abc import Generator
import json
from unittest.mock import MagicMock, patch
from irm_kmi_api import IrmKmiApiError
import pytest
from homeassistant.components.irm_kmi.const import DOMAIN
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_LOCATION,
CONF_UNIQUE_ID,
)
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="Home",
domain=DOMAIN,
data={
CONF_LOCATION: {ATTR_LATITUDE: 50.84, ATTR_LONGITUDE: 4.35},
CONF_UNIQUE_ID: "city country",
},
unique_id="50.84-4.35",
)
@pytest.fixture
def mock_setup_entry() -> Generator[None]:
"""Mock setting up a config entry."""
with patch("homeassistant.components.irm_kmi.async_setup_entry", return_value=True):
yield
@pytest.fixture
def mock_get_forecast_in_benelux():
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux."""
with patch(
"homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
return_value={"cityName": "Brussels", "country": "BE"},
):
yield
@pytest.fixture
def mock_get_forecast_out_benelux_then_in_belgium():
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux."""
with patch(
"homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
side_effect=[
{"cityName": "Outside the Benelux (Brussels)", "country": "BE"},
{"cityName": "Brussels", "country": "BE"},
],
):
yield
@pytest.fixture
def mock_get_forecast_api_error():
"""Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error."""
with patch(
"homeassistant.components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord",
side_effect=IrmKmiApiError,
):
yield
@pytest.fixture
def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock]:
"""Return a mocked IrmKmi api client."""
fixture: str = "forecast.json"
forecast = json.loads(load_fixture(fixture, "irm_kmi"))
with patch(
"homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.get_forecasts_coord.return_value = forecast
yield irm_kmi
@pytest.fixture
def mock_irm_kmi_api_nl():
"""Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return a forecast in The Netherlands."""
fixture: str = "forecast_nl.json"
forecast = json.loads(load_fixture(fixture, "irm_kmi"))
with patch(
"homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord",
return_value=forecast,
):
yield
@pytest.fixture
def mock_irm_kmi_api_high_low_temp():
"""Mock a call to IrmKmiApiClientHa.get_forecasts_coord() to return high_low_temp.json forecast."""
fixture: str = "high_low_temp.json"
forecast = json.loads(load_fixture(fixture, "irm_kmi"))
with patch(
"homeassistant.components.irm_kmi.coordinator.IrmKmiApiClientHa.get_forecasts_coord",
return_value=forecast,
):
yield
@pytest.fixture
def mock_exception_irm_kmi_api(
request: pytest.FixtureRequest,
) -> Generator[None, MagicMock]:
"""Return a mocked IrmKmi api client that will raise an error upon refreshing data."""
with patch(
"homeassistant.components.irm_kmi.IrmKmiApiClientHa", autospec=True
) as irm_kmi_api_mock:
irm_kmi = irm_kmi_api_mock.return_value
irm_kmi.refresh_forecasts_coord.side_effect = IrmKmiApiError
yield irm_kmi

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
# serializer version: 1
# name: test_forecast_service[daily]
dict({
'weather.home': dict({
'forecast': list([
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2023-12-28',
'is_daytime': True,
'precipitation': 0.1,
'precipitation_probability': None,
'sunrise': '2023-12-28T08:47:43+01:00',
'sunset': '2023-12-28T16:34:06+01:00',
'temperature': 11.0,
'templow': 9.0,
'text': '''
Waarschuwingen
Vanavond zijn er in het noordwesten zware windstoten mogelijk van 75-90 km/uur (code geel).
Vanochtend is het half bewolkt met in het noorden kans op een bui. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, af en toe even stormachtig, windkracht 8. Aan de kust komen windstoten voor van ongeveer 80 km/uur.
Vanmiddag is het half tot zwaar bewolkt met kans op een bui, vooral in het noorden en westen. De middagtemperatuur ligt rond 11°C. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust krachtig tot hard, windkracht 6-7, vooral later ook af en toe stormachtig, windkracht 8. Aan de kust zijn er windstoten tot ongeveer 80 km/uur.
Vanavond zijn er buien, alleen in het zuidoosten is het overwegend droog. De wind komt uit het zuidwesten en is meestal vrij krachtig, aan de kust hard tot stormachtig, windkracht 7 tot 8. Vooral in het noordwesten zijn windstoten mogelijk van 75-90 km/uur.
Komende nacht komen er enkele buien voor. Met een minimumtemperatuur van ongeveer 8°C is het zacht. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met zware windstoten tot ongeveer 80 km/uur.
Morgenochtend is het half tot zwaar bewolkt en zijn er enkele buien. De wind komt uit het zuidwesten en is matig tot vrij krachtig, aan zee krachtig hard, windkracht 6-7, met vooral in het noordwesten mogelijk zware windstoten tot ongeveer 80 km/uur.
Morgenmiddag is er af en toe ruimte voor de zon en blijft het op de meeste plaatsen droog, alleen in het zuidoosten kan een enkele bui vallen. Met middagtemperaturen van ongeveer 10°C blijft het zacht. De wind uit het zuidwesten is matig tot vrij krachtig, aan zee krachtig tot hard, windkracht 6-7, met in het Waddengebied zware windstoten tot ongeveer 80 km/uur.
Morgenavond is het half tot zwaar bewolkt met een enkele bui. De wind komt uit het zuidwesten en is meest matig, aan de kust krachtig tot hard, windkracht 6-7, boven de Wadden eerst stormachtig, windkracht 8.
(Bron: KNMI, 2023-12-28T06:56:00+01:00)
''',
'wind_bearing': 225.0,
'wind_gust_speed': 33.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'partlycloudy',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2023-12-29',
'is_daytime': True,
'precipitation': 3.8,
'precipitation_probability': None,
'sunrise': '2023-12-29T08:47:48+01:00',
'sunset': '2023-12-29T16:35:00+01:00',
'temperature': 10.0,
'text': '',
'wind_bearing': 248.0,
'wind_gust_speed': 28.0,
'wind_speed': 26.0,
}),
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2023-12-30',
'is_daytime': True,
'precipitation': 1.7,
'precipitation_probability': None,
'sunrise': '2023-12-30T08:47:49+01:00',
'sunset': '2023-12-30T16:35:57+01:00',
'temperature': 10.0,
'templow': 5.0,
'text': '',
'wind_bearing': 225.0,
'wind_gust_speed': 25.0,
'wind_speed': 22.0,
}),
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2023-12-31',
'is_daytime': True,
'precipitation': 4.2,
'precipitation_probability': None,
'sunrise': '2023-12-31T08:47:47+01:00',
'sunset': '2023-12-31T16:36:56+01:00',
'temperature': 9.0,
'templow': 7.0,
'text': '',
'wind_bearing': 203.0,
'wind_gust_speed': 31.0,
'wind_speed': 30.0,
}),
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2024-01-01',
'is_daytime': True,
'precipitation': 2.2,
'precipitation_probability': None,
'sunrise': '2024-01-01T08:47:42+01:00',
'sunset': '2024-01-01T16:37:59+01:00',
'temperature': 7.0,
'templow': 5.0,
'text': '',
'wind_bearing': 225.0,
'wind_gust_speed': 28.0,
'wind_speed': 23.0,
}),
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2024-01-02',
'is_daytime': True,
'precipitation': 1.4,
'precipitation_probability': None,
'sunrise': '2024-01-02T08:47:32+01:00',
'sunset': '2024-01-02T16:39:04+01:00',
'temperature': 6.0,
'templow': 3.0,
'text': '',
'wind_bearing': 225.0,
'wind_gust_speed': 16.0,
'wind_speed': 15.0,
}),
dict({
'condition': 'pouring',
'condition_2': None,
'condition_evol': <ConditionEvol.STABLE: 'stable'>,
'datetime': '2024-01-03',
'is_daytime': True,
'precipitation': 1.0,
'precipitation_probability': None,
'sunrise': '2024-01-03T08:47:20+01:00',
'sunset': '2024-01-03T16:40:12+01:00',
'temperature': 6.0,
'templow': 3.0,
'text': '',
'wind_bearing': 203.0,
'wind_gust_speed': 14.0,
'wind_speed': 13.0,
}),
]),
}),
})
# ---
# name: test_forecast_service[hourly]
dict({
'weather.home': dict({
'forecast': list([
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T15:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1008.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 33.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T16:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1008.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T17:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1007.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T18:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1007.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T19:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T20:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-22T21:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-22T22:00:00+02:00',
'is_daytime': False,
'precipitation': 0.7,
'precipitation_probability': 70,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-22T23:00:00+02:00',
'is_daytime': False,
'precipitation': 0.1,
'precipitation_probability': 10,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 37.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T00:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 20,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-23T01:00:00+02:00',
'is_daytime': False,
'precipitation': 1.9,
'precipitation_probability': 80,
'pressure': 1005.0,
'temperature': 10.0,
'wind_bearing': 225.0,
'wind_speed': 31.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-23T02:00:00+02:00',
'is_daytime': False,
'precipitation': 0.6,
'precipitation_probability': 70,
'pressure': 1005.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 38.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T03:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T04:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 34.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T05:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 35.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T06:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 34.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T07:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T08:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 31.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T09:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 31.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T10:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T11:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 32.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T12:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 34.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T13:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 33.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T14:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 31.0,
}),
dict({
'condition': 'sunny',
'datetime': '2025-09-23T15:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 10.0,
'wind_bearing': 248.0,
'wind_speed': 28.0,
}),
dict({
'condition': 'sunny',
'datetime': '2025-09-23T16:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 9.0,
'wind_bearing': 248.0,
'wind_speed': 24.0,
}),
dict({
'condition': 'clear-night',
'datetime': '2025-09-23T17:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 8.0,
'wind_bearing': 225.0,
'wind_speed': 20.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T18:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 8.0,
'wind_bearing': 225.0,
'wind_speed': 18.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T19:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1005.0,
'temperature': 8.0,
'wind_bearing': 203.0,
'wind_speed': 15.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-23T20:00:00+02:00',
'is_daytime': False,
'precipitation': 5.7,
'precipitation_probability': 100,
'pressure': 1005.0,
'temperature': 8.0,
'wind_bearing': 248.0,
'wind_speed': 22.0,
}),
dict({
'condition': 'pouring',
'datetime': '2025-09-23T21:00:00+02:00',
'is_daytime': False,
'precipitation': 3.8,
'precipitation_probability': 100,
'pressure': 1006.0,
'temperature': 7.0,
'wind_bearing': 270.0,
'wind_speed': 26.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-23T22:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1006.0,
'temperature': 8.0,
'wind_bearing': 248.0,
'wind_speed': 24.0,
}),
dict({
'condition': 'cloudy',
'datetime': '2025-09-23T23:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1007.0,
'temperature': 7.0,
'wind_bearing': 248.0,
'wind_speed': 22.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T00:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1008.0,
'temperature': 8.0,
'wind_bearing': 270.0,
'wind_speed': 26.0,
}),
dict({
'condition': 'clear-night',
'datetime': '2025-09-24T01:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1007.0,
'temperature': 7.0,
'wind_bearing': 270.0,
'wind_speed': 26.0,
}),
dict({
'condition': 'clear-night',
'datetime': '2025-09-24T02:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1008.0,
'temperature': 7.0,
'wind_bearing': 270.0,
'wind_speed': 24.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T03:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1008.0,
'temperature': 7.0,
'wind_bearing': 270.0,
'wind_speed': 24.0,
}),
dict({
'condition': 'clear-night',
'datetime': '2025-09-24T04:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1009.0,
'temperature': 7.0,
'wind_bearing': 248.0,
'wind_speed': 23.0,
}),
dict({
'condition': 'clear-night',
'datetime': '2025-09-24T05:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1009.0,
'temperature': 6.0,
'wind_bearing': 248.0,
'wind_speed': 23.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T06:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1009.0,
'temperature': 6.0,
'wind_bearing': 248.0,
'wind_speed': 21.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T07:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1010.0,
'temperature': 6.0,
'wind_bearing': 248.0,
'wind_speed': 20.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T08:00:00+02:00',
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1011.0,
'temperature': 6.0,
'wind_bearing': 248.0,
'wind_speed': 17.0,
}),
dict({
'condition': 'sunny',
'datetime': '2025-09-24T09:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1011.0,
'temperature': 6.0,
'wind_bearing': 248.0,
'wind_speed': 13.0,
}),
dict({
'condition': 'partlycloudy',
'datetime': '2025-09-24T10:00:00+02:00',
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1012.0,
'temperature': 5.0,
'wind_bearing': 225.0,
'wind_speed': 12.0,
}),
]),
}),
})
# ---
# name: test_weather_nl[weather.home-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'weather',
'entity_category': None,
'entity_id': 'weather.home',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'irm_kmi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <WeatherEntityFeature: 7>,
'translation_key': None,
'unique_id': 'city country',
'unit_of_measurement': None,
})
# ---
# name: test_weather_nl[weather.home-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Weather data from the Royal Meteorological Institute of Belgium meteo.be',
'friendly_name': 'Home',
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure': 1008.0,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': <WeatherEntityFeature: 7>,
'temperature': 11.0,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'uv_index': 1,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 225.0,
'wind_speed': 40.0,
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'weather.home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cloudy',
})
# ---

View File

@@ -0,0 +1,154 @@
"""Tests for the IRM KMI config flow."""
from unittest.mock import MagicMock
from homeassistant.components.irm_kmi.const import CONF_LANGUAGE_OVERRIDE, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_LOCATION,
CONF_UNIQUE_ID,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_full_user_flow(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_in_benelux: MagicMock,
) -> None:
"""Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "Brussels"
assert result.get("data") == {
CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456},
CONF_UNIQUE_ID: "brussels be",
}
async def test_user_flow_home(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_in_benelux: MagicMock,
) -> None:
"""Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "Brussels"
async def test_config_flow_location_out_benelux(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_out_benelux_then_in_belgium: MagicMock,
) -> None:
"""Test configuration flow with a zone outside of Benelux."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 0.123, ATTR_LONGITUDE: 0.456}},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
assert CONF_LOCATION in result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
async def test_config_flow_with_api_error(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_api_error: MagicMock,
) -> None:
"""Test when API returns an error during the configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.123, ATTR_LONGITUDE: 4.456}},
)
assert result.get("type") is FlowResultType.ABORT
async def test_setup_twice_same_location(
hass: HomeAssistant,
mock_setup_entry: MagicMock,
mock_get_forecast_in_benelux: MagicMock,
) -> None:
"""Test when the user tries to set up the weather twice for the same location."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
# Set up a second time
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_LOCATION: {ATTR_LATITUDE: 50.5, ATTR_LONGITUDE: 4.6}},
)
assert result.get("type") is FlowResultType.ABORT
async def test_option_flow(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test when the user changes options with the option flow."""
mock_config_entry.add_to_hass(hass)
assert not mock_config_entry.options
result = await hass.config_entries.options.async_init(
mock_config_entry.entry_id, data=None
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_LANGUAGE_OVERRIDE: "none"}

View File

@@ -0,0 +1,43 @@
"""Tests for the IRM KMI integration."""
from unittest.mock import AsyncMock
from homeassistant.components.irm_kmi.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api: AsyncMock,
) -> None:
"""Test the IRM KMI configuration entry loading/unloading."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_exception_irm_kmi_api: AsyncMock,
) -> None:
"""Test the IRM KMI configuration entry not ready."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_exception_irm_kmi_api.refresh_forecasts_coord.call_count == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,99 @@
"""Test for the weather entity of the IRM KMI integration."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.freeze_time("2023-12-28T15:30:00+01:00")
async def test_weather_nl(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api_nl: AsyncMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test weather with forecast from the Netherland."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
"forecast_type",
["daily", "hourly"],
)
async def test_forecast_service(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_irm_kmi_api_nl: AsyncMock,
mock_config_entry: MockConfigEntry,
forecast_type: str,
) -> None:
"""Test multiple forecast."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_ENTITY_ID: "weather.home",
"type": forecast_type,
},
blocking=True,
return_response=True,
)
assert response == snapshot
@pytest.mark.freeze_time("2024-01-21T14:15:00+01:00")
@pytest.mark.parametrize(
"forecast_type",
["daily", "hourly"],
)
async def test_weather_higher_temp_at_night(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_irm_kmi_api_high_low_temp: AsyncMock,
forecast_type: str,
) -> None:
"""Test that the templow is always lower than temperature, even when API returns the opposite."""
# Test case for https://github.com/jdejaegh/irm-kmi-ha/issues/8
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_ENTITY_ID: "weather.home",
"type": forecast_type,
},
blocking=True,
return_response=True,
)
for forecast in response["weather.home"]["forecast"]:
assert (
forecast.get("native_temperature") is None
or forecast.get("native_templow") is None
or forecast["native_temperature"] >= forecast["native_templow"]
)