diff --git a/.strict-typing b/.strict-typing index 6182837d15e..06b4fa56fb5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -231,6 +231,7 @@ homeassistant.components.google_cloud.* homeassistant.components.google_drive.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* +homeassistant.components.google_weather.* homeassistant.components.govee_ble.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* diff --git a/CODEOWNERS b/CODEOWNERS index daf4dd0d0ae..695537acacf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -607,6 +607,8 @@ build.json @home-assistant/supervisor /tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger +/homeassistant/components/google_weather/ @tronikos +/tests/components/google_weather/ @tronikos /homeassistant/components/govee_ble/ @bdraco /tests/components/govee_ble/ @bdraco /homeassistant/components/govee_light_local/ @Galorhallen diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 872cfc0aac5..b7ace2cf461 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -15,6 +15,7 @@ "google_tasks", "google_translate", "google_travel_time", + "google_weather", "google_wifi", "google", "nest", diff --git a/homeassistant/components/google_weather/__init__.py b/homeassistant/components/google_weather/__init__.py new file mode 100644 index 00000000000..97d64ff676f --- /dev/null +++ b/homeassistant/components/google_weather/__init__.py @@ -0,0 +1,84 @@ +"""The Google Weather integration.""" + +from __future__ import annotations + +import asyncio + +from google_weather_api import GoogleWeatherApi + +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_REFERRER +from .coordinator import ( + GoogleWeatherConfigEntry, + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, + GoogleWeatherRuntimeData, + GoogleWeatherSubEntryRuntimeData, +) + +_PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> bool: + """Set up Google Weather from a config entry.""" + + api = GoogleWeatherApi( + session=async_get_clientsession(hass), + api_key=entry.data[CONF_API_KEY], + referrer=entry.data.get(CONF_REFERRER), + language_code=hass.config.language, + ) + subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {} + for subentry in entry.subentries.values(): + subentry_runtime_data = GoogleWeatherSubEntryRuntimeData( + coordinator_observation=GoogleWeatherCurrentConditionsCoordinator( + hass, entry, subentry, api + ), + coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator( + hass, entry, subentry, api + ), + coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator( + hass, entry, subentry, api + ), + ) + subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data + tasks = [ + coro + for subentry_runtime_data in subentries_runtime_data.values() + for coro in ( + subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(), + subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(), + subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(), + ) + ] + await asyncio.gather(*tasks) + entry.runtime_data = GoogleWeatherRuntimeData( + api=api, + subentries_runtime_data=subentries_runtime_data, + ) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + +async def async_update_options( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/google_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py new file mode 100644 index 00000000000..661146ab01d --- /dev/null +++ b/homeassistant/components/google_weather/config_flow.py @@ -0,0 +1,198 @@ +"""Config flow for the Google Weather integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig + +from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(SECTION_API_KEY_OPTIONS): section( + vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True} + ), + } +) + + +async def _validate_input( + user_input: dict[str, Any], + api: GoogleWeatherApi, + errors: dict[str, str], + description_placeholders: dict[str, str], +) -> bool: + try: + await api.async_get_current_conditions( + latitude=user_input[CONF_LOCATION][CONF_LATITUDE], + longitude=user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except GoogleWeatherApiError as err: + errors["base"] = "cannot_connect" + description_placeholders["error_message"] = str(err) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return True + return False + + +def _get_location_schema(hass: HomeAssistant) -> vol.Schema: + """Return the schema for a location with default values from the hass config.""" + return vol.Schema( + { + vol.Required(CONF_NAME, default=hass.config.location_name): str, + vol.Required( + CONF_LOCATION, + default={ + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ): LocationSelector(LocationSelectorConfig(radius=False)), + } + ) + + +def _is_location_already_configured( + hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4 +) -> bool: + """Check if the location is already configured.""" + for entry in hass.config_entries.async_entries(DOMAIN): + for subentry in entry.subentries.values(): + # A more accurate way is to use the haversine formula, but for simplicity + # we use a simple distance check. The epsilon value is small anyway. + # This is mostly to capture cases where the user has slightly moved the location pin. + if ( + abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon + and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE]) + <= epsilon + ): + return True + return False + + +class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Weather.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + } + if user_input is not None: + api_key = user_input[CONF_API_KEY] + referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) + self._async_abort_entries_match({CONF_API_KEY: api_key}) + if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + return self.async_abort(reason="already_configured") + api = GoogleWeatherApi( + session=async_get_clientsession(self.hass), + api_key=api_key, + referrer=referrer, + language_code=self.hass.config.language, + ) + if await _validate_input(user_input, api, errors, description_placeholders): + return self.async_create_entry( + title="Google Weather", + data={ + CONF_API_KEY: api_key, + CONF_REFERRER: referrer, + }, + subentries=[ + { + "subentry_type": "location", + "data": user_input[CONF_LOCATION], + "title": user_input[CONF_NAME], + "unique_id": None, + }, + ], + ) + else: + user_input = {} + schema = STEP_USER_DATA_SCHEMA.schema.copy() + schema.update(_get_location_schema(self.hass).schema) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), user_input + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"location": LocationSubentryFlowHandler} + + +class LocationSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for location.""" + + async def async_step_location( + self, + user_input: dict[str, Any] | None = None, + ) -> SubentryFlowResult: + """Handle the location step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + if user_input is not None: + if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + return self.async_abort(reason="already_configured") + api: GoogleWeatherApi = self._get_entry().runtime_data.api + if await _validate_input(user_input, api, errors, description_placeholders): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input[CONF_LOCATION], + ) + else: + user_input = {} + return self.async_show_form( + step_id="location", + data_schema=self.add_suggested_values_to_schema( + _get_location_schema(self.hass), user_input + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + async_step_user = async_step_location diff --git a/homeassistant/components/google_weather/const.py b/homeassistant/components/google_weather/const.py new file mode 100644 index 00000000000..94c3e67bdbb --- /dev/null +++ b/homeassistant/components/google_weather/const.py @@ -0,0 +1,8 @@ +"""Constants for the Google Weather integration.""" + +from typing import Final + +DOMAIN = "google_weather" + +SECTION_API_KEY_OPTIONS: Final = "api_key_options" +CONF_REFERRER: Final = "referrer" diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py new file mode 100644 index 00000000000..3f81a8a31e9 --- /dev/null +++ b/homeassistant/components/google_weather/coordinator.py @@ -0,0 +1,169 @@ +"""The Google Weather coordinator.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TypeVar + +from google_weather_api import ( + CurrentConditionsResponse, + DailyForecastResponse, + GoogleWeatherApi, + GoogleWeatherApiError, + HourlyForecastResponse, +) + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) + +_LOGGER = logging.getLogger(__name__) + +T = TypeVar( + "T", + bound=( + CurrentConditionsResponse + | DailyForecastResponse + | HourlyForecastResponse + | None + ), +) + + +@dataclass +class GoogleWeatherSubEntryRuntimeData: + """Runtime data for a Google Weather sub-entry.""" + + coordinator_observation: GoogleWeatherCurrentConditionsCoordinator + coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator + coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator + + +@dataclass +class GoogleWeatherRuntimeData: + """Runtime data for the Google Weather integration.""" + + api: GoogleWeatherApi + subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] + + +type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData] + + +class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]): + """Base class for Google Weather coordinators.""" + + config_entry: GoogleWeatherConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + data_type_name: str, + update_interval: timedelta, + api_method: Callable[..., Awaitable[T]], + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"Google Weather {data_type_name} coordinator for {subentry.title}", + update_interval=update_interval, + ) + self.subentry = subentry + self._data_type_name = data_type_name + self._api_method = api_method + + async def _async_update_data(self) -> T: + """Fetch data from API and handle errors.""" + try: + return await self._api_method( + self.subentry.data[CONF_LATITUDE], + self.subentry.data[CONF_LONGITUDE], + ) + except GoogleWeatherApiError as err: + _LOGGER.error( + "Error fetching %s for %s: %s", + self._data_type_name, + self.subentry.title, + err, + ) + raise UpdateFailed(f"Error fetching {self._data_type_name}") from err + + +class GoogleWeatherCurrentConditionsCoordinator( + GoogleWeatherBaseCoordinator[CurrentConditionsResponse] +): + """Handle fetching current weather conditions.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "current weather conditions", + timedelta(minutes=15), + api.async_get_current_conditions, + ) + + +class GoogleWeatherDailyForecastCoordinator( + GoogleWeatherBaseCoordinator[DailyForecastResponse] +): + """Handle fetching daily weather forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "daily weather forecast", + timedelta(hours=1), + api.async_get_daily_forecast, + ) + + +class GoogleWeatherHourlyForecastCoordinator( + GoogleWeatherBaseCoordinator[HourlyForecastResponse] +): + """Handle fetching hourly weather forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "hourly weather forecast", + timedelta(hours=1), + api.async_get_hourly_forecast, + ) diff --git a/homeassistant/components/google_weather/entity.py b/homeassistant/components/google_weather/entity.py new file mode 100644 index 00000000000..2d6104d280f --- /dev/null +++ b/homeassistant/components/google_weather/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Google Weather.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .coordinator import GoogleWeatherConfigEntry + + +class GoogleWeatherBaseEntity(Entity): + """Base entity for all Google Weather entities.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry + ) -> None: + """Initialize base entity.""" + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Google", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json new file mode 100644 index 00000000000..fb7b63ebd5f --- /dev/null +++ b/homeassistant/components/google_weather/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_weather", + "name": "Google Weather", + "codeowners": ["@tronikos"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/google_weather", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["google_weather_api"], + "quality_scale": "bronze", + "requirements": ["python-google-weather-api==0.0.4"] +} diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml new file mode 100644 index 00000000000..946bcc9a0d3 --- /dev/null +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events subscribed. + 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 actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: N/A + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: N/A + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json new file mode 100644 index 00000000000..db0531f8015 --- /dev/null +++ b/homeassistant/components/google_weather/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "location": "[%key:common::config_flow::data::location%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API", + "location": "Location coordinates", + "name": "Location name" + }, + "description": "Get your API key from [here]({api_key_url}).", + "sections": { + "api_key_options": { + "data": { + "referrer": "HTTP referrer" + }, + "data_description": { + "referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})" + }, + "name": "Optional API key options" + } + } + } + } + }, + "config_subentries": { + "location": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." + }, + "entry_type": "Location", + "error": { + "cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add location" + }, + "step": { + "location": { + "data": { + "location": "[%key:common::config_flow::data::location%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "location": "[%key:component::google_weather::config::step::user::data_description::location%]", + "name": "[%key:component::google_weather::config::step::user::data_description::name%]" + } + } + } + } + } +} diff --git a/homeassistant/components/google_weather/weather.py b/homeassistant/components/google_weather/weather.py new file mode 100644 index 00000000000..0c906abee40 --- /dev/null +++ b/homeassistant/components/google_weather/weather.py @@ -0,0 +1,366 @@ +"""Weather entity.""" + +from __future__ import annotations + +from google_weather_api import ( + DailyForecastResponse, + HourlyForecastResponse, + WeatherCondition, +) + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + CoordinatorWeatherEntity, + Forecast, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ( + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ( + GoogleWeatherConfigEntry, + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, +) +from .entity import GoogleWeatherBaseEntity + +PARALLEL_UPDATES = 0 + +# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons +# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition +_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = { + WeatherCondition.Type.TYPE_UNSPECIFIED: None, + WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY, + WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY, + WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, + WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY, + WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING, + WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING, + WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING, + WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING, + WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY, + WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL, + WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL, + WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY, +} + + +def _get_condition( + api_condition: WeatherCondition.Type, is_daytime: bool +) -> str | None: + """Map Google Weather condition to Home Assistant condition.""" + cond = _CONDITION_MAP[api_condition] + if cond == ATTR_CONDITION_SUNNY and not is_daytime: + return ATTR_CONDITION_CLEAR_NIGHT + return cond + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GoogleWeatherConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + for subentry in entry.subentries.values(): + async_add_entities( + [GoogleWeatherEntity(entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleWeatherEntity( + CoordinatorWeatherEntity[ + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, + GoogleWeatherDailyForecastCoordinator, + ], + GoogleWeatherBaseEntity, +): + """Representation of a Google Weather entity.""" + + _attr_attribution = "Data from Google Weather" + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_visibility_unit = UnitOfLength.KILOMETERS + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + + _attr_name = None + + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) + + def __init__( + self, + entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the weather entity.""" + subentry_runtime_data = entry.runtime_data.subentries_runtime_data[ + subentry.subentry_id + ] + super().__init__( + observation_coordinator=subentry_runtime_data.coordinator_observation, + daily_coordinator=subentry_runtime_data.coordinator_daily_forecast, + hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast, + twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast, + ) + GoogleWeatherBaseEntity.__init__(self, entry, subentry) + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return _get_condition( + self.coordinator.data.weather_condition.type, + self.coordinator.data.is_daytime, + ) + + @property + def native_temperature(self) -> float: + """Return the temperature.""" + return self.coordinator.data.temperature.degrees + + @property + def native_apparent_temperature(self) -> float: + """Return the apparent temperature.""" + return self.coordinator.data.feels_like_temperature.degrees + + @property + def native_dew_point(self) -> float: + """Return the dew point.""" + return self.coordinator.data.dew_point.degrees + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self.coordinator.data.relative_humidity + + @property + def uv_index(self) -> float: + """Return the UV index.""" + return float(self.coordinator.data.uv_index) + + @property + def native_pressure(self) -> float: + """Return the pressure.""" + return self.coordinator.data.air_pressure.mean_sea_level_millibars + + @property + def native_wind_gust_speed(self) -> float: + """Return the wind gust speed.""" + return self.coordinator.data.wind.gust.value + + @property + def native_wind_speed(self) -> float: + """Return the wind speed.""" + return self.coordinator.data.wind.speed.value + + @property + def wind_bearing(self) -> int: + """Return the wind bearing.""" + return self.coordinator.data.wind.direction.degrees + + @property + def native_visibility(self) -> float: + """Return the visibility.""" + return self.coordinator.data.visibility.distance + + @property + def cloud_coverage(self) -> float: + """Return the Cloud coverage in %.""" + return float(self.coordinator.data.cloud_cover) + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + coordinator = self.forecast_coordinators["daily"] + assert coordinator + daily_data = coordinator.data + assert isinstance(daily_data, DailyForecastResponse) + return [ + { + ATTR_FORECAST_CONDITION: _get_condition( + item.daytime_forecast.weather_condition.type, is_daytime=True + ), + ATTR_FORECAST_TIME: item.interval.start_time, + ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: max( + item.daytime_forecast.precipitation.probability.percent, + item.nighttime_forecast.precipitation.probability.percent, + ), + ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: ( + item.daytime_forecast.precipitation.qpf.quantity + + item.nighttime_forecast.precipitation.qpf.quantity + ), + ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees, + ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: ( + item.feels_like_max_temperature.degrees + ), + ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max( + item.daytime_forecast.wind.gust.value, + item.nighttime_forecast.wind.gust.value, + ), + ATTR_FORECAST_NATIVE_WIND_SPEED: max( + item.daytime_forecast.wind.speed.value, + item.nighttime_forecast.wind.speed.value, + ), + ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index, + } + for item in daily_data.forecast_days + ] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + coordinator = self.forecast_coordinators["hourly"] + assert coordinator + hourly_data = coordinator.data + assert isinstance(hourly_data, HourlyForecastResponse) + return [ + { + ATTR_FORECAST_CONDITION: _get_condition( + item.weather_condition.type, item.is_daytime + ), + ATTR_FORECAST_TIME: item.interval.start_time, + ATTR_FORECAST_HUMIDITY: item.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars, + ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value, + ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees, + ATTR_FORECAST_UV_INDEX: item.uv_index, + ATTR_FORECAST_IS_DAYTIME: item.is_daytime, + } + for item in hourly_data.forecast_hours + ] + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = self.forecast_coordinators["twice_daily"] + assert coordinator + daily_data = coordinator.data + assert isinstance(daily_data, DailyForecastResponse) + forecasts: list[Forecast] = [] + for item in daily_data.forecast_days: + # Process daytime forecast + day_forecast = item.daytime_forecast + forecasts.append( + { + ATTR_FORECAST_CONDITION: _get_condition( + day_forecast.weather_condition.type, is_daytime=True + ), + ATTR_FORECAST_TIME: day_forecast.interval.start_time, + ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value, + ATTR_FORECAST_UV_INDEX: day_forecast.uv_index, + ATTR_FORECAST_IS_DAYTIME: True, + } + ) + + # Process nighttime forecast + night_forecast = item.nighttime_forecast + forecasts.append( + { + ATTR_FORECAST_CONDITION: _get_condition( + night_forecast.weather_condition.type, is_daytime=False + ), + ATTR_FORECAST_TIME: night_forecast.interval.start_time, + ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value, + ATTR_FORECAST_UV_INDEX: night_forecast.uv_index, + ATTR_FORECAST_IS_DAYTIME: False, + } + ) + + return forecasts diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9c478ee8e7e..2e27f344ef1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -255,6 +255,7 @@ FLOWS = { "google_tasks", "google_translate", "google_travel_time", + "google_weather", "govee_ble", "govee_light_local", "gpsd", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 64cbfec6da9..6ad3ae487bf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2451,6 +2451,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "google_weather": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Weather" + }, "google_wifi": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index 8c26c82409f..fe18814bde0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2066,6 +2066,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_weather.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.govee_ble.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e47181769af..1bd9f9fb7d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2469,6 +2469,9 @@ python-gitlab==1.6.0 # homeassistant.components.google_drive python-google-drive-api==0.1.0 +# homeassistant.components.google_weather +python-google-weather-api==0.0.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a223325afc3..5f40743a25b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,6 +2044,9 @@ python-fullykiosk==0.0.14 # homeassistant.components.google_drive python-google-drive-api==0.1.0 +# homeassistant.components.google_weather +python-google-weather-api==0.0.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/tests/components/google_weather/__init__.py b/tests/components/google_weather/__init__.py new file mode 100644 index 00000000000..e13b9da1577 --- /dev/null +++ b/tests/components/google_weather/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Weather integration.""" diff --git a/tests/components/google_weather/conftest.py b/tests/components/google_weather/conftest.py new file mode 100644 index 00000000000..19ce293b429 --- /dev/null +++ b/tests/components/google_weather/conftest.py @@ -0,0 +1,83 @@ +"""Common fixtures for the Google Weather tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from google_weather_api import ( + CurrentConditionsResponse, + DailyForecastResponse, + HourlyForecastResponse, +) +import pytest + +from homeassistant.components.google_weather.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_weather.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + title="Google Weather", + domain=DOMAIN, + data={ + CONF_API_KEY: "test-api-key", + }, + subentries_data=[ + ConfigSubentryDataWithId( + data={ + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + subentry_type="location", + title="Home", + subentry_id="home-subentry-id", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_google_weather_api() -> Generator[AsyncMock]: + """Mock Google Weather API.""" + current_conditions = CurrentConditionsResponse.from_dict( + load_json_object_fixture("current_conditions.json", DOMAIN) + ) + daily_forecast = DailyForecastResponse.from_dict( + load_json_object_fixture("daily_forecast.json", DOMAIN) + ) + hourly_forecast = HourlyForecastResponse.from_dict( + load_json_object_fixture("hourly_forecast.json", DOMAIN) + ) + + with ( + patch( + "homeassistant.components.google_weather.GoogleWeatherApi", autospec=True + ) as mock_api, + patch( + "homeassistant.components.google_weather.config_flow.GoogleWeatherApi", + new=mock_api, + ), + ): + api = mock_api.return_value + api.async_get_current_conditions.return_value = current_conditions + api.async_get_daily_forecast.return_value = daily_forecast + api.async_get_hourly_forecast.return_value = hourly_forecast + + yield api diff --git a/tests/components/google_weather/fixtures/current_conditions.json b/tests/components/google_weather/fixtures/current_conditions.json new file mode 100644 index 00000000000..d2f81cc6451 --- /dev/null +++ b/tests/components/google_weather/fixtures/current_conditions.json @@ -0,0 +1,88 @@ +{ + "currentTime": "2025-01-28T22:04:12.025273178Z", + "timeZone": { + "id": "America/Los_Angeles" + }, + "isDaytime": true, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny", + "description": { + "text": "Sunny", + "languageCode": "en" + }, + "type": "CLEAR" + }, + "temperature": { + "degrees": 13.7, + "unit": "CELSIUS" + }, + "feelsLikeTemperature": { + "degrees": 13.1, + "unit": "CELSIUS" + }, + "dewPoint": { + "degrees": 1.1, + "unit": "CELSIUS" + }, + "heatIndex": { + "degrees": 13.7, + "unit": "CELSIUS" + }, + "windChill": { + "degrees": 13.1, + "unit": "CELSIUS" + }, + "relativeHumidity": 42, + "uvIndex": 1, + "precipitation": { + "probability": { + "percent": 0, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "airPressure": { + "meanSeaLevelMillibars": 1019.16 + }, + "wind": { + "direction": { + "degrees": 335, + "cardinal": "NORTH_NORTHWEST" + }, + "speed": { + "value": 8, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 18, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "visibility": { + "distance": 16, + "unit": "KILOMETERS" + }, + "cloudCover": 0, + "currentConditionsHistory": { + "temperatureChange": { + "degrees": -0.6, + "unit": "CELSIUS" + }, + "maxTemperature": { + "degrees": 14.3, + "unit": "CELSIUS" + }, + "minTemperature": { + "degrees": 3.7, + "unit": "CELSIUS" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + } +} diff --git a/tests/components/google_weather/fixtures/daily_forecast.json b/tests/components/google_weather/fixtures/daily_forecast.json new file mode 100644 index 00000000000..bc0bf98af0d --- /dev/null +++ b/tests/components/google_weather/fixtures/daily_forecast.json @@ -0,0 +1,135 @@ +{ + "forecastDays": [ + { + "interval": { + "startTime": "2025-02-10T15:00:00Z", + "endTime": "2025-02-11T15:00:00Z" + }, + "displayDate": { + "year": 2025, + "month": 2, + "day": 10 + }, + "daytimeForecast": { + "interval": { + "startTime": "2025-02-10T15:00:00Z", + "endTime": "2025-02-11T03:00:00Z" + }, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/party_cloudy", + "description": { + "text": "Partly sunny", + "languageCode": "en" + }, + "type": "PARTLY_CLOUDY" + }, + "relativeHumidity": 54, + "uvIndex": 3, + "precipitation": { + "probability": { + "percent": 5, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "wind": { + "direction": { + "degrees": 280, + "cardinal": "WEST" + }, + "speed": { + "value": 6, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 14, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "cloudCover": 53 + }, + "nighttimeForecast": { + "interval": { + "startTime": "2025-02-11T03:00:00Z", + "endTime": "2025-02-11T15:00:00Z" + }, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/partly_clear", + "description": { + "text": "Partly cloudy", + "languageCode": "en" + }, + "type": "PARTLY_CLOUDY" + }, + "relativeHumidity": 85, + "uvIndex": 0, + "precipitation": { + "probability": { + "percent": 10, + "type": "RAIN_AND_SNOW" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "wind": { + "direction": { + "degrees": 201, + "cardinal": "SOUTH_SOUTHWEST" + }, + "speed": { + "value": 6, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 14, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "cloudCover": 70 + }, + "maxTemperature": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "minTemperature": { + "degrees": 1.5, + "unit": "CELSIUS" + }, + "feelsLikeMaxTemperature": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "feelsLikeMinTemperature": { + "degrees": 1.5, + "unit": "CELSIUS" + }, + "sunEvents": { + "sunriseTime": "2025-02-10T15:02:35.703929582Z", + "sunsetTime": "2025-02-11T01:43:00.762932858Z" + }, + "moonEvents": { + "moonPhase": "WAXING_GIBBOUS", + "moonriseTimes": ["2025-02-10T23:54:17.713157984Z"], + "moonsetTimes": ["2025-02-10T14:13:58.625181191Z"] + }, + "maxHeatIndex": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "iceThickness": { + "thickness": 0, + "unit": "MILLIMETERS" + } + } + ], + "timeZone": { + "id": "America/Los_Angeles" + } +} diff --git a/tests/components/google_weather/fixtures/hourly_forecast.json b/tests/components/google_weather/fixtures/hourly_forecast.json new file mode 100644 index 00000000000..87866a1b631 --- /dev/null +++ b/tests/components/google_weather/fixtures/hourly_forecast.json @@ -0,0 +1,92 @@ +{ + "forecastHours": [ + { + "interval": { + "startTime": "2025-02-05T23:00:00Z", + "endTime": "2025-02-06T00:00:00Z" + }, + "displayDateTime": { + "year": 2025, + "month": 2, + "day": 5, + "hours": 15, + "utcOffset": "-28800s" + }, + "isDaytime": true, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny", + "description": { + "text": "Sunny", + "languageCode": "en" + }, + "type": "CLEAR" + }, + "temperature": { + "degrees": 12.7, + "unit": "CELSIUS" + }, + "feelsLikeTemperature": { + "degrees": 12, + "unit": "CELSIUS" + }, + "dewPoint": { + "degrees": 2.7, + "unit": "CELSIUS" + }, + "heatIndex": { + "degrees": 12.7, + "unit": "CELSIUS" + }, + "windChill": { + "degrees": 12, + "unit": "CELSIUS" + }, + "wetBulbTemperature": { + "degrees": 7.7, + "unit": "CELSIUS" + }, + "relativeHumidity": 51, + "uvIndex": 1, + "precipitation": { + "probability": { + "percent": 0, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "airPressure": { + "meanSeaLevelMillibars": 1019.13 + }, + "wind": { + "direction": { + "degrees": 335, + "cardinal": "NORTH_NORTHWEST" + }, + "speed": { + "value": 10, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 19, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "visibility": { + "distance": 16, + "unit": "KILOMETERS" + }, + "cloudCover": 0, + "iceThickness": { + "thickness": 0, + "unit": "MILLIMETERS" + } + } + ], + "timeZone": { + "id": "America/Los_Angeles" + } +} diff --git a/tests/components/google_weather/snapshots/test_weather.ambr b/tests/components/google_weather/snapshots/test_weather.ambr new file mode 100644 index 00000000000..e33f864ab67 --- /dev/null +++ b/tests/components/google_weather/snapshots/test_weather.ambr @@ -0,0 +1,191 @@ +# serializer version: 1 +# name: test_forecast_service[daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 12.0, + 'cloud_coverage': 0, + 'condition': 'sunny', + 'datetime': '2025-02-05T23:00:00Z', + 'dew_point': 2.7, + 'humidity': 51, + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1019.13, + 'temperature': 12.7, + 'uv_index': 1, + 'wind_bearing': 335, + 'wind_gust_speed': 19.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[twice_daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 13.3, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + dict({ + 'apparent_temperature': 1.5, + 'cloud_coverage': 70, + 'condition': 'partlycloudy', + 'datetime': '2025-02-11T03:00:00Z', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_subscription + list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]) +# --- +# name: test_forecast_subscription.1 + list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]) +# --- +# name: test_weather[weather.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'google_weather', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'home-subentry-id', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather[weather.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 13.1, + 'attribution': 'Data from Google Weather', + 'cloud_coverage': 0.0, + 'dew_point': 1.1, + 'friendly_name': 'Home', + 'humidity': 42, + 'precipitation_unit': , + 'pressure': 1019.16, + 'pressure_unit': , + 'supported_features': , + 'temperature': 13.7, + 'temperature_unit': , + 'uv_index': 1.0, + 'visibility': 16.0, + 'visibility_unit': , + 'wind_bearing': 335, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.0, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sunny', + }) +# --- diff --git a/tests/components/google_weather/test_config_flow.py b/tests/components/google_weather/test_config_flow.py new file mode 100644 index 00000000000..719c545beb5 --- /dev/null +++ b/tests/components/google_weather/test_config_flow.py @@ -0,0 +1,375 @@ +"""Test the Google Weather config flow.""" + +from unittest.mock import AsyncMock + +from google_weather_api import GoogleWeatherApiError +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_weather.const import ( + CONF_REFERRER, + DOMAIN, + SECTION_API_KEY_OPTIONS, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, get_schema_suggested_value + + +def _assert_create_entry_result( + result: dict, expected_referrer: str | None = None +) -> None: + """Assert that the result is a create entry result.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Weather" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + CONF_REFERRER: expected_referrer, + } + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "location" + assert subentry["title"] == "test-name" + assert subentry["data"] == { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + } + + +async def test_create_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test creating a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1, longitude=20.1 + ) + + _assert_create_entry_result(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_referrer( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test we get the form and optional referrer is specified.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "test-referrer", + }, + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1, longitude=20.1 + ) + + _assert_create_entry_result(result, expected_referrer="test-referrer") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error"), + [ + (GoogleWeatherApiError(), "cannot_connect"), + (ValueError(), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, + api_exception, + expected_error, +) -> None: + """Test we handle exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + # On error, the form should have the previous user input + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_NAME) == "test-name" + assert get_schema_suggested_value(data_schema, CONF_API_KEY) == "test-api-key" + assert get_schema_suggested_value(data_schema, CONF_LOCATION) == { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + } + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + mock_google_weather_api.async_get_current_conditions.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + _assert_create_entry_result(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_api_key_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for config_entry with API key that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.2, + CONF_LONGITUDE: 20.2, + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_google_weather_api.async_get_current_conditions.call_count == 0 + + +async def test_form_location_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for a location that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "another-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1001, + CONF_LONGITUDE: 20.0999, + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_google_weather_api.async_get_current_conditions.call_count == 0 + + +async def test_form_not_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for config_entry different than the existing one.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "new-test-name", + CONF_API_KEY: "new-test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1002, + CONF_LONGITUDE: 20.0998, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1002, longitude=20.0998 + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Weather" + assert result["data"] == { + CONF_API_KEY: "new-test-api-key", + CONF_REFERRER: None, + } + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "location" + assert subentry["title"] == "new-test-name" + assert subentry["data"] == { + CONF_LATITUDE: 10.1002, + CONF_LONGITUDE: 20.0998, + } + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test creating a location subentry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # After initial setup for 1 subentry, each API is called once + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + assert mock_google_weather_api.async_get_daily_forecast.call_count == 1 + assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "Work", + CONF_LOCATION: { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + }, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Work" + assert result2["data"] == { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + } + + # Initial setup: 1 of each API call + # Subentry flow validation: 1 current conditions call + # Reload with 2 subentries: 2 of each API call + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + 1 + 2 + assert mock_google_weather_api.async_get_daily_forecast.call_count == 1 + 2 + assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1 + 2 + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 2 + + +async def test_subentry_flow_location_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for a location that already exists.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "Work", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 1 + + +async def test_subentry_flow_entry_not_loaded( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test creating a location subentry when the parent entry is not loaded.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" diff --git a/tests/components/google_weather/test_init.py b/tests/components/google_weather/test_init.py new file mode 100644 index 00000000000..16462c86023 --- /dev/null +++ b/tests/components/google_weather/test_init.py @@ -0,0 +1,69 @@ +"""Test init of Google Weather integration.""" + +from unittest.mock import AsyncMock + +from google_weather_api import GoogleWeatherApiError +import pytest + +from homeassistant.components.google_weather.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test a successful setup entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("weather.home") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + + +@pytest.mark.parametrize( + "failing_api_method", + [ + "async_get_current_conditions", + "async_get_daily_forecast", + "async_get_hourly_forecast", + ], +) +async def test_config_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + failing_api_method: str, +) -> None: + """Test for setup failure if an API call fails.""" + getattr( + mock_google_weather_api, failing_api_method + ).side_effect = GoogleWeatherApiError() + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test successful unload of entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/google_weather/test_weather.py b/tests/components/google_weather/test_weather.py new file mode 100644 index 00000000000..128bcee02ac --- /dev/null +++ b/tests/components/google_weather/test_weather.py @@ -0,0 +1,214 @@ +"""Test weather of Google Weather integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from google_weather_api import GoogleWeatherApiError, WeatherCondition +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_weather.weather import _CONDITION_MAP +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_SUNNY, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + + +async def test_weather( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, +) -> None: + """Test states of the weather.""" + with patch( + "homeassistant.components.google_weather._PLATFORMS", [Platform.WEATHER] + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + entity_id = "weather.home" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiError() + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + # Reset side effect, return a valid response again + mock_google_weather_api.async_get_current_conditions.side_effect = None + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + mock_google_weather_api.async_get_current_conditions.assert_called_with( + latitude=10.1, longitude=20.1 + ) + + +async def test_manual_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test manual update entity via service homeassistant/update_entity.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await async_setup_component(hass, "homeassistant", {}) + + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + mock_google_weather_api.async_get_current_conditions.assert_called_with( + latitude=10.1, longitude=20.1 + ) + + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["weather.home"]}, + blocking=True, + ) + + assert mock_google_weather_api.async_get_current_conditions.call_count == 2 + + +@pytest.mark.parametrize( + ("api_condition", "is_daytime", "expected_ha_condition"), + [ + (WeatherCondition.Type.CLEAR, True, ATTR_CONDITION_SUNNY), + (WeatherCondition.Type.CLEAR, False, ATTR_CONDITION_CLEAR_NIGHT), + (WeatherCondition.Type.PARTLY_CLOUDY, True, ATTR_CONDITION_PARTLYCLOUDY), + (WeatherCondition.Type.PARTLY_CLOUDY, False, ATTR_CONDITION_PARTLYCLOUDY), + (WeatherCondition.Type.TYPE_UNSPECIFIED, True, "unknown"), + ], +) +async def test_condition( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_condition: WeatherCondition.Type, + is_daytime: bool, + expected_ha_condition: str, +) -> None: + """Test condition mapping.""" + mock_google_weather_api.async_get_current_conditions.return_value.weather_condition.type = api_condition + mock_google_weather_api.async_get_current_conditions.return_value.is_daytime = ( + is_daytime + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get("weather.home") + assert state.state == expected_ha_condition + + +def test_all_conditions_mapped() -> None: + """Ensure all WeatherCondition.Type enum members are in the _CONDITION_MAP.""" + for condition_type in WeatherCondition.Type: + assert condition_type in _CONDITION_MAP + + +@pytest.mark.parametrize(("forecast_type"), ["daily", "hourly", "twice_daily"]) +async def test_forecast_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, + forecast_type, +) -> None: + """Test forecast service.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + "entity_id": "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_forecast_subscription( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.home", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(timedelta(hours=1) + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot