diff --git a/.coveragerc b/.coveragerc index 02632bba1a6..df9d59c61df 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 398d7e60f5e..2b665a16856 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py new file mode 100644 index 00000000000..24b862433bd --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -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 diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py new file mode 100644 index 00000000000..85c1acbb807 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/weatherflow_cloud/const.py b/homeassistant/components/weatherflow_cloud/const.py new file mode 100644 index 00000000000..73245346b50 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/const.py @@ -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" diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py new file mode 100644 index 00000000000..7b9ddaafaae --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -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 diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json new file mode 100644 index 00000000000..2dd4e9ddcd1 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -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"] +} diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json new file mode 100644 index 00000000000..782b0dcf960 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py new file mode 100644 index 00000000000..b4ed6a3a9d8 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -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] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 305804a624a..55d77e26336 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -585,6 +585,7 @@ FLOWS = { "watttime", "waze_travel_time", "weatherflow", + "weatherflow_cloud", "weatherkit", "webmin", "webostv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f04689fc7c7..6b6c41e412c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index d0bf324e617..193aa2daab0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6af35ae5ac9..6e3046dbe45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/weatherflow_cloud/__init__.py b/tests/components/weatherflow_cloud/__init__.py new file mode 100644 index 00000000000..c251e7868cc --- /dev/null +++ b/tests/components/weatherflow_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeatherflowCloud integration.""" diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py new file mode 100644 index 00000000000..45ad80541f7 --- /dev/null +++ b/tests/components/weatherflow_cloud/conftest.py @@ -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 diff --git a/tests/components/weatherflow_cloud/test_config_flow.py b/tests/components/weatherflow_cloud/test_config_flow.py new file mode 100644 index 00000000000..9a6c64fc32c --- /dev/null +++ b/tests/components/weatherflow_cloud/test_config_flow.py @@ -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