WeatherFlow Forecast (REST API) (#106615)

* rebase off dev

* Update homeassistant/components/weatherflow_cloud/const.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Addressing 1st round of PR Comments

* Update homeassistant/components/weatherflow_cloud/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* addressing PR Comments

* fixing last comment that i can see

* Update homeassistant/components/weatherflow_cloud/coordinator.py

OOPS

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/weatherflow_cloud/weather.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* switching to station id

* Update homeassistant/components/weatherflow_cloud/strings.json

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* addressing PR

* Updated tests to be better

* Updated tests accordingly

* REAuth flow and tests added

* Update homeassistant/components/weatherflow_cloud/strings.json

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/weatherflow_cloud/coordinator.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Addressing PR comments

* Apply suggestions from code review

* ruff fix

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Jeef 2024-02-26 14:40:21 -07:00 committed by GitHub
parent b6393cc3a0
commit bc20e7900c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 532 additions and 0 deletions

View File

@ -1582,6 +1582,10 @@ omit =
homeassistant/components/weatherflow/__init__.py
homeassistant/components/weatherflow/const.py
homeassistant/components/weatherflow/sensor.py
homeassistant/components/weatherflow_cloud/__init__.py
homeassistant/components/weatherflow_cloud/const.py
homeassistant/components/weatherflow_cloud/coordinator.py
homeassistant/components/weatherflow_cloud/weather.py
homeassistant/components/webmin/sensor.py
homeassistant/components/wiffi/__init__.py
homeassistant/components/wiffi/binary_sensor.py

View File

@ -1509,6 +1509,8 @@ build.json @home-assistant/supervisor
/tests/components/weather/ @home-assistant/core
/homeassistant/components/weatherflow/ @natekspencer @jeeftor
/tests/components/weatherflow/ @natekspencer @jeeftor
/homeassistant/components/weatherflow_cloud/ @jeeftor
/tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
/homeassistant/components/webhook/ @home-assistant/core

View File

@ -0,0 +1,34 @@
"""The WeatherflowCloud integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WeatherFlowCloud from a config entry."""
data_coordinator = WeatherFlowCloudDataUpdateCoordinator(
hass=hass,
api_token=entry.data[CONF_API_TOKEN],
)
await data_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,79 @@
"""Config flow for WeatherflowCloud integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiohttp import ClientResponseError
import voluptuous as vol
from weatherflow4py.api import WeatherFlowRestAPI
from homeassistant import config_entries
from homeassistant.const import CONF_API_TOKEN
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
async def _validate_api_token(api_token: str) -> dict[str, Any]:
"""Validate the API token."""
try:
async with WeatherFlowRestAPI(api_token) as api:
await api.async_get_stations()
except ClientResponseError as err:
if err.status == 401:
return {"base": "invalid_api_key"}
return {"base": "cannot_connect"}
return {}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for WeatherFlowCloud."""
VERSION = 1
async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
"""Handle a flow for reauth."""
errors = {}
if user_input is not None:
api_token = user_input[CONF_API_TOKEN]
errors = await _validate_api_token(api_token)
if not errors:
# Update the existing entry and abort
if existing_entry := self.hass.config_entries.async_get_entry(
self.context["entry_id"]
):
return self.async_update_reload_and_abort(
existing_entry,
data={CONF_API_TOKEN: api_token},
reason="reauth_successful",
)
return self.async_show_form(
step_id="reauth",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
api_token = user_input[CONF_API_TOKEN]
errors = await _validate_api_token(api_token)
if not errors:
return self.async_create_entry(
title="Weatherflow REST",
data={CONF_API_TOKEN: api_token},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)

View File

@ -0,0 +1,8 @@
"""Constants for the WeatherflowCloud integration."""
import logging
DOMAIN = "weatherflow_cloud"
LOGGER = logging.getLogger(__package__)
ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api"
MANUFACTURER = "WeatherFlow"

View File

@ -0,0 +1,39 @@
"""Data coordinator for WeatherFlow Cloud Data."""
from datetime import timedelta
from aiohttp import ClientResponseError
from weatherflow4py.api import WeatherFlowRestAPI
from weatherflow4py.models.unified import WeatherFlowData
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
class WeatherFlowCloudDataUpdateCoordinator(
DataUpdateCoordinator[dict[int, WeatherFlowData]]
):
"""Class to manage fetching REST Based WeatherFlow Forecast data."""
def __init__(self, hass: HomeAssistant, api_token: str) -> None:
"""Initialize global WeatherFlow forecast data updater."""
self.weather_api = WeatherFlowRestAPI(api_token=api_token)
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=15),
)
async def _async_update_data(self) -> dict[int, WeatherFlowData]:
"""Fetch data from WeatherFlow Forecast."""
try:
async with self.weather_api:
return await self.weather_api.get_all_data()
except ClientResponseError as err:
if err.status == 401:
raise ConfigEntryAuthFailed(err) from err
raise UpdateFailed(f"Update failed: {err}") from err

View File

@ -0,0 +1,9 @@
{
"domain": "weatherflow_cloud",
"name": "WeatherflowCloud",
"codeowners": ["@jeeftor"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
"iot_class": "cloud_polling",
"requirements": ["weatherflow4py==0.1.11"]
}

View File

@ -0,0 +1,27 @@
{
"config": {
"step": {
"user": {
"description": "Set up a WeatherFlow Forecast Station",
"data": {
"api_token": "Personal api token"
}
},
"reauth": {
"description": "Reauthenticate with WeatherFlow",
"data": {
"api_token": "[%key:component::weatherflow_cloud::config::step::user::data::api_token%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@ -0,0 +1,139 @@
"""Support for WeatherFlow Forecast weather service."""
from __future__ import annotations
from weatherflow4py.models.unified import WeatherFlowData
from homeassistant.components.weather import (
Forecast,
SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
async_add_entities(
[
WeatherFlowWeather(coordinator, station_id=station_id)
for station_id, data in coordinator.data.items()
]
)
class WeatherFlowWeather(
SingleCoordinatorWeatherEntity[WeatherFlowCloudDataUpdateCoordinator]
):
"""Implementation of a WeatherFlow weather condition."""
_attr_attribution = ATTR_ATTRIBUTION
_attr_has_entity_name = True
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_native_pressure_unit = UnitOfPressure.MBAR
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
_attr_name = None
def __init__(
self,
coordinator: WeatherFlowCloudDataUpdateCoordinator,
station_id: int,
) -> None:
"""Initialise the platform with a data instance and station."""
super().__init__(coordinator)
self.station_id = station_id
self._attr_unique_id = f"weatherflow_forecast_{station_id}"
self._attr_device_info = DeviceInfo(
name=self.local_data.station.name,
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, f"{station_id}")},
manufacturer=MANUFACTURER,
configuration_url=f"https://tempestwx.com/station/{station_id}/grid",
)
@property
def local_data(self) -> WeatherFlowData:
"""Return the local weather data object for this station."""
return self.coordinator.data[self.station_id]
@property
def condition(self) -> str | None:
"""Return current condition - required property."""
return self.local_data.weather.current_conditions.icon.ha_icon
@property
def native_temperature(self) -> float | None:
"""Return the temperature."""
return self.local_data.weather.current_conditions.air_temperature
@property
def native_pressure(self) -> float | None:
"""Return the Air Pressure @ Station."""
return self.local_data.weather.current_conditions.station_pressure
#
@property
def humidity(self) -> float | None:
"""Return the humidity."""
return self.local_data.weather.current_conditions.relative_humidity
@property
def native_wind_speed(self) -> float | None:
"""Return the wind speed."""
return self.local_data.weather.current_conditions.wind_avg
@property
def wind_bearing(self) -> float | str | None:
"""Return the wind direction."""
return self.local_data.weather.current_conditions.wind_direction
@property
def native_wind_gust_speed(self) -> float | None:
"""Return the wind gust speed in native units."""
return self.local_data.weather.current_conditions.wind_gust
@property
def native_dew_point(self) -> float | None:
"""Return dew point."""
return self.local_data.weather.current_conditions.dew_point
@property
def uv_index(self) -> float | None:
"""Return UV Index."""
return self.local_data.weather.current_conditions.uv
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
return [x.ha_forecast for x in self.local_data.weather.forecast.daily]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
return [x.ha_forecast for x in self.local_data.weather.forecast.hourly]

View File

@ -585,6 +585,7 @@ FLOWS = {
"watttime",
"waze_travel_time",
"weatherflow",
"weatherflow_cloud",
"weatherkit",
"webmin",
"webostv",

View File

@ -6657,6 +6657,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"weatherflow_cloud": {
"name": "WeatherflowCloud",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"webhook": {
"name": "Webhook",
"integration_type": "hub",

View File

@ -2835,6 +2835,9 @@ watchdog==2.3.1
# homeassistant.components.waterfurnace
waterfurnace==1.1.0
# homeassistant.components.weatherflow_cloud
weatherflow4py==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.1

View File

@ -2170,6 +2170,9 @@ wallbox==0.6.0
# homeassistant.components.folder_watcher
watchdog==2.3.1
# homeassistant.components.weatherflow_cloud
weatherflow4py==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.1

View File

@ -0,0 +1 @@
"""Tests for the WeatherflowCloud integration."""

View File

@ -0,0 +1,57 @@
"""Common fixtures for the WeatherflowCloud tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientResponseError
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.weatherflow_cloud.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_get_stations() -> Generator[AsyncMock, None, None]:
"""Mock get_stations with a sequence of responses."""
side_effects = [
True,
]
with patch(
"weatherflow4py.api.WeatherFlowRestAPI.async_get_stations",
side_effect=side_effects,
) as mock_get_stations:
yield mock_get_stations
@pytest.fixture
def mock_get_stations_500_error() -> Generator[AsyncMock, None, None]:
"""Mock get_stations with a sequence of responses."""
side_effects = [
ClientResponseError(Mock(), (), status=500),
True,
]
with patch(
"weatherflow4py.api.WeatherFlowRestAPI.async_get_stations",
side_effect=side_effects,
) as mock_get_stations:
yield mock_get_stations
@pytest.fixture
def mock_get_stations_401_error() -> Generator[AsyncMock, None, None]:
"""Mock get_stations with a sequence of responses."""
side_effects = [ClientResponseError(Mock(), (), status=401), True, True, True]
with patch(
"weatherflow4py.api.WeatherFlowRestAPI.async_get_stations",
side_effect=side_effects,
) as mock_get_stations:
yield mock_get_stations

View File

@ -0,0 +1,120 @@
"""Test the WeatherflowCloud config flow."""
import pytest
from homeassistant import config_entries
from homeassistant.components.weatherflow_cloud.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_config(hass: HomeAssistant, mock_get_stations) -> None:
"""Test the config flow for the ideal case."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_TOKEN: "string",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
async def test_config_flow_abort(hass: HomeAssistant, mock_get_stations) -> None:
"""Test an abort case."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_API_TOKEN: "same_same",
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_TOKEN: "same_same",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"mock_fixture, expected_error", # noqa: PT006
[
("mock_get_stations_500_error", "cannot_connect"),
("mock_get_stations_401_error", "invalid_api_key"),
],
)
async def test_config_errors(
hass: HomeAssistant, request, expected_error, mock_fixture, mock_get_stations
) -> None:
"""Test the config flow for various error scenarios."""
mock_get_stations_bad = request.getfixturevalue(mock_fixture)
with mock_get_stations_bad:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "string"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
with mock_get_stations:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "string"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
async def test_reauth(hass: HomeAssistant, mock_get_stations_401_error) -> None:
"""Test a reauth_flow."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_API_TOKEN: "same_same",
},
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.SETUP_ERROR
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, data=None
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id},
data={CONF_API_TOKEN: "SAME_SAME"},
)
assert result["reason"] == "reauth_successful"
assert result["type"] == FlowResultType.ABORT