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:
@@ -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
2
CODEOWNERS
generated
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
84
homeassistant/components/google_weather/__init__.py
Normal file
84
homeassistant/components/google_weather/__init__.py
Normal 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)
|
||||||
198
homeassistant/components/google_weather/config_flow.py
Normal file
198
homeassistant/components/google_weather/config_flow.py
Normal 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
|
||||||
8
homeassistant/components/google_weather/const.py
Normal file
8
homeassistant/components/google_weather/const.py
Normal 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"
|
||||||
169
homeassistant/components/google_weather/coordinator.py
Normal file
169
homeassistant/components/google_weather/coordinator.py
Normal 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,
|
||||||
|
)
|
||||||
28
homeassistant/components/google_weather/entity.py
Normal file
28
homeassistant/components/google_weather/entity.py
Normal 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,
|
||||||
|
)
|
||||||
12
homeassistant/components/google_weather/manifest.json
Normal file
12
homeassistant/components/google_weather/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
82
homeassistant/components/google_weather/quality_scale.yaml
Normal 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
|
||||||
65
homeassistant/components/google_weather/strings.json
Normal file
65
homeassistant/components/google_weather/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
366
homeassistant/components/google_weather/weather.py
Normal file
366
homeassistant/components/google_weather/weather.py
Normal 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
|
||||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
10
mypy.ini
generated
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
1
tests/components/google_weather/__init__.py
Normal file
1
tests/components/google_weather/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Google Weather integration."""
|
||||||
83
tests/components/google_weather/conftest.py
Normal file
83
tests/components/google_weather/conftest.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
tests/components/google_weather/fixtures/daily_forecast.json
Normal file
135
tests/components/google_weather/fixtures/daily_forecast.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
191
tests/components/google_weather/snapshots/test_weather.ambr
Normal file
191
tests/components/google_weather/snapshots/test_weather.ambr
Normal 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',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
375
tests/components/google_weather/test_config_flow.py
Normal file
375
tests/components/google_weather/test_config_flow.py
Normal 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"
|
||||||
69
tests/components/google_weather/test_init.py
Normal file
69
tests/components/google_weather/test_init.py
Normal 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
|
||||||
214
tests/components/google_weather/test_weather.py
Normal file
214
tests/components/google_weather/test_weather.py
Normal 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
|
||||||
Reference in New Issue
Block a user