Add Open-Meteo integration (second attempt) (#61742)
parent
fe08668a87
commit
0dbd948867
|
@ -771,6 +771,7 @@ omit =
|
|||
homeassistant/components/onvif/event.py
|
||||
homeassistant/components/onvif/parsers.py
|
||||
homeassistant/components/onvif/sensor.py
|
||||
homeassistant/components/open_meteo/weather.py
|
||||
homeassistant/components/opencv/*
|
||||
homeassistant/components/openevse/sensor.py
|
||||
homeassistant/components/openexchangerates/sensor.py
|
||||
|
|
|
@ -17,7 +17,7 @@ repos:
|
|||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba,haa
|
||||
- --ignore-words-list=hass,alot,datas,dof,dur,ether,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,rime,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort,ba,haa
|
||||
- --skip="./.*,*.csv,*.json"
|
||||
- --quiet-level=2
|
||||
exclude_types: [csv, json]
|
||||
|
|
|
@ -95,6 +95,7 @@ homeassistant.components.notify.*
|
|||
homeassistant.components.notion.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.onewire.*
|
||||
homeassistant.components.open_meteo.*
|
||||
homeassistant.components.openuv.*
|
||||
homeassistant.components.persistent_notification.*
|
||||
homeassistant.components.pi_hole.*
|
||||
|
|
|
@ -379,6 +379,7 @@ homeassistant/components/onboarding/* @home-assistant/core
|
|||
homeassistant/components/ondilo_ico/* @JeromeHXP
|
||||
homeassistant/components/onewire/* @garbled1 @epenet
|
||||
homeassistant/components/onvif/* @hunterjm
|
||||
homeassistant/components/open_meteo/* @frenck
|
||||
homeassistant/components/openerz/* @misialq
|
||||
homeassistant/components/opengarage/* @danielhiversen
|
||||
homeassistant/components/openhome/* @bazwilliams
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
"""Support for Open-Meteo."""
|
||||
from __future__ import annotations
|
||||
|
||||
from open_meteo import Forecast, OpenMeteo, OpenMeteoError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
PLATFORMS = [Platform.WEATHER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Open-Meteo from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
open_meteo = OpenMeteo(session=session)
|
||||
|
||||
async def async_update_forecast() -> Forecast:
|
||||
zone = hass.states.get(entry.data[CONF_ZONE])
|
||||
if zone is None:
|
||||
raise UpdateFailed(f"Zone '{entry.data[CONF_ZONE]}' not found")
|
||||
|
||||
try:
|
||||
return await open_meteo.forecast(
|
||||
latitude=zone.attributes[ATTR_LATITUDE],
|
||||
longitude=zone.attributes[ATTR_LONGITUDE],
|
||||
current_weather=True,
|
||||
)
|
||||
except OpenMeteoError as err:
|
||||
raise UpdateFailed("Open-Meteo API communication error") from err
|
||||
|
||||
coordinator: DataUpdateCoordinator[Forecast] = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN}_{entry.data[CONF_ZONE]}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_method=async_update_forecast,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Open-Meteo config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
del hass.data[DOMAIN][entry.entry_id]
|
||||
return unload_ok
|
|
@ -0,0 +1,54 @@
|
|||
"""Config flow to configure the Open-Meteo integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, ENTITY_ID_HOME
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_ZONE
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class OpenMeteoFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for OpenMeteo."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_ZONE])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
zone = self.hass.states.get(user_input[CONF_ZONE])
|
||||
return self.async_create_entry(
|
||||
title=zone.name if zone else "Open-Meteo",
|
||||
data={CONF_ZONE: user_input[CONF_ZONE]},
|
||||
)
|
||||
|
||||
zones: dict[str, str] = {
|
||||
entity_id: state.name
|
||||
for entity_id in self.hass.states.async_entity_ids(ZONE_DOMAIN)
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
}
|
||||
zones = dict(sorted(zones.items(), key=lambda x: x[1], reverse=True))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE): vol.In(
|
||||
{
|
||||
ENTITY_ID_HOME: zones.pop(ENTITY_ID_HOME),
|
||||
**zones,
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
|
@ -0,0 +1,56 @@
|
|||
"""Constants for the Open-Meteo integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_FOG,
|
||||
ATTR_CONDITION_LIGHTNING,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
ATTR_CONDITION_RAINY,
|
||||
ATTR_CONDITION_SNOWY,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
)
|
||||
|
||||
DOMAIN: Final = "open_meteo"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
SCAN_INTERVAL = timedelta(minutes=30)
|
||||
|
||||
# World Meteorological Organization Weather Code
|
||||
# mapped to Home Assistant weather conditions.
|
||||
# https://www.weather.gov/tg/wmo
|
||||
WMO_TO_HA_CONDITION_MAP = {
|
||||
0: ATTR_CONDITION_SUNNY, # Clear sky
|
||||
1: ATTR_CONDITION_SUNNY, # Mainly clear
|
||||
2: ATTR_CONDITION_PARTLYCLOUDY, # Partly cloudy
|
||||
3: ATTR_CONDITION_CLOUDY, # Overcast
|
||||
45: ATTR_CONDITION_FOG, # Fog
|
||||
48: ATTR_CONDITION_FOG, # Depositing rime fog
|
||||
51: ATTR_CONDITION_RAINY, # Drizzle: Light intensity
|
||||
53: ATTR_CONDITION_RAINY, # Drizzle: Moderate intensity
|
||||
55: ATTR_CONDITION_RAINY, # Drizzle: Dense intensity
|
||||
56: ATTR_CONDITION_RAINY, # Freezing Drizzle: Light intensity
|
||||
57: ATTR_CONDITION_RAINY, # Freezing Drizzle: Dense intensity
|
||||
61: ATTR_CONDITION_RAINY, # Rain: Slight intensity
|
||||
63: ATTR_CONDITION_RAINY, # Rain: Moderate intensity
|
||||
65: ATTR_CONDITION_POURING, # Rain: Heavy intensity
|
||||
66: ATTR_CONDITION_RAINY, # Freezing Rain: Light intensity
|
||||
67: ATTR_CONDITION_POURING, # Freezing Rain: Heavy intensity
|
||||
71: ATTR_CONDITION_SNOWY, # Snow fall: Slight intensity
|
||||
73: ATTR_CONDITION_SNOWY, # Snow fall: Moderate intensity
|
||||
75: ATTR_CONDITION_SNOWY, # Snow fall: Heavy intensity
|
||||
77: ATTR_CONDITION_SNOWY, # Snow grains
|
||||
80: ATTR_CONDITION_RAINY, # Rain showers: Slight intensity
|
||||
81: ATTR_CONDITION_RAINY, # Rain showers: Moderate intensity
|
||||
82: ATTR_CONDITION_POURING, # Rain showers: Violent intensity
|
||||
85: ATTR_CONDITION_SNOWY, # Snow showers: Slight intensity
|
||||
86: ATTR_CONDITION_SNOWY, # Snow showers: Heavy intensity
|
||||
95: ATTR_CONDITION_LIGHTNING, # Thunderstorm: Slight and moderate intensity
|
||||
96: ATTR_CONDITION_LIGHTNING, # Thunderstorm with slight hail
|
||||
99: ATTR_CONDITION_LIGHTNING, # Thunderstorm with heavy hail
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"domain": "open_meteo",
|
||||
"name": "Open-Meteo",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/open_meteo",
|
||||
"requirements": ["open-meteo==0.2.1"],
|
||||
"dependencies": ["zone"],
|
||||
"codeowners": ["@frenck"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Select location to use for weather forecasting",
|
||||
"data": {
|
||||
"zone": "Zone"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"zone": "Zone"
|
||||
},
|
||||
"description": "Select location to use for weather forecasting"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
"""Support for Open-Meteo weather."""
|
||||
from __future__ import annotations
|
||||
|
||||
from open_meteo import Forecast as OpenMeteoForecast
|
||||
|
||||
from homeassistant.components.weather import WeatherEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Open-Meteo weather entity based on a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([OpenMeteoWeatherEntity(entry=entry, coordinator=coordinator)])
|
||||
|
||||
|
||||
class OpenMeteoWeatherEntity(CoordinatorEntity, WeatherEntity):
|
||||
"""Defines an Open-Meteo weather entity."""
|
||||
|
||||
_attr_temperature_unit = TEMP_CELSIUS
|
||||
coordinator: DataUpdateCoordinator[OpenMeteoForecast]
|
||||
|
||||
def __init__(
|
||||
self, *, entry: ConfigEntry, coordinator: DataUpdateCoordinator
|
||||
) -> None:
|
||||
"""Initialize Open-Meteo weather entity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_name = entry.title
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Open-Meteo",
|
||||
name=entry.title,
|
||||
)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
if not self.coordinator.data.current_weather:
|
||||
return None
|
||||
return WMO_TO_HA_CONDITION_MAP.get(
|
||||
self.coordinator.data.current_weather.weather_code
|
||||
)
|
||||
|
||||
@property
|
||||
def temperature(self) -> float | None:
|
||||
"""Return the platform temperature."""
|
||||
if not self.coordinator.data.current_weather:
|
||||
return None
|
||||
return self.coordinator.data.current_weather.temperature
|
||||
|
||||
@property
|
||||
def wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
if not self.coordinator.data.current_weather:
|
||||
return None
|
||||
return self.coordinator.data.current_weather.wind_speed
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the wind bearing."""
|
||||
if not self.coordinator.data.current_weather:
|
||||
return None
|
||||
return self.coordinator.data.current_weather.wind_direction
|
|
@ -218,6 +218,7 @@ FLOWS = [
|
|||
"ondilo_ico",
|
||||
"onewire",
|
||||
"onvif",
|
||||
"open_meteo",
|
||||
"opengarage",
|
||||
"opentherm_gw",
|
||||
"openuv",
|
||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -1056,6 +1056,17 @@ no_implicit_optional = true
|
|||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.open_meteo.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.openuv.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
|
|
@ -1140,6 +1140,9 @@ onvif-zeep-async==1.2.0
|
|||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
# homeassistant.components.open_meteo
|
||||
open-meteo==0.2.1
|
||||
|
||||
# homeassistant.components.opencv
|
||||
# opencv-python-headless==4.5.2.54
|
||||
|
||||
|
|
|
@ -705,6 +705,9 @@ onvif-zeep-async==1.2.0
|
|||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
|
||||
# homeassistant.components.open_meteo
|
||||
open-meteo==0.2.1
|
||||
|
||||
# homeassistant.components.openerz
|
||||
openerz-api==0.1.0
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the Open-Meteo integration."""
|
|
@ -0,0 +1,49 @@
|
|||
"""Fixtures for the Open-Meteo integration tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from open_meteo import Forecast
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.open_meteo.const import DOMAIN
|
||||
from homeassistant.const import CONF_ZONE
|
||||
|
||||
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_ZONE: "zone.home"},
|
||||
unique_id="zone.home",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[None, None, None]:
|
||||
"""Mock setting up a config entry."""
|
||||
with patch(
|
||||
"homeassistant.components.open_meteo.async_setup_entry", return_value=True
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]:
|
||||
"""Return a mocked Open-Meteo client."""
|
||||
fixture: str = "forecast.json"
|
||||
if hasattr(request, "param") and request.param:
|
||||
fixture = request.param
|
||||
|
||||
forecast = Forecast.parse_raw(load_fixture(fixture, DOMAIN))
|
||||
with patch(
|
||||
"homeassistant.components.open_meteo.OpenMeteo", autospec=True
|
||||
) as open_meteo_mock:
|
||||
open_meteo = open_meteo_mock.return_value
|
||||
open_meteo.forecast.return_value = forecast
|
||||
yield open_meteo
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,33 @@
|
|||
"""Tests for the Open-Meteo config flow."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeassistant.components.open_meteo.const import DOMAIN
|
||||
from homeassistant.components.zone import ENTITY_ID_HOME
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_ZONE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
|
||||
|
||||
|
||||
async def test_full_user_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: 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") == RESULT_TYPE_FORM
|
||||
assert result.get("step_id") == SOURCE_USER
|
||||
assert "flow_id" in result
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_ZONE: ENTITY_ID_HOME},
|
||||
)
|
||||
|
||||
assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2.get("title") == "test home"
|
||||
assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME}
|
|
@ -0,0 +1,68 @@
|
|||
"""Tests for the Open-Meteo integration."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from open_meteo import OpenMeteoConnectionError
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from homeassistant.components.open_meteo.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ZONE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload_config_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_open_meteo: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the Open-Meteo 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
|
||||
|
||||
|
||||
@patch(
|
||||
"homeassistant.components.open_meteo.OpenMeteo.forecast",
|
||||
side_effect=OpenMeteoConnectionError,
|
||||
)
|
||||
async def test_config_entry_not_ready(
|
||||
mock_forecast: MagicMock,
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the Open-Meteo 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_forecast.call_count == 1
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_config_entry_zone_removed(
|
||||
hass: HomeAssistant,
|
||||
caplog: LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test the Open-Meteo configuration entry not ready."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
title="My Castle",
|
||||
domain=DOMAIN,
|
||||
data={CONF_ZONE: "zone.castle"},
|
||||
unique_id="zone.castle",
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert "Zone 'zone.castle' not found" in caplog.text
|
Loading…
Reference in New Issue