1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-20 10:59:24 +00:00

Add Google Weather integration (#147015)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
tronikos
2025-11-14 10:46:56 -08:00
committed by GitHub
parent 04458e01be
commit 3aff225bc3
26 changed files with 2287 additions and 0 deletions

View File

@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
homeassistant.components.google_drive.* homeassistant.components.google_drive.*
homeassistant.components.google_photos.* homeassistant.components.google_photos.*
homeassistant.components.google_sheets.* homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.* homeassistant.components.govee_ble.*
homeassistant.components.gpsd.* homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.* homeassistant.components.greeneye_monitor.*

2
CODEOWNERS generated
View File

@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter /tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger /homeassistant/components/google_travel_time/ @eifinger
/tests/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 /homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco /tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen /homeassistant/components/govee_light_local/ @Galorhallen

View File

@@ -15,6 +15,7 @@
"google_tasks", "google_tasks",
"google_translate", "google_translate",
"google_travel_time", "google_travel_time",
"google_weather",
"google_wifi", "google_wifi",
"google", "google",
"nest", "nest",

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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%]"
}
}
}
}
}
}

View File

@@ -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

View File

@@ -255,6 +255,7 @@ FLOWS = {
"google_tasks", "google_tasks",
"google_translate", "google_translate",
"google_travel_time", "google_travel_time",
"google_weather",
"govee_ble", "govee_ble",
"govee_light_local", "govee_light_local",
"gpsd", "gpsd",

View File

@@ -2451,6 +2451,12 @@
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
}, },
"google_weather": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Google Weather"
},
"google_wifi": { "google_wifi": {
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": false,

10
mypy.ini generated
View File

@@ -2066,6 +2066,16 @@ disallow_untyped_defs = true
warn_return_any = true warn_return_any = true
warn_unreachable = 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.*] [mypy-homeassistant.components.govee_ble.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@@ -2469,6 +2469,9 @@ python-gitlab==1.6.0
# homeassistant.components.google_drive # homeassistant.components.google_drive
python-google-drive-api==0.1.0 python-google-drive-api==0.1.0
# homeassistant.components.google_weather
python-google-weather-api==0.0.4
# homeassistant.components.analytics_insights # homeassistant.components.analytics_insights
python-homeassistant-analytics==0.9.0 python-homeassistant-analytics==0.9.0

View File

@@ -2044,6 +2044,9 @@ python-fullykiosk==0.0.14
# homeassistant.components.google_drive # homeassistant.components.google_drive
python-google-drive-api==0.1.0 python-google-drive-api==0.1.0
# homeassistant.components.google_weather
python-google-weather-api==0.0.4
# homeassistant.components.analytics_insights # homeassistant.components.analytics_insights
python-homeassistant-analytics==0.9.0 python-homeassistant-analytics==0.9.0

View File

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

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'weather',
'entity_category': None,
'entity_id': 'weather.home',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <WeatherEntityFeature: 7>,
'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': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure': 1019.16,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': <WeatherEntityFeature: 7>,
'temperature': 13.7,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'uv_index': 1.0,
'visibility': 16.0,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 335,
'wind_gust_speed': 18.0,
'wind_speed': 8.0,
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'weather.home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'sunny',
})
# ---

View File

@@ -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"

View File

@@ -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

View File

@@ -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