diff --git a/.strict-typing b/.strict-typing index 6182837d15e804..06b4fa56fb513a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -231,6 +231,7 @@ homeassistant.components.google_cloud.* homeassistant.components.google_drive.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* +homeassistant.components.google_weather.* homeassistant.components.govee_ble.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* diff --git a/CODEOWNERS b/CODEOWNERS index daf4dd0d0aeeef..b868e9eee5df36 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -607,6 +607,8 @@ build.json @home-assistant/supervisor /tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger +/homeassistant/components/google_weather/ @tronikos +/tests/components/google_weather/ @tronikos /homeassistant/components/govee_ble/ @bdraco /tests/components/govee_ble/ @bdraco /homeassistant/components/govee_light_local/ @Galorhallen @@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor /tests/components/sanix/ @tomaszsluszniak /homeassistant/components/satel_integra/ @Tommatheussen /tests/components/satel_integra/ @Tommatheussen +/homeassistant/components/saunum/ @mettolen +/tests/components/saunum/ @mettolen /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 872cfc0aac5021..b7ace2cf46184f 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -15,6 +15,7 @@ "google_tasks", "google_translate", "google_travel_time", + "google_weather", "google_wifi", "google", "nest", diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index a6b3831c4c107b..4d1b37c9d1a6be 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN @@ -43,6 +44,9 @@ def __init__( name=entry.title, config_entry=entry, update_interval=timedelta(seconds=SCAN_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=30, immediate=False + ), ) self.api = AmazonEchoApi( session, diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d8e3c64bad81b1..16bde96a5f9b29 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: # Due dates are returned always in UTC so we only need to # parse the date portion which will be interpreted as a a local date. due = datetime.fromisoformat(due_str).date() + completed: datetime | None = None + if (completed_str := item.get("completed")) is not None: + completed = datetime.fromisoformat(completed_str) return TodoItem( summary=item["title"], uid=item["id"], @@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: TodoItemStatus.NEEDS_ACTION, ), due=due, + completed=completed, description=item.get("notes"), ) diff --git a/homeassistant/components/google_weather/__init__.py b/homeassistant/components/google_weather/__init__.py new file mode 100644 index 00000000000000..97d64ff676f817 --- /dev/null +++ b/homeassistant/components/google_weather/__init__.py @@ -0,0 +1,84 @@ +"""The Google Weather integration.""" + +from __future__ import annotations + +import asyncio + +from google_weather_api import GoogleWeatherApi + +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_REFERRER +from .coordinator import ( + GoogleWeatherConfigEntry, + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, + GoogleWeatherRuntimeData, + GoogleWeatherSubEntryRuntimeData, +) + +_PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> bool: + """Set up Google Weather from a config entry.""" + + api = GoogleWeatherApi( + session=async_get_clientsession(hass), + api_key=entry.data[CONF_API_KEY], + referrer=entry.data.get(CONF_REFERRER), + language_code=hass.config.language, + ) + subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {} + for subentry in entry.subentries.values(): + subentry_runtime_data = GoogleWeatherSubEntryRuntimeData( + coordinator_observation=GoogleWeatherCurrentConditionsCoordinator( + hass, entry, subentry, api + ), + coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator( + hass, entry, subentry, api + ), + coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator( + hass, entry, subentry, api + ), + ) + subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data + tasks = [ + coro + for subentry_runtime_data in subentries_runtime_data.values() + for coro in ( + subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(), + subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(), + subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(), + ) + ] + await asyncio.gather(*tasks) + entry.runtime_data = GoogleWeatherRuntimeData( + api=api, + subentries_runtime_data=subentries_runtime_data, + ) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) + + +async def async_update_options( + hass: HomeAssistant, entry: GoogleWeatherConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/google_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py new file mode 100644 index 00000000000000..661146ab01d97e --- /dev/null +++ b/homeassistant/components/google_weather/config_flow.py @@ -0,0 +1,198 @@ +"""Config flow for the Google Weather integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import section +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig + +from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(SECTION_API_KEY_OPTIONS): section( + vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True} + ), + } +) + + +async def _validate_input( + user_input: dict[str, Any], + api: GoogleWeatherApi, + errors: dict[str, str], + description_placeholders: dict[str, str], +) -> bool: + try: + await api.async_get_current_conditions( + latitude=user_input[CONF_LOCATION][CONF_LATITUDE], + longitude=user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except GoogleWeatherApiError as err: + errors["base"] = "cannot_connect" + description_placeholders["error_message"] = str(err) + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return True + return False + + +def _get_location_schema(hass: HomeAssistant) -> vol.Schema: + """Return the schema for a location with default values from the hass config.""" + return vol.Schema( + { + vol.Required(CONF_NAME, default=hass.config.location_name): str, + vol.Required( + CONF_LOCATION, + default={ + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + }, + ): LocationSelector(LocationSelectorConfig(radius=False)), + } + ) + + +def _is_location_already_configured( + hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4 +) -> bool: + """Check if the location is already configured.""" + for entry in hass.config_entries.async_entries(DOMAIN): + for subentry in entry.subentries.values(): + # A more accurate way is to use the haversine formula, but for simplicity + # we use a simple distance check. The epsilon value is small anyway. + # This is mostly to capture cases where the user has slightly moved the location pin. + if ( + abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon + and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE]) + <= epsilon + ): + return True + return False + + +class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Weather.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = { + "api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key", + "restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys", + } + if user_input is not None: + api_key = user_input[CONF_API_KEY] + referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER) + self._async_abort_entries_match({CONF_API_KEY: api_key}) + if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + return self.async_abort(reason="already_configured") + api = GoogleWeatherApi( + session=async_get_clientsession(self.hass), + api_key=api_key, + referrer=referrer, + language_code=self.hass.config.language, + ) + if await _validate_input(user_input, api, errors, description_placeholders): + return self.async_create_entry( + title="Google Weather", + data={ + CONF_API_KEY: api_key, + CONF_REFERRER: referrer, + }, + subentries=[ + { + "subentry_type": "location", + "data": user_input[CONF_LOCATION], + "title": user_input[CONF_NAME], + "unique_id": None, + }, + ], + ) + else: + user_input = {} + schema = STEP_USER_DATA_SCHEMA.schema.copy() + schema.update(_get_location_schema(self.hass).schema) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), user_input + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"location": LocationSubentryFlowHandler} + + +class LocationSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for location.""" + + async def async_step_location( + self, + user_input: dict[str, Any] | None = None, + ) -> SubentryFlowResult: + """Handle the location step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + if user_input is not None: + if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]): + return self.async_abort(reason="already_configured") + api: GoogleWeatherApi = self._get_entry().runtime_data.api + if await _validate_input(user_input, api, errors, description_placeholders): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input[CONF_LOCATION], + ) + else: + user_input = {} + return self.async_show_form( + step_id="location", + data_schema=self.add_suggested_values_to_schema( + _get_location_schema(self.hass), user_input + ), + errors=errors, + description_placeholders=description_placeholders, + ) + + async_step_user = async_step_location diff --git a/homeassistant/components/google_weather/const.py b/homeassistant/components/google_weather/const.py new file mode 100644 index 00000000000000..94c3e67bdbb9fd --- /dev/null +++ b/homeassistant/components/google_weather/const.py @@ -0,0 +1,8 @@ +"""Constants for the Google Weather integration.""" + +from typing import Final + +DOMAIN = "google_weather" + +SECTION_API_KEY_OPTIONS: Final = "api_key_options" +CONF_REFERRER: Final = "referrer" diff --git a/homeassistant/components/google_weather/coordinator.py b/homeassistant/components/google_weather/coordinator.py new file mode 100644 index 00000000000000..3f81a8a31e9be4 --- /dev/null +++ b/homeassistant/components/google_weather/coordinator.py @@ -0,0 +1,169 @@ +"""The Google Weather coordinator.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TypeVar + +from google_weather_api import ( + CurrentConditionsResponse, + DailyForecastResponse, + GoogleWeatherApi, + GoogleWeatherApiError, + HourlyForecastResponse, +) + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) + +_LOGGER = logging.getLogger(__name__) + +T = TypeVar( + "T", + bound=( + CurrentConditionsResponse + | DailyForecastResponse + | HourlyForecastResponse + | None + ), +) + + +@dataclass +class GoogleWeatherSubEntryRuntimeData: + """Runtime data for a Google Weather sub-entry.""" + + coordinator_observation: GoogleWeatherCurrentConditionsCoordinator + coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator + coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator + + +@dataclass +class GoogleWeatherRuntimeData: + """Runtime data for the Google Weather integration.""" + + api: GoogleWeatherApi + subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] + + +type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData] + + +class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]): + """Base class for Google Weather coordinators.""" + + config_entry: GoogleWeatherConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + data_type_name: str, + update_interval: timedelta, + api_method: Callable[..., Awaitable[T]], + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"Google Weather {data_type_name} coordinator for {subentry.title}", + update_interval=update_interval, + ) + self.subentry = subentry + self._data_type_name = data_type_name + self._api_method = api_method + + async def _async_update_data(self) -> T: + """Fetch data from API and handle errors.""" + try: + return await self._api_method( + self.subentry.data[CONF_LATITUDE], + self.subentry.data[CONF_LONGITUDE], + ) + except GoogleWeatherApiError as err: + _LOGGER.error( + "Error fetching %s for %s: %s", + self._data_type_name, + self.subentry.title, + err, + ) + raise UpdateFailed(f"Error fetching {self._data_type_name}") from err + + +class GoogleWeatherCurrentConditionsCoordinator( + GoogleWeatherBaseCoordinator[CurrentConditionsResponse] +): + """Handle fetching current weather conditions.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "current weather conditions", + timedelta(minutes=15), + api.async_get_current_conditions, + ) + + +class GoogleWeatherDailyForecastCoordinator( + GoogleWeatherBaseCoordinator[DailyForecastResponse] +): + """Handle fetching daily weather forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "daily weather forecast", + timedelta(hours=1), + api.async_get_daily_forecast, + ) + + +class GoogleWeatherHourlyForecastCoordinator( + GoogleWeatherBaseCoordinator[HourlyForecastResponse] +): + """Handle fetching hourly weather forecast.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + api: GoogleWeatherApi, + ) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + config_entry, + subentry, + "hourly weather forecast", + timedelta(hours=1), + api.async_get_hourly_forecast, + ) diff --git a/homeassistant/components/google_weather/entity.py b/homeassistant/components/google_weather/entity.py new file mode 100644 index 00000000000000..2d6104d280f5ed --- /dev/null +++ b/homeassistant/components/google_weather/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Google Weather.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigSubentry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .coordinator import GoogleWeatherConfigEntry + + +class GoogleWeatherBaseEntity(Entity): + """Base entity for all Google Weather entities.""" + + _attr_has_entity_name = True + + def __init__( + self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry + ) -> None: + """Initialize base entity.""" + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Google", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/google_weather/manifest.json b/homeassistant/components/google_weather/manifest.json new file mode 100644 index 00000000000000..fb7b63ebd5fa40 --- /dev/null +++ b/homeassistant/components/google_weather/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_weather", + "name": "Google Weather", + "codeowners": ["@tronikos"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/google_weather", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["google_weather_api"], + "quality_scale": "bronze", + "requirements": ["python-google-weather-api==0.0.4"] +} diff --git a/homeassistant/components/google_weather/quality_scale.yaml b/homeassistant/components/google_weather/quality_scale.yaml new file mode 100644 index 00000000000000..946bcc9a0d3a66 --- /dev/null +++ b/homeassistant/components/google_weather/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No events subscribed. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: No actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: N/A + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: N/A + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_weather/strings.json b/homeassistant/components/google_weather/strings.json new file mode 100644 index 00000000000000..db0531f8015066 --- /dev/null +++ b/homeassistant/components/google_weather/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "location": "[%key:common::config_flow::data::location%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API", + "location": "Location coordinates", + "name": "Location name" + }, + "description": "Get your API key from [here]({api_key_url}).", + "sections": { + "api_key_options": { + "data": { + "referrer": "HTTP referrer" + }, + "data_description": { + "referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})" + }, + "name": "Optional API key options" + } + } + } + } + }, + "config_subentries": { + "location": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." + }, + "entry_type": "Location", + "error": { + "cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add location" + }, + "step": { + "location": { + "data": { + "location": "[%key:common::config_flow::data::location%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "location": "[%key:component::google_weather::config::step::user::data_description::location%]", + "name": "[%key:component::google_weather::config::step::user::data_description::name%]" + } + } + } + } + } +} diff --git a/homeassistant/components/google_weather/weather.py b/homeassistant/components/google_weather/weather.py new file mode 100644 index 00000000000000..0c906abee40319 --- /dev/null +++ b/homeassistant/components/google_weather/weather.py @@ -0,0 +1,366 @@ +"""Weather entity.""" + +from __future__ import annotations + +from google_weather_api import ( + DailyForecastResponse, + HourlyForecastResponse, + WeatherCondition, +) + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + CoordinatorWeatherEntity, + Forecast, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ( + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ( + GoogleWeatherConfigEntry, + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, +) +from .entity import GoogleWeatherBaseEntity + +PARALLEL_UPDATES = 0 + +# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons +# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition +_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = { + WeatherCondition.Type.TYPE_UNSPECIFIED: None, + WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY, + WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY, + WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, + WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY, + WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY, + WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING, + WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING, + WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY, + WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING, + WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING, + WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY, + WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY, + WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL, + WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL, + WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY, + WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY, +} + + +def _get_condition( + api_condition: WeatherCondition.Type, is_daytime: bool +) -> str | None: + """Map Google Weather condition to Home Assistant condition.""" + cond = _CONDITION_MAP[api_condition] + if cond == ATTR_CONDITION_SUNNY and not is_daytime: + return ATTR_CONDITION_CLEAR_NIGHT + return cond + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GoogleWeatherConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + for subentry in entry.subentries.values(): + async_add_entities( + [GoogleWeatherEntity(entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleWeatherEntity( + CoordinatorWeatherEntity[ + GoogleWeatherCurrentConditionsCoordinator, + GoogleWeatherDailyForecastCoordinator, + GoogleWeatherHourlyForecastCoordinator, + GoogleWeatherDailyForecastCoordinator, + ], + GoogleWeatherBaseEntity, +): + """Representation of a Google Weather entity.""" + + _attr_attribution = "Data from Google Weather" + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_visibility_unit = UnitOfLength.KILOMETERS + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + + _attr_name = None + + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) + + def __init__( + self, + entry: GoogleWeatherConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the weather entity.""" + subentry_runtime_data = entry.runtime_data.subentries_runtime_data[ + subentry.subentry_id + ] + super().__init__( + observation_coordinator=subentry_runtime_data.coordinator_observation, + daily_coordinator=subentry_runtime_data.coordinator_daily_forecast, + hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast, + twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast, + ) + GoogleWeatherBaseEntity.__init__(self, entry, subentry) + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return _get_condition( + self.coordinator.data.weather_condition.type, + self.coordinator.data.is_daytime, + ) + + @property + def native_temperature(self) -> float: + """Return the temperature.""" + return self.coordinator.data.temperature.degrees + + @property + def native_apparent_temperature(self) -> float: + """Return the apparent temperature.""" + return self.coordinator.data.feels_like_temperature.degrees + + @property + def native_dew_point(self) -> float: + """Return the dew point.""" + return self.coordinator.data.dew_point.degrees + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self.coordinator.data.relative_humidity + + @property + def uv_index(self) -> float: + """Return the UV index.""" + return float(self.coordinator.data.uv_index) + + @property + def native_pressure(self) -> float: + """Return the pressure.""" + return self.coordinator.data.air_pressure.mean_sea_level_millibars + + @property + def native_wind_gust_speed(self) -> float: + """Return the wind gust speed.""" + return self.coordinator.data.wind.gust.value + + @property + def native_wind_speed(self) -> float: + """Return the wind speed.""" + return self.coordinator.data.wind.speed.value + + @property + def wind_bearing(self) -> int: + """Return the wind bearing.""" + return self.coordinator.data.wind.direction.degrees + + @property + def native_visibility(self) -> float: + """Return the visibility.""" + return self.coordinator.data.visibility.distance + + @property + def cloud_coverage(self) -> float: + """Return the Cloud coverage in %.""" + return float(self.coordinator.data.cloud_cover) + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + coordinator = self.forecast_coordinators["daily"] + assert coordinator + daily_data = coordinator.data + assert isinstance(daily_data, DailyForecastResponse) + return [ + { + ATTR_FORECAST_CONDITION: _get_condition( + item.daytime_forecast.weather_condition.type, is_daytime=True + ), + ATTR_FORECAST_TIME: item.interval.start_time, + ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: max( + item.daytime_forecast.precipitation.probability.percent, + item.nighttime_forecast.precipitation.probability.percent, + ), + ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: ( + item.daytime_forecast.precipitation.qpf.quantity + + item.nighttime_forecast.precipitation.qpf.quantity + ), + ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees, + ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: ( + item.feels_like_max_temperature.degrees + ), + ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max( + item.daytime_forecast.wind.gust.value, + item.nighttime_forecast.wind.gust.value, + ), + ATTR_FORECAST_NATIVE_WIND_SPEED: max( + item.daytime_forecast.wind.speed.value, + item.nighttime_forecast.wind.speed.value, + ), + ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index, + } + for item in daily_data.forecast_days + ] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + coordinator = self.forecast_coordinators["hourly"] + assert coordinator + hourly_data = coordinator.data + assert isinstance(hourly_data, HourlyForecastResponse) + return [ + { + ATTR_FORECAST_CONDITION: _get_condition( + item.weather_condition.type, item.is_daytime + ), + ATTR_FORECAST_TIME: item.interval.start_time, + ATTR_FORECAST_HUMIDITY: item.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars, + ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value, + ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees, + ATTR_FORECAST_UV_INDEX: item.uv_index, + ATTR_FORECAST_IS_DAYTIME: item.is_daytime, + } + for item in hourly_data.forecast_hours + ] + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = self.forecast_coordinators["twice_daily"] + assert coordinator + daily_data = coordinator.data + assert isinstance(daily_data, DailyForecastResponse) + forecasts: list[Forecast] = [] + for item in daily_data.forecast_days: + # Process daytime forecast + day_forecast = item.daytime_forecast + forecasts.append( + { + ATTR_FORECAST_CONDITION: _get_condition( + day_forecast.weather_condition.type, is_daytime=True + ), + ATTR_FORECAST_TIME: day_forecast.interval.start_time, + ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value, + ATTR_FORECAST_UV_INDEX: day_forecast.uv_index, + ATTR_FORECAST_IS_DAYTIME: True, + } + ) + + # Process nighttime forecast + night_forecast = item.nighttime_forecast + forecasts.append( + { + ATTR_FORECAST_CONDITION: _get_condition( + night_forecast.weather_condition.type, is_daytime=False + ), + ATTR_FORECAST_TIME: night_forecast.interval.start_time, + ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent, + ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover, + ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity, + ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees, + ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value, + ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value, + ATTR_FORECAST_UV_INDEX: night_forecast.uv_index, + ATTR_FORECAST_IS_DAYTIME: False, + } + ) + + return forecasts diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index cd8d00ac8f1620..92d35616b2d9ab 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -121,12 +121,15 @@ def __init__( """Initialize AutomowerEntity.""" super().__init__(coordinator) self.mower_id = mower_id - parts = self.mower_attributes.system.model.split(maxsplit=2) + model_witout_manufacturer = self.mower_attributes.system.model.removeprefix( + "Husqvarna " + ).removeprefix("HUSQVARNA ") + parts = model_witout_manufacturer.split(maxsplit=1) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mower_id)}, - manufacturer=parts[0], - model=parts[1], - model_id=parts[2], + manufacturer="Husqvarna", + model=parts[0].capitalize().removesuffix("®"), + model_id=parts[1], name=self.mower_attributes.system.name, serial_number=self.mower_attributes.system.serial_number, suggested_area="Garden", diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index e6c2e6cc6d27e8..3570dbdc45fe57 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -194,7 +194,7 @@ "name": "Altitude above sea level" }, "auto_relock_timer": { - "name": "Autorelock time" + "name": "Auto-relock time" }, "cook_time": { "name": "Cooking time" diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 53b94655124be0..7cbaffe4d63427 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.2.0", "python-open-router==0.3.3"] + "requirements": ["openai==2.8.0", "python-open-router==0.3.3"] } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index f733b7a2af62a1..8f129578a368ff 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==2.2.0"] + "requirements": ["openai==2.8.0"] } diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py new file mode 100644 index 00000000000000..e9bd9fb402066f --- /dev/null +++ b/homeassistant/components/saunum/__init__.py @@ -0,0 +1,50 @@ +"""The Saunum Leil Sauna Control Unit integration.""" + +from __future__ import annotations + +import logging + +from pysaunum import SaunumClient, SaunumConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import PLATFORMS +from .coordinator import LeilSaunaCoordinator + +_LOGGER = logging.getLogger(__name__) + +type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: + """Set up Saunum Leil Sauna from a config entry.""" + host = entry.data[CONF_HOST] + + client = SaunumClient(host=host) + + # Test connection + try: + await client.connect() + except SaunumConnectionError as exc: + raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + + coordinator = LeilSaunaCoordinator(hass, client, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + coordinator = entry.runtime_data + coordinator.client.close() + + return unload_ok diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py new file mode 100644 index 00000000000000..d4b48423e5f355 --- /dev/null +++ b/homeassistant/components/saunum/climate.py @@ -0,0 +1,107 @@ +"""Climate platform for Saunum Leil Sauna Control Unit.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LeilSaunaConfigEntry +from .const import DELAYED_REFRESH_SECONDS +from .entity import LeilSaunaEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LeilSaunaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Saunum Leil Sauna climate entity.""" + coordinator = entry.runtime_data + async_add_entities([LeilSaunaClimate(coordinator)]) + + +class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity): + """Representation of a Saunum Leil Sauna climate entity.""" + + _attr_name = None + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = MIN_TEMPERATURE + _attr_max_temp = MAX_TEMPERATURE + + @property + def current_temperature(self) -> float | None: + """Return the current temperature in Celsius.""" + return self.coordinator.data.current_temperature + + @property + def target_temperature(self) -> float | None: + """Return the target temperature in Celsius.""" + return self.coordinator.data.target_temperature + + @property + def hvac_mode(self) -> HVACMode: + """Return current HVAC mode.""" + session_active = self.coordinator.data.session_active + return HVACMode.HEAT if session_active else HVACMode.OFF + + @property + def hvac_action(self) -> HVACAction | None: + """Return current HVAC action.""" + if not self.coordinator.data.session_active: + return HVACAction.OFF + + heater_elements_active = self.coordinator.data.heater_elements_active + return ( + HVACAction.HEATING + if heater_elements_active and heater_elements_active > 0 + else HVACAction.IDLE + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new HVAC mode.""" + try: + if hvac_mode == HVACMode.HEAT: + await self.coordinator.client.async_start_session() + else: + await self.coordinator.client.async_stop_session() + except SaunumException as err: + raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err + + # The device takes 1-2 seconds to turn heater elements on/off and + # update heater_elements_active. Wait and refresh again to ensure + # the HVAC action state reflects the actual heater status. + await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds()) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + try: + await self.coordinator.client.async_set_target_temperature( + int(kwargs[ATTR_TEMPERATURE]) + ) + except SaunumException as err: + raise HomeAssistantError( + f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/saunum/config_flow.py b/homeassistant/components/saunum/config_flow.py new file mode 100644 index 00000000000000..d1e60dc0ede0a0 --- /dev/null +++ b/homeassistant/components/saunum/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Saunum Leil Sauna Control Unit integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pysaunum import SaunumClient, SaunumException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } +) + + +async def validate_input(data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + host = data[CONF_HOST] + + client = SaunumClient(host=host) + + try: + await client.connect() + # Try to read data to verify communication + await client.async_get_data() + finally: + client.close() + + +class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Saunum Leil Sauna Control Unit.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Check for duplicate configuration + self._async_abort_entries_match(user_input) + + try: + await validate_input(user_input) + except SaunumException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Saunum Leil Sauna", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/saunum/const.py b/homeassistant/components/saunum/const.py new file mode 100644 index 00000000000000..70ab3a988fd8c7 --- /dev/null +++ b/homeassistant/components/saunum/const.py @@ -0,0 +1,16 @@ +"""Constants for the Saunum Leil Sauna Control Unit integration.""" + +from datetime import timedelta +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "saunum" + +# Platforms +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, +] + +DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60) +DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3) diff --git a/homeassistant/components/saunum/coordinator.py b/homeassistant/components/saunum/coordinator.py new file mode 100644 index 00000000000000..867e9715c974d9 --- /dev/null +++ b/homeassistant/components/saunum/coordinator.py @@ -0,0 +1,47 @@ +"""Coordinator for Saunum Leil Sauna Control Unit integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pysaunum import SaunumClient, SaunumData, SaunumException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +if TYPE_CHECKING: + from . import LeilSaunaConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]): + """Coordinator for fetching Saunum Leil Sauna data.""" + + config_entry: LeilSaunaConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: SaunumClient, + config_entry: LeilSaunaConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + config_entry=config_entry, + ) + self.client = client + + async def _async_update_data(self) -> SaunumData: + """Fetch data from the sauna controller.""" + try: + return await self.client.async_get_data() + except SaunumException as err: + raise UpdateFailed(f"Communication error: {err}") from err diff --git a/homeassistant/components/saunum/entity.py b/homeassistant/components/saunum/entity.py new file mode 100644 index 00000000000000..c0ed7bad5175aa --- /dev/null +++ b/homeassistant/components/saunum/entity.py @@ -0,0 +1,27 @@ +"""Base entity for Saunum Leil Sauna Control Unit integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LeilSaunaCoordinator + + +class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]): + """Base entity for Saunum Leil Sauna.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LeilSaunaCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name="Saunum Leil", + manufacturer="Saunum", + model="Leil Touch Panel", + ) diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json new file mode 100644 index 00000000000000..7a0c682547b28c --- /dev/null +++ b/homeassistant/components/saunum/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "saunum", + "name": "Saunum Leil", + "codeowners": ["@mettolen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/saunum", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pysaunum"], + "quality_scale": "silver", + "requirements": ["pysaunum==0.1.0"] +} diff --git a/homeassistant/components/saunum/quality_scale.yaml b/homeassistant/components/saunum/quality_scale.yaml new file mode 100644 index 00000000000000..c7a52ef2f850c5 --- /dev/null +++ b/homeassistant/components/saunum/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom 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: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver tier + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: Modbus TCP does not require authentication. + test-coverage: done + + # Gold tier + devices: done + diagnostics: todo + discovery: + status: exempt + comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network. + discovery-update-info: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Integration controls a single device; no dynamic device discovery needed. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Integration controls a single device; no dynamic device discovery needed. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json new file mode 100644 index 00000000000000..94d9727d68acb6 --- /dev/null +++ b/homeassistant/components/saunum/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "host": "IP address of your Saunum Leil sauna control unit" + }, + "description": "To find the IP address, navigate to Settings → Modbus Settings on your Leil touch panel" + } + } + } +} diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 05cf25af9b1fe0..7666fee3620bc2 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import timedelta +from datetime import datetime, timedelta from http import HTTPStatus import logging @@ -251,7 +251,7 @@ async def _async_update_data(self) -> XboxData: translation_key="request_exception", ) from e title_data[person.xuid] = title.titles[0] - + person.last_seen_date_time_utc = self.last_seen_timestamp(person) if ( self.current_friends - (new_friends := set(presence_data)) or not self.current_friends @@ -261,6 +261,22 @@ async def _async_update_data(self) -> XboxData: return XboxData(new_console_data, presence_data, title_data) + def last_seen_timestamp(self, person: Person) -> datetime | None: + """Returns the most recent of two timestamps.""" + + # The Xbox API constantly fluctuates the "last seen" timestamp between two close values, + # causing unnecessary updates. We only accept the most recent one as valild to prevent this. + if not (prev_data := self.data.presence.get(person.xuid)): + return person.last_seen_date_time_utc + + prev_dt = prev_data.last_seen_date_time_utc + cur_dt = person.last_seen_date_time_utc + + if prev_dt and cur_dt: + return max(prev_dt, cur_dt) + + return cur_dt + def remove_stale_devices(self, xuids: set[str]) -> None: """Remove stale devices from registry.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9c478ee8e7e2ce..fa45a9defb1532 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -255,6 +255,7 @@ "google_tasks", "google_translate", "google_travel_time", + "google_weather", "govee_ble", "govee_light_local", "gpsd", @@ -574,6 +575,7 @@ "samsungtv", "sanix", "satel_integra", + "saunum", "schlage", "scrape", "screenlogic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 64cbfec6da91fc..ddff8ad4232bef 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2451,6 +2451,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "google_weather": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Weather" + }, "google_wifi": { "integration_type": "hub", "config_flow": false, @@ -5751,6 +5757,12 @@ "config_flow": true, "iot_class": "local_push" }, + "saunum": { + "name": "Saunum Leil", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "schlage": { "name": "Schlage", "integration_type": "hub", diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 818c39cc1e6c83..f45a4b553262e0 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -59,7 +59,6 @@ area_registry as ar, device_registry as dr, entity_registry as er, - floor_registry as fr, issue_registry as ir, location as loc_helper, ) @@ -79,7 +78,7 @@ template_context_manager, template_cv, ) -from .helpers import raise_no_default +from .helpers import raise_no_default, resolve_area_id from .render_info import RenderInfo, render_info_cv if TYPE_CHECKING: @@ -1318,74 +1317,6 @@ def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | N return None -def floors(hass: HomeAssistant) -> Iterable[str | None]: - """Return all floors.""" - floor_registry = fr.async_get(hass) - return [floor.floor_id for floor in floor_registry.async_list_floors()] - - -def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the floor ID from a floor or area name, alias, device id, or entity id.""" - floor_registry = fr.async_get(hass) - lookup_str = str(lookup_value) - if floor := floor_registry.async_get_floor_by_name(lookup_str): - return floor.floor_id - floors_list = floor_registry.async_get_floors_by_alias(lookup_str) - if floors_list: - return floors_list[0].floor_id - - if aid := area_id(hass, lookup_value): - area_reg = ar.async_get(hass) - if area := area_reg.async_get_area(aid): - return area.floor_id - - return None - - -def floor_name(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the floor name from a floor id.""" - floor_registry = fr.async_get(hass) - if floor := floor_registry.async_get_floor(lookup_value): - return floor.name - - if aid := area_id(hass, lookup_value): - area_reg = ar.async_get(hass) - if ( - (area := area_reg.async_get_area(aid)) - and area.floor_id - and (floor := floor_registry.async_get_floor(area.floor_id)) - ): - return floor.name - - return None - - -def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: - """Return area IDs for a given floor ID or name.""" - _floor_id: str | None - # If floor_name returns a value, we know the input was an ID, otherwise we - # assume it's a name, and if it's neither, we return early - if floor_name(hass, floor_id_or_name) is not None: - _floor_id = floor_id_or_name - else: - _floor_id = floor_id(hass, floor_id_or_name) - if _floor_id is None: - return [] - - area_reg = ar.async_get(hass) - entries = ar.async_entries_for_floor(area_reg, _floor_id) - return [entry.id for entry in entries if entry.id] - - -def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: - """Return entity_ids for a given floor ID or name.""" - return [ - entity_id - for area_id in floor_areas(hass, floor_id_or_name) - for entity_id in area_entities(hass, area_id) - ] - - def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(ar.async_get(hass).areas) @@ -1393,37 +1324,7 @@ def areas(hass: HomeAssistant) -> Iterable[str | None]: def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area ID from an area name, alias, device id, or entity id.""" - area_reg = ar.async_get(hass) - lookup_str = str(lookup_value) - if area := area_reg.async_get_area_by_name(lookup_str): - return area.id - areas_list = area_reg.async_get_areas_by_alias(lookup_str) - if areas_list: - return areas_list[0].id - - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - # Import here, not at top-level to avoid circular import - from homeassistant.helpers import config_validation as cv # noqa: PLC0415 - - try: - cv.entity_id(lookup_value) - except vol.Invalid: - pass - else: - if entity := ent_reg.async_get(lookup_value): - # If entity has an area ID, return that - if entity.area_id: - return entity.area_id - # If entity has a device ID, return the area ID for the device - if entity.device_id and (device := dev_reg.async_get(entity.device_id)): - return device.area_id - - # Check if this could be a device ID - if device := dev_reg.async_get(lookup_value): - return device.area_id - - return None + return resolve_area_id(hass, lookup_value) def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str: @@ -2359,6 +2260,7 @@ def __init__( "homeassistant.helpers.template.extensions.CollectionExtension" ) self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") + self.add_extension("homeassistant.helpers.template.extensions.FloorExtension") self.add_extension("homeassistant.helpers.template.extensions.LabelExtension") self.add_extension("homeassistant.helpers.template.extensions.MathExtension") self.add_extension("homeassistant.helpers.template.extensions.RegexExtension") @@ -2462,23 +2364,6 @@ def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = self.globals["area_devices"] - # Floor extensions - - self.globals["floors"] = hassfunction(floors) - self.filters["floors"] = self.globals["floors"] - - self.globals["floor_id"] = hassfunction(floor_id) - self.filters["floor_id"] = self.globals["floor_id"] - - self.globals["floor_name"] = hassfunction(floor_name) - self.filters["floor_name"] = self.globals["floor_name"] - - self.globals["floor_areas"] = hassfunction(floor_areas) - self.filters["floor_areas"] = self.globals["floor_areas"] - - self.globals["floor_entities"] = hassfunction(floor_entities) - self.filters["floor_entities"] = self.globals["floor_entities"] - # Integration extensions self.globals["integration_entities"] = hassfunction(integration_entities) @@ -2534,8 +2419,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "device_id", "distance", "expand", - "floor_id", - "floor_name", "has_value", "is_device_attr", "is_hidden_entity", @@ -2557,8 +2440,6 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: "closest", "device_id", "expand", - "floor_id", - "floor_name", "has_value", ] hass_tests = [ diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py index b136bcd18b1f10..96cc11ccab1a01 100644 --- a/homeassistant/helpers/template/extensions/__init__.py +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -3,6 +3,7 @@ from .base64 import Base64Extension from .collection import CollectionExtension from .crypto import CryptoExtension +from .floors import FloorExtension from .labels import LabelExtension from .math import MathExtension from .regex import RegexExtension @@ -12,6 +13,7 @@ "Base64Extension", "CollectionExtension", "CryptoExtension", + "FloorExtension", "LabelExtension", "MathExtension", "RegexExtension", diff --git a/homeassistant/helpers/template/extensions/floors.py b/homeassistant/helpers/template/extensions/floors.py new file mode 100644 index 00000000000000..ef163e39b4dbac --- /dev/null +++ b/homeassistant/helpers/template/extensions/floors.py @@ -0,0 +1,157 @@ +"""Floor functions for Home Assistant templates.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, +) +from homeassistant.helpers.template.helpers import resolve_area_id + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class FloorExtension(BaseTemplateExtension): + """Extension for floor-related template functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the floor extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "floors", + self.floors, + as_global=True, + requires_hass=True, + ), + TemplateFunction( + "floor_id", + self.floor_id, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "floor_name", + self.floor_name, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), + TemplateFunction( + "floor_areas", + self.floor_areas, + as_global=True, + as_filter=True, + requires_hass=True, + ), + TemplateFunction( + "floor_entities", + self.floor_entities, + as_global=True, + as_filter=True, + requires_hass=True, + ), + ], + ) + + def floors(self) -> Iterable[str | None]: + """Return all floors.""" + floor_registry = fr.async_get(self.hass) + return [floor.floor_id for floor in floor_registry.async_list_floors()] + + def floor_id(self, lookup_value: Any) -> str | None: + """Get the floor ID from a floor or area name, alias, device id, or entity id.""" + floor_registry = fr.async_get(self.hass) + lookup_str = str(lookup_value) + + # Check if it's a floor name or alias + if floor := floor_registry.async_get_floor_by_name(lookup_str): + return floor.floor_id + floors_list = floor_registry.async_get_floors_by_alias(lookup_str) + if floors_list: + return floors_list[0].floor_id + + # Resolve to area ID and get floor from area + if aid := resolve_area_id(self.hass, lookup_value): + area_reg = ar.async_get(self.hass) + if area := area_reg.async_get_area(aid): + return area.floor_id + + return None + + def floor_name(self, lookup_value: str) -> str | None: + """Get the floor name from a floor id.""" + floor_registry = fr.async_get(self.hass) + + # Check if it's a floor ID + if floor := floor_registry.async_get_floor(lookup_value): + return floor.name + + # Resolve to area ID and get floor name from area's floor + if aid := resolve_area_id(self.hass, lookup_value): + area_reg = ar.async_get(self.hass) + if ( + (area := area_reg.async_get_area(aid)) + and area.floor_id + and (floor := floor_registry.async_get_floor(area.floor_id)) + ): + return floor.name + + return None + + def _floor_id_or_name(self, floor_id_or_name: str) -> str | None: + """Get the floor ID from a floor name or ID.""" + # If floor_name returns a value, we know the input was an ID, otherwise we + # assume it's a name, and if it's neither, we return early. + if self.floor_name(floor_id_or_name) is not None: + return floor_id_or_name + return self.floor_id(floor_id_or_name) + + def floor_areas(self, floor_id_or_name: str) -> Iterable[str]: + """Return area IDs for a given floor ID or name.""" + if (_floor_id := self._floor_id_or_name(floor_id_or_name)) is None: + return [] + + area_reg = ar.async_get(self.hass) + entries = ar.async_entries_for_floor(area_reg, _floor_id) + return [entry.id for entry in entries if entry.id] + + def floor_entities(self, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + ent_reg = er.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + entity_ids = [] + + for area_id in self.floor_areas(floor_id_or_name): + # Get entities directly assigned to the area + entity_ids.extend( + [ + entry.entity_id + for entry in er.async_entries_for_area(ent_reg, area_id) + ] + ) + + # Also add entities tied to a device in the area that don't themselves + # have an area specified since they inherit the area from the device + entity_ids.extend( + [ + entity.entity_id + for device in dr.async_entries_for_area(dev_reg, area_id) + for entity in er.async_entries_for_device(ent_reg, device.id) + if entity.area_id is None + ] + ) + + return entity_ids diff --git a/homeassistant/helpers/template/helpers.py b/homeassistant/helpers/template/helpers.py index 2e5942f3b74f84..71c95f77c47b86 100644 --- a/homeassistant/helpers/template/helpers.py +++ b/homeassistant/helpers/template/helpers.py @@ -2,10 +2,21 @@ from __future__ import annotations -from typing import Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn + +import voluptuous as vol + +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from .context import template_cv +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + def raise_no_default(function: str, value: Any) -> NoReturn: """Raise ValueError when no default is specified for template functions.""" @@ -14,3 +25,47 @@ def raise_no_default(function: str, value: Any) -> NoReturn: f"Template error: {function} got invalid input '{value}' when {action} template" f" '{template}' but no default was specified" ) + + +def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None: + """Resolve lookup value to an area ID. + + Accepts area name, area alias, device ID, or entity ID. + Returns the area ID or None if not found. + """ + area_reg = ar.async_get(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + lookup_str = str(lookup_value) + + # Check if it's an area name + if area := area_reg.async_get_area_by_name(lookup_str): + return area.id + + # Check if it's an area alias + areas_list = area_reg.async_get_areas_by_alias(lookup_str) + if areas_list: + return areas_list[0].id + + # Import here, not at top-level to avoid circular import + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 + + # Check if it's an entity ID + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + # If entity has an area ID, return that + if entity.area_id: + return entity.area_id + # If entity has a device ID, return the area ID for the device + if entity.device_id and (device := dev_reg.async_get(entity.device_id)): + return device.area_id + + # Check if it's a device ID + if device := dev_reg.async_get(lookup_value): + return device.area_id + + return None diff --git a/mypy.ini b/mypy.ini index 8c26c82409f5ac..fe18814bde0154 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2066,6 +2066,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_weather.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.govee_ble.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 76cedfd76250d1..a39c25c8a66fa5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1625,7 +1625,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==2.2.0 +openai==2.8.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -2339,6 +2339,9 @@ pysabnzbd==1.1.1 # homeassistant.components.saj pysaj==0.0.16 +# homeassistant.components.saunum +pysaunum==0.1.0 + # homeassistant.components.schlage pyschlage==2025.9.0 @@ -2469,6 +2472,9 @@ python-gitlab==1.6.0 # homeassistant.components.google_drive python-google-drive-api==0.1.0 +# homeassistant.components.google_weather +python-google-weather-api==0.0.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c788c02c4a62c..79ac88818b19d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1396,7 +1396,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==2.2.0 +openai==2.8.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1950,6 +1950,9 @@ pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 +# homeassistant.components.saunum +pysaunum==0.1.0 + # homeassistant.components.schlage pyschlage==2025.9.0 @@ -2044,6 +2047,9 @@ python-fullykiosk==0.0.14 # homeassistant.components.google_drive python-google-drive-api==0.1.0 +# homeassistant.components.google_weather +python-google-weather-api==0.0.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index f28f1bb917ea8f..969dc754ce4fb9 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -222,6 +222,7 @@ def setup_http_response(mock_http_response: Mock) -> None: "status": "needsAction", "position": "0000000000000001", "due": "2023-11-18T00:00:00Z", + "updated": "2023-11-10T23:00:00.333Z", }, { "id": "task-2", @@ -229,6 +230,8 @@ def setup_http_response(mock_http_response: Mock) -> None: "status": "completed", "position": "0000000000000002", "notes": "long description", + "updated": "2023-11-12T12:31:04.132Z", + "completed": "2023-11-12T12:31:04.132Z", }, ], }, @@ -262,6 +265,7 @@ async def test_get_items( "uid": "task-2", "summary": "Task 2", "status": "completed", + "completed": "2023-11-12T12:31:04.132000+00:00", "description": "long description", }, ] diff --git a/tests/components/google_weather/__init__.py b/tests/components/google_weather/__init__.py new file mode 100644 index 00000000000000..e13b9da1577758 --- /dev/null +++ b/tests/components/google_weather/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Weather integration.""" diff --git a/tests/components/google_weather/conftest.py b/tests/components/google_weather/conftest.py new file mode 100644 index 00000000000000..19ce293b429129 --- /dev/null +++ b/tests/components/google_weather/conftest.py @@ -0,0 +1,83 @@ +"""Common fixtures for the Google Weather tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from google_weather_api import ( + CurrentConditionsResponse, + DailyForecastResponse, + HourlyForecastResponse, +) +import pytest + +from homeassistant.components.google_weather.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_weather.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + title="Google Weather", + domain=DOMAIN, + data={ + CONF_API_KEY: "test-api-key", + }, + subentries_data=[ + ConfigSubentryDataWithId( + data={ + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + subentry_type="location", + title="Home", + subentry_id="home-subentry-id", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_google_weather_api() -> Generator[AsyncMock]: + """Mock Google Weather API.""" + current_conditions = CurrentConditionsResponse.from_dict( + load_json_object_fixture("current_conditions.json", DOMAIN) + ) + daily_forecast = DailyForecastResponse.from_dict( + load_json_object_fixture("daily_forecast.json", DOMAIN) + ) + hourly_forecast = HourlyForecastResponse.from_dict( + load_json_object_fixture("hourly_forecast.json", DOMAIN) + ) + + with ( + patch( + "homeassistant.components.google_weather.GoogleWeatherApi", autospec=True + ) as mock_api, + patch( + "homeassistant.components.google_weather.config_flow.GoogleWeatherApi", + new=mock_api, + ), + ): + api = mock_api.return_value + api.async_get_current_conditions.return_value = current_conditions + api.async_get_daily_forecast.return_value = daily_forecast + api.async_get_hourly_forecast.return_value = hourly_forecast + + yield api diff --git a/tests/components/google_weather/fixtures/current_conditions.json b/tests/components/google_weather/fixtures/current_conditions.json new file mode 100644 index 00000000000000..d2f81cc64517ff --- /dev/null +++ b/tests/components/google_weather/fixtures/current_conditions.json @@ -0,0 +1,88 @@ +{ + "currentTime": "2025-01-28T22:04:12.025273178Z", + "timeZone": { + "id": "America/Los_Angeles" + }, + "isDaytime": true, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny", + "description": { + "text": "Sunny", + "languageCode": "en" + }, + "type": "CLEAR" + }, + "temperature": { + "degrees": 13.7, + "unit": "CELSIUS" + }, + "feelsLikeTemperature": { + "degrees": 13.1, + "unit": "CELSIUS" + }, + "dewPoint": { + "degrees": 1.1, + "unit": "CELSIUS" + }, + "heatIndex": { + "degrees": 13.7, + "unit": "CELSIUS" + }, + "windChill": { + "degrees": 13.1, + "unit": "CELSIUS" + }, + "relativeHumidity": 42, + "uvIndex": 1, + "precipitation": { + "probability": { + "percent": 0, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "airPressure": { + "meanSeaLevelMillibars": 1019.16 + }, + "wind": { + "direction": { + "degrees": 335, + "cardinal": "NORTH_NORTHWEST" + }, + "speed": { + "value": 8, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 18, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "visibility": { + "distance": 16, + "unit": "KILOMETERS" + }, + "cloudCover": 0, + "currentConditionsHistory": { + "temperatureChange": { + "degrees": -0.6, + "unit": "CELSIUS" + }, + "maxTemperature": { + "degrees": 14.3, + "unit": "CELSIUS" + }, + "minTemperature": { + "degrees": 3.7, + "unit": "CELSIUS" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + } +} diff --git a/tests/components/google_weather/fixtures/daily_forecast.json b/tests/components/google_weather/fixtures/daily_forecast.json new file mode 100644 index 00000000000000..bc0bf98af0dac4 --- /dev/null +++ b/tests/components/google_weather/fixtures/daily_forecast.json @@ -0,0 +1,135 @@ +{ + "forecastDays": [ + { + "interval": { + "startTime": "2025-02-10T15:00:00Z", + "endTime": "2025-02-11T15:00:00Z" + }, + "displayDate": { + "year": 2025, + "month": 2, + "day": 10 + }, + "daytimeForecast": { + "interval": { + "startTime": "2025-02-10T15:00:00Z", + "endTime": "2025-02-11T03:00:00Z" + }, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/party_cloudy", + "description": { + "text": "Partly sunny", + "languageCode": "en" + }, + "type": "PARTLY_CLOUDY" + }, + "relativeHumidity": 54, + "uvIndex": 3, + "precipitation": { + "probability": { + "percent": 5, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "wind": { + "direction": { + "degrees": 280, + "cardinal": "WEST" + }, + "speed": { + "value": 6, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 14, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "cloudCover": 53 + }, + "nighttimeForecast": { + "interval": { + "startTime": "2025-02-11T03:00:00Z", + "endTime": "2025-02-11T15:00:00Z" + }, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/partly_clear", + "description": { + "text": "Partly cloudy", + "languageCode": "en" + }, + "type": "PARTLY_CLOUDY" + }, + "relativeHumidity": 85, + "uvIndex": 0, + "precipitation": { + "probability": { + "percent": 10, + "type": "RAIN_AND_SNOW" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "wind": { + "direction": { + "degrees": 201, + "cardinal": "SOUTH_SOUTHWEST" + }, + "speed": { + "value": 6, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 14, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "cloudCover": 70 + }, + "maxTemperature": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "minTemperature": { + "degrees": 1.5, + "unit": "CELSIUS" + }, + "feelsLikeMaxTemperature": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "feelsLikeMinTemperature": { + "degrees": 1.5, + "unit": "CELSIUS" + }, + "sunEvents": { + "sunriseTime": "2025-02-10T15:02:35.703929582Z", + "sunsetTime": "2025-02-11T01:43:00.762932858Z" + }, + "moonEvents": { + "moonPhase": "WAXING_GIBBOUS", + "moonriseTimes": ["2025-02-10T23:54:17.713157984Z"], + "moonsetTimes": ["2025-02-10T14:13:58.625181191Z"] + }, + "maxHeatIndex": { + "degrees": 13.3, + "unit": "CELSIUS" + }, + "iceThickness": { + "thickness": 0, + "unit": "MILLIMETERS" + } + } + ], + "timeZone": { + "id": "America/Los_Angeles" + } +} diff --git a/tests/components/google_weather/fixtures/hourly_forecast.json b/tests/components/google_weather/fixtures/hourly_forecast.json new file mode 100644 index 00000000000000..87866a1b6317a7 --- /dev/null +++ b/tests/components/google_weather/fixtures/hourly_forecast.json @@ -0,0 +1,92 @@ +{ + "forecastHours": [ + { + "interval": { + "startTime": "2025-02-05T23:00:00Z", + "endTime": "2025-02-06T00:00:00Z" + }, + "displayDateTime": { + "year": 2025, + "month": 2, + "day": 5, + "hours": 15, + "utcOffset": "-28800s" + }, + "isDaytime": true, + "weatherCondition": { + "iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny", + "description": { + "text": "Sunny", + "languageCode": "en" + }, + "type": "CLEAR" + }, + "temperature": { + "degrees": 12.7, + "unit": "CELSIUS" + }, + "feelsLikeTemperature": { + "degrees": 12, + "unit": "CELSIUS" + }, + "dewPoint": { + "degrees": 2.7, + "unit": "CELSIUS" + }, + "heatIndex": { + "degrees": 12.7, + "unit": "CELSIUS" + }, + "windChill": { + "degrees": 12, + "unit": "CELSIUS" + }, + "wetBulbTemperature": { + "degrees": 7.7, + "unit": "CELSIUS" + }, + "relativeHumidity": 51, + "uvIndex": 1, + "precipitation": { + "probability": { + "percent": 0, + "type": "RAIN" + }, + "qpf": { + "quantity": 0, + "unit": "MILLIMETERS" + } + }, + "thunderstormProbability": 0, + "airPressure": { + "meanSeaLevelMillibars": 1019.13 + }, + "wind": { + "direction": { + "degrees": 335, + "cardinal": "NORTH_NORTHWEST" + }, + "speed": { + "value": 10, + "unit": "KILOMETERS_PER_HOUR" + }, + "gust": { + "value": 19, + "unit": "KILOMETERS_PER_HOUR" + } + }, + "visibility": { + "distance": 16, + "unit": "KILOMETERS" + }, + "cloudCover": 0, + "iceThickness": { + "thickness": 0, + "unit": "MILLIMETERS" + } + } + ], + "timeZone": { + "id": "America/Los_Angeles" + } +} diff --git a/tests/components/google_weather/snapshots/test_weather.ambr b/tests/components/google_weather/snapshots/test_weather.ambr new file mode 100644 index 00000000000000..e33f864ab67926 --- /dev/null +++ b/tests/components/google_weather/snapshots/test_weather.ambr @@ -0,0 +1,191 @@ +# serializer version: 1 +# name: test_forecast_service[daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[hourly] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 12.0, + 'cloud_coverage': 0, + 'condition': 'sunny', + 'datetime': '2025-02-05T23:00:00Z', + 'dew_point': 2.7, + 'humidity': 51, + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'pressure': 1019.13, + 'temperature': 12.7, + 'uv_index': 1, + 'wind_bearing': 335, + 'wind_gust_speed': 19.0, + 'wind_speed': 10.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_service[twice_daily] + dict({ + 'weather.home': dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'is_daytime': True, + 'precipitation': 0.0, + 'precipitation_probability': 5, + 'temperature': 13.3, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + dict({ + 'apparent_temperature': 1.5, + 'cloud_coverage': 70, + 'condition': 'partlycloudy', + 'datetime': '2025-02-11T03:00:00Z', + 'humidity': 85, + 'is_daytime': False, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 1.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]), + }), + }) +# --- +# name: test_forecast_subscription + list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]) +# --- +# name: test_forecast_subscription.1 + list([ + dict({ + 'apparent_temperature': 13.3, + 'cloud_coverage': 53, + 'condition': 'partlycloudy', + 'datetime': '2025-02-10T15:00:00Z', + 'humidity': 54, + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 13.3, + 'templow': 1.5, + 'uv_index': 3, + 'wind_bearing': 280, + 'wind_gust_speed': 14.0, + 'wind_speed': 6.0, + }), + ]) +# --- +# name: test_weather[weather.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'google_weather', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'home-subentry-id', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather[weather.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 13.1, + 'attribution': 'Data from Google Weather', + 'cloud_coverage': 0.0, + 'dew_point': 1.1, + 'friendly_name': 'Home', + 'humidity': 42, + 'precipitation_unit': , + 'pressure': 1019.16, + 'pressure_unit': , + 'supported_features': , + 'temperature': 13.7, + 'temperature_unit': , + 'uv_index': 1.0, + 'visibility': 16.0, + 'visibility_unit': , + 'wind_bearing': 335, + 'wind_gust_speed': 18.0, + 'wind_speed': 8.0, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sunny', + }) +# --- diff --git a/tests/components/google_weather/test_config_flow.py b/tests/components/google_weather/test_config_flow.py new file mode 100644 index 00000000000000..719c545beb5be8 --- /dev/null +++ b/tests/components/google_weather/test_config_flow.py @@ -0,0 +1,375 @@ +"""Test the Google Weather config flow.""" + +from unittest.mock import AsyncMock + +from google_weather_api import GoogleWeatherApiError +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_weather.const import ( + CONF_REFERRER, + DOMAIN, + SECTION_API_KEY_OPTIONS, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, get_schema_suggested_value + + +def _assert_create_entry_result( + result: dict, expected_referrer: str | None = None +) -> None: + """Assert that the result is a create entry result.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Weather" + assert result["data"] == { + CONF_API_KEY: "test-api-key", + CONF_REFERRER: expected_referrer, + } + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "location" + assert subentry["title"] == "test-name" + assert subentry["data"] == { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + } + + +async def test_create_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test creating a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1, longitude=20.1 + ) + + _assert_create_entry_result(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_referrer( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, +) -> None: + """Test we get the form and optional referrer is specified.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + SECTION_API_KEY_OPTIONS: { + CONF_REFERRER: "test-referrer", + }, + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1, longitude=20.1 + ) + + _assert_create_entry_result(result, expected_referrer="test-referrer") + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error"), + [ + (GoogleWeatherApiError(), "cannot_connect"), + (ValueError(), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_google_weather_api: AsyncMock, + api_exception, + expected_error, +) -> None: + """Test we handle exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_google_weather_api.async_get_current_conditions.side_effect = api_exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + # On error, the form should have the previous user input + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_NAME) == "test-name" + assert get_schema_suggested_value(data_schema, CONF_API_KEY) == "test-api-key" + assert get_schema_suggested_value(data_schema, CONF_LOCATION) == { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + } + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + mock_google_weather_api.async_get_current_conditions.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + _assert_create_entry_result(result) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_api_key_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for config_entry with API key that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.2, + CONF_LONGITUDE: 20.2, + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_google_weather_api.async_get_current_conditions.call_count == 0 + + +async def test_form_location_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for a location that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "test-name", + CONF_API_KEY: "another-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1001, + CONF_LONGITUDE: 20.0999, + }, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_google_weather_api.async_get_current_conditions.call_count == 0 + + +async def test_form_not_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for config_entry different than the existing one.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "new-test-name", + CONF_API_KEY: "new-test-api-key", + CONF_LOCATION: { + CONF_LATITUDE: 10.1002, + CONF_LONGITUDE: 20.0998, + }, + }, + ) + + mock_google_weather_api.async_get_current_conditions.assert_called_once_with( + latitude=10.1002, longitude=20.0998 + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Weather" + assert result["data"] == { + CONF_API_KEY: "new-test-api-key", + CONF_REFERRER: None, + } + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "location" + assert subentry["title"] == "new-test-name" + assert subentry["data"] == { + CONF_LATITUDE: 10.1002, + CONF_LONGITUDE: 20.0998, + } + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test creating a location subentry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # After initial setup for 1 subentry, each API is called once + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + assert mock_google_weather_api.async_get_daily_forecast.call_count == 1 + assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "Work", + CONF_LOCATION: { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + }, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Work" + assert result2["data"] == { + CONF_LATITUDE: 30.1, + CONF_LONGITUDE: 40.1, + } + + # Initial setup: 1 of each API call + # Subentry flow validation: 1 current conditions call + # Reload with 2 subentries: 2 of each API call + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + 1 + 2 + assert mock_google_weather_api.async_get_daily_forecast.call_count == 1 + 2 + assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1 + 2 + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 2 + + +async def test_subentry_flow_location_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test user input for a location that already exists.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "location" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_NAME: "Work", + CONF_LOCATION: { + CONF_LATITUDE: 10.1, + CONF_LONGITUDE: 20.1, + }, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert len(entry.subentries) == 1 + + +async def test_subentry_flow_entry_not_loaded( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test creating a location subentry when the parent entry is not loaded.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "location"), + context={"source": "user"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" diff --git a/tests/components/google_weather/test_init.py b/tests/components/google_weather/test_init.py new file mode 100644 index 00000000000000..16462c8602341a --- /dev/null +++ b/tests/components/google_weather/test_init.py @@ -0,0 +1,69 @@ +"""Test init of Google Weather integration.""" + +from unittest.mock import AsyncMock + +from google_weather_api import GoogleWeatherApiError +import pytest + +from homeassistant.components.google_weather.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test a successful setup entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("weather.home") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + + +@pytest.mark.parametrize( + "failing_api_method", + [ + "async_get_current_conditions", + "async_get_daily_forecast", + "async_get_hourly_forecast", + ], +) +async def test_config_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + failing_api_method: str, +) -> None: + """Test for setup failure if an API call fails.""" + getattr( + mock_google_weather_api, failing_api_method + ).side_effect = GoogleWeatherApiError() + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test successful unload of entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/google_weather/test_weather.py b/tests/components/google_weather/test_weather.py new file mode 100644 index 00000000000000..128bcee02ac7be --- /dev/null +++ b/tests/components/google_weather/test_weather.py @@ -0,0 +1,214 @@ +"""Test weather of Google Weather integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from google_weather_api import GoogleWeatherApiError, WeatherCondition +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_weather.weather import _CONDITION_MAP +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_SUNNY, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + + +async def test_weather( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, +) -> None: + """Test states of the weather.""" + with patch( + "homeassistant.components.google_weather._PLATFORMS", [Platform.WEATHER] + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + entity_id = "weather.home" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + + mock_google_weather_api.async_get_current_conditions.side_effect = ( + GoogleWeatherApiError() + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + # Reset side effect, return a valid response again + mock_google_weather_api.async_get_current_conditions.side_effect = None + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" + mock_google_weather_api.async_get_current_conditions.assert_called_with( + latitude=10.1, longitude=20.1 + ) + + +async def test_manual_update_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, +) -> None: + """Test manual update entity via service homeassistant/update_entity.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await async_setup_component(hass, "homeassistant", {}) + + assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + mock_google_weather_api.async_get_current_conditions.assert_called_with( + latitude=10.1, longitude=20.1 + ) + + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["weather.home"]}, + blocking=True, + ) + + assert mock_google_weather_api.async_get_current_conditions.call_count == 2 + + +@pytest.mark.parametrize( + ("api_condition", "is_daytime", "expected_ha_condition"), + [ + (WeatherCondition.Type.CLEAR, True, ATTR_CONDITION_SUNNY), + (WeatherCondition.Type.CLEAR, False, ATTR_CONDITION_CLEAR_NIGHT), + (WeatherCondition.Type.PARTLY_CLOUDY, True, ATTR_CONDITION_PARTLYCLOUDY), + (WeatherCondition.Type.PARTLY_CLOUDY, False, ATTR_CONDITION_PARTLYCLOUDY), + (WeatherCondition.Type.TYPE_UNSPECIFIED, True, "unknown"), + ], +) +async def test_condition( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_google_weather_api: AsyncMock, + api_condition: WeatherCondition.Type, + is_daytime: bool, + expected_ha_condition: str, +) -> None: + """Test condition mapping.""" + mock_google_weather_api.async_get_current_conditions.return_value.weather_condition.type = api_condition + mock_google_weather_api.async_get_current_conditions.return_value.is_daytime = ( + is_daytime + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get("weather.home") + assert state.state == expected_ha_condition + + +def test_all_conditions_mapped() -> None: + """Ensure all WeatherCondition.Type enum members are in the _CONDITION_MAP.""" + for condition_type in WeatherCondition.Type: + assert condition_type in _CONDITION_MAP + + +@pytest.mark.parametrize(("forecast_type"), ["daily", "hourly", "twice_daily"]) +async def test_forecast_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, + forecast_type, +) -> None: + """Test forecast service.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + "entity_id": "weather.home", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_forecast_subscription( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_google_weather_api: AsyncMock, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.home", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(timedelta(hours=1) + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 57ff86634d3c2c..7e1759bb93472d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -19,8 +19,8 @@ }), 'labels': set({ }), - 'manufacturer': 'HUSQVARNA', - 'model': 'AUTOMOWER®', + 'manufacturer': 'Husqvarna', + 'model': 'Automower', 'model_id': '450XH', 'name': 'Test Mower 1', 'name_by_user': None, diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 1a93731cb7e444..e5d26400c37a40 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -199,6 +199,39 @@ async def mock_function(): assert mock.call_count == 1 +@pytest.mark.parametrize( + ("api_input", "model", "model_id"), + [ + ("HUSQVARNA AUTOMOWER® 450XH", "Automower", "450XH"), + ("Automower 315X", "Automower", "315X"), + ("Husqvarna Automower® 435 AWD", "Automower", "435 AWD"), + ("Husqvarna CEORA® 544 EPOS", "Ceora", "544 EPOS"), + ], +) +async def test_model_id_information( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_automower_client: AsyncMock, + device_registry: dr.DeviceRegistry, + values: dict[str, MowerAttributes], + api_input: str, + model: str, + model_id: str, +) -> None: + """Test model and model_id parsing.""" + values[TEST_MOWER_ID].system.model = api_input + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_MOWER_ID)}, + ) + assert reg_device is not None + assert reg_device.manufacturer == "Husqvarna" + assert reg_device.model == model + assert reg_device.model_id == model_id + + async def test_device_info( hass: HomeAssistant, mock_automower_client: AsyncMock, @@ -206,7 +239,7 @@ async def test_device_info( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Test select platform.""" + """Test device info.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 598ca5bbea9ba2..0bf38ec717597e 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -633,7 +633,7 @@ 'state': '1.0', }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-entry] +# name: test_numbers[door_lock][number.mock_door_lock_auto_relock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -651,7 +651,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_autorelock_time', + 'entity_id': 'number.mock_door_lock_auto_relock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -663,7 +663,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Autorelock time', + 'original_name': 'Auto-relock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -673,10 +673,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-state] +# name: test_numbers[door_lock][number.mock_door_lock_auto_relock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Autorelock time', + 'friendly_name': 'Mock Door Lock Auto-relock time', 'max': 65534, 'min': 0, 'mode': , @@ -684,7 +684,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_autorelock_time', + 'entity_id': 'number.mock_door_lock_auto_relock_time', 'last_changed': , 'last_reported': , 'last_updated': , @@ -806,7 +806,7 @@ 'state': '3', }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_autorelock_time-entry] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_auto_relock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -824,7 +824,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_with_unbolt_autorelock_time', + 'entity_id': 'number.mock_door_lock_with_unbolt_auto_relock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -836,7 +836,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Autorelock time', + 'original_name': 'Auto-relock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -846,10 +846,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_autorelock_time-state] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_auto_relock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock with unbolt Autorelock time', + 'friendly_name': 'Mock Door Lock with unbolt Auto-relock time', 'max': 65534, 'min': 0, 'mode': , @@ -857,7 +857,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_with_unbolt_autorelock_time', + 'entity_id': 'number.mock_door_lock_with_unbolt_auto_relock_time', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/saunum/__init__.py b/tests/components/saunum/__init__.py new file mode 100644 index 00000000000000..007297a4e81c20 --- /dev/null +++ b/tests/components/saunum/__init__.py @@ -0,0 +1 @@ +"""Tests for the Saunum integration.""" diff --git a/tests/components/saunum/conftest.py b/tests/components/saunum/conftest.py new file mode 100644 index 00000000000000..fa97c41d123a04 --- /dev/null +++ b/tests/components/saunum/conftest.py @@ -0,0 +1,77 @@ +"""Configuration for Saunum Leil integration tests.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from pysaunum import SaunumData +import pytest + +from homeassistant.components.saunum.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + entry_id="01K98T2T85R5GN0ZHYV25VFMMA", + title="Saunum Leil Sauna", + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + ) + + +@pytest.fixture +def mock_saunum_client() -> Generator[MagicMock]: + """Return a mocked Saunum client for config flow and integration tests.""" + with ( + patch( + "homeassistant.components.saunum.config_flow.SaunumClient", autospec=True + ) as mock_client_class, + patch("homeassistant.components.saunum.SaunumClient", new=mock_client_class), + ): + mock_client = mock_client_class.return_value + mock_client.is_connected = True + + # Create mock data for async_get_data + mock_data = SaunumData( + session_active=False, + sauna_type=0, + sauna_duration=120, + fan_duration=10, + target_temperature=80, + fan_speed=2, + light_on=False, + current_temperature=75.0, + on_time=3600, + heater_elements_active=0, + door_open=False, + alarm_door_open=False, + alarm_door_sensor=False, + alarm_thermal_cutoff=False, + alarm_internal_temp=False, + alarm_temp_sensor_short=False, + alarm_temp_sensor_open=False, + ) + + mock_client.async_get_data.return_value = mock_data + + yield mock_client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/saunum/snapshots/test_climate.ambr b/tests/components/saunum/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..573ab535455018 --- /dev/null +++ b/tests/components/saunum/snapshots/test_climate.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_entities[climate.saunum_leil-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 100, + 'min_temp': 40, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.saunum_leil', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.saunum_leil-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 75.0, + 'friendly_name': 'Saunum Leil', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 100, + 'min_temp': 40, + 'supported_features': , + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.saunum_leil', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/saunum/snapshots/test_init.ambr b/tests/components/saunum/snapshots/test_init.ambr new file mode 100644 index 00000000000000..473bfe6ce139e2 --- /dev/null +++ b/tests/components/saunum/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'saunum', + '01K98T2T85R5GN0ZHYV25VFMMA', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Saunum', + 'model': 'Leil Touch Panel', + 'model_id': None, + 'name': 'Saunum Leil', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/saunum/test_climate.py b/tests/components/saunum/test_climate.py new file mode 100644 index 00000000000000..d636d9136e80b8 --- /dev/null +++ b/tests/components/saunum/test_climate.py @@ -0,0 +1,232 @@ +"""Test the Saunum climate platform.""" + +from __future__ import annotations + +from dataclasses import replace + +from freezegun.api import FrozenDateTimeFactory +from pysaunum import SaunumException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "service_data", "client_method", "expected_args"), + [ + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "async_start_session", + (), + ), + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + "async_stop_session", + (), + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 85}, + "async_set_target_temperature", + (85,), + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_climate_service_calls( + hass: HomeAssistant, + mock_saunum_client, + service: str, + service_data: dict, + client_method: str, + expected_args: tuple, +) -> None: + """Test climate service calls.""" + entity_id = "climate.saunum_leil" + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + getattr(mock_saunum_client, client_method).assert_called_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("heater_elements_active", "expected_hvac_action"), + [ + (3, HVACAction.HEATING), + (0, HVACAction.IDLE), + ], +) +async def test_climate_hvac_actions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, + heater_elements_active: int, + expected_hvac_action: HVACAction, +) -> None: + """Test climate HVAC actions when session is active.""" + # Get the existing mock data and modify only what we need + mock_saunum_client.async_get_data.return_value.session_active = True + mock_saunum_client.async_get_data.return_value.heater_elements_active = ( + heater_elements_active + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.saunum_leil" + state = hass.states.get(entity_id) + assert state is not None + + assert state.state == HVACMode.HEAT + assert state.attributes.get(ATTR_HVAC_ACTION) == expected_hvac_action + + +@pytest.mark.parametrize( + ( + "current_temperature", + "target_temperature", + "expected_current", + "expected_target", + ), + [ + (None, 80, None, 80), + (35.0, 30, 35, 30), + ], +) +async def test_climate_temperature_edge_cases( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, + current_temperature: float | None, + target_temperature: int, + expected_current: float | None, + expected_target: int, +) -> None: + """Test climate with edge case temperature values.""" + # Get the existing mock data and modify only what we need + base_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + base_data, + current_temperature=current_temperature, + target_temperature=target_temperature, + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "climate.saunum_leil" + state = hass.states.get(entity_id) + assert state is not None + + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == expected_current + assert state.attributes.get(ATTR_TEMPERATURE) == expected_target + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_update_failure( + hass: HomeAssistant, + mock_saunum_client, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entity becomes unavailable when coordinator update fails.""" + entity_id = "climate.saunum_leil" + + # Verify entity is initially available + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # Make the next update fail + mock_saunum_client.async_get_data.side_effect = SaunumException("Read error") + + # Move time forward to trigger a coordinator update (60 seconds) + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Entity should now be unavailable + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service", "service_data", "client_method", "error_match"), + [ + ( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.HEAT}, + "async_start_session", + "Failed to set HVAC mode", + ), + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 85}, + "async_set_target_temperature", + "Failed to set temperature", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_action_error_handling( + hass: HomeAssistant, + mock_saunum_client, + service: str, + service_data: dict, + client_method: str, + error_match: str, +) -> None: + """Test error handling when climate actions fail.""" + entity_id = "climate.saunum_leil" + + # Make the client method raise an exception + getattr(mock_saunum_client, client_method).side_effect = SaunumException( + "Communication error" + ) + + # Attempt to call service should raise HomeAssistantError + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) diff --git a/tests/components/saunum/test_config_flow.py b/tests/components/saunum/test_config_flow.py new file mode 100644 index 00000000000000..f5b3f6e6b39476 --- /dev/null +++ b/tests/components/saunum/test_config_flow.py @@ -0,0 +1,98 @@ +"""Test the Saunum config flow.""" + +from __future__ import annotations + +from pysaunum import SaunumConnectionError, SaunumException +import pytest + +from homeassistant.components.saunum.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USER_INPUT = {CONF_HOST: "192.168.1.100"} + + +@pytest.mark.usefixtures("mock_saunum_client") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Saunum Leil Sauna" + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.parametrize( + ("side_effect", "error_base"), + [ + (SaunumConnectionError("Connection failed"), "cannot_connect"), + (SaunumException("Read error"), "cannot_connect"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_saunum_client, + side_effect: Exception, + error_base: str, +) -> None: + """Test error handling and recovery.""" + mock_saunum_client.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_base} + + # Test recovery - clear the error and try again + mock_saunum_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Saunum Leil Sauna" + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_saunum_client") +async def test_form_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test duplicate entry handling.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/saunum/test_init.py b/tests/components/saunum/test_init.py new file mode 100644 index 00000000000000..fe50e182f8482f --- /dev/null +++ b/tests/components/saunum/test_init.py @@ -0,0 +1,56 @@ +"""Test Saunum Leil integration setup and teardown.""" + +from pysaunum import SaunumConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.saunum.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, +) -> None: + """Test integration setup and unload.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + 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 + + +async def test_async_setup_entry_connection_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, +) -> None: + """Test integration setup fails when connection cannot be established.""" + mock_config_entry.add_to_hass(hass) + + mock_saunum_client.connect.side_effect = SaunumConnectionError("Connection failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("init_integration") +async def test_device_entry( + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry entry.""" + assert ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, "01K98T2T85R5GN0ZHYV25VFMMA")} + ) + ) + assert device_entry == snapshot diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index f4a7192b67c5b3..01080fb17d031d 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from pyvlx.lightening_device import LighteningDevice +from pyvlx.opening_device import Blind, Window from homeassistant.components.velux import DOMAIN -from homeassistant.components.velux.binary_sensor import Window -from homeassistant.components.velux.light import LighteningDevice from homeassistant.components.velux.scene import PyVLXScene as Scene from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant @@ -26,22 +26,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_velux_client() -> Generator[AsyncMock]: - """Mock a Velux client.""" - with ( - patch( - "homeassistant.components.velux.config_flow.PyVLX", - autospec=True, - ) as mock_client, - ): - client = mock_client.return_value - yield client - - -@pytest.fixture -def mock_user_config_entry() -> MockConfigEntry: - """Return the user config entry.""" +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry (unified fixture for all tests).""" return MockConfigEntry( + entry_id="test_entry_id", domain=DOMAIN, title="127.0.0.1", data={ @@ -66,7 +54,8 @@ def mock_discovered_config_entry() -> MockConfigEntry: ) -# fixtures for the binary sensor tests +# various types of fixtures for specific node types +# first the window @pytest.fixture def mock_window() -> AsyncMock: """Create a mock Velux window with a rain sensor.""" @@ -81,6 +70,27 @@ def mock_window() -> AsyncMock: return window +# a blind +@pytest.fixture +def mock_blind() -> AsyncMock: + """Create a mock Velux blind (cover with tilt).""" + blind = AsyncMock(spec=Blind, autospec=True) + blind.name = "Test Blind" + blind.serial_number = "4711" + # Standard cover position (used by current_cover_position) + blind.position = MagicMock(position_percent=40, closed=False) + blind.is_opening = False + blind.is_closing = False + # Orientation/tilt-related attributes and methods + blind.orientation = MagicMock(position_percent=25) + blind.open_orientation = AsyncMock() + blind.close_orientation = AsyncMock() + blind.stop_orientation = AsyncMock() + blind.set_orientation = AsyncMock() + return blind + + +# a light @pytest.fixture def mock_light() -> AsyncMock: """Create a mock Velux light.""" @@ -91,50 +101,72 @@ def mock_light() -> AsyncMock: return light +# fixture to create all other cover types via parameterization @pytest.fixture -def mock_scene() -> AsyncMock: - """Create a mock Velux scene.""" - scene = AsyncMock(spec=Scene, autospec=True) - scene.name = "Test Scene" - scene.scene_id = "1234" - scene.scene = AsyncMock() - return scene +def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: + """Create a mock Velux cover of specified type.""" + cover = AsyncMock(spec=request.param, autospec=True) + cover.name = f"Test {request.param.__name__}" + cover.serial_number = f"serial_{request.param.__name__}" + cover.is_opening = False + cover.is_closing = False + cover.position = MagicMock(position_percent=30, closed=False) + return cover @pytest.fixture def mock_pyvlx( - mock_window: MagicMock, mock_light: MagicMock, mock_scene: AsyncMock + mock_scene: AsyncMock, + mock_light: AsyncMock, + mock_window: AsyncMock, + mock_blind: AsyncMock, + request: pytest.FixtureRequest, ) -> Generator[MagicMock]: - """Create the library mock and patch PyVLX.""" + """Create the library mock and patch PyVLX in both component and config_flow. + + Tests can parameterize this fixture with the name of a node fixture to include + (e.g., "mock_window", "mock_blind", "mock_light", or "mock_cover_type"). + If no parameter is provided, an empty node list is used. + """ + pyvlx = MagicMock() - pyvlx.nodes = [mock_window, mock_light] + + if hasattr(request, "param"): + pyvlx.nodes = [request.getfixturevalue(request.param)] + else: + pyvlx.nodes = [mock_light, mock_blind, mock_window, mock_cover_type] + pyvlx.scenes = [mock_scene] + + # Async methods invoked by the integration/config flow pyvlx.load_scenes = AsyncMock() pyvlx.load_nodes = AsyncMock() + pyvlx.connect = AsyncMock() pyvlx.disconnect = AsyncMock() - with patch("homeassistant.components.velux.PyVLX", return_value=pyvlx): + with ( + patch("homeassistant.components.velux.PyVLX", return_value=pyvlx), + patch("homeassistant.components.velux.config_flow.PyVLX", return_value=pyvlx), + ): yield pyvlx @pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return a mock config entry.""" - return MockConfigEntry( - entry_id="test_entry_id", - domain=DOMAIN, - data={ - CONF_HOST: "testhost", - CONF_PASSWORD: "testpw", - }, - ) +def mock_scene() -> AsyncMock: + """Create a mock Velux scene.""" + scene = AsyncMock(spec=Scene, autospec=True) + scene.name = "Test Scene" + scene.scene_id = "1234" + scene.scene = AsyncMock() + return scene +# Fixture to set up the integration for testing, needs platform fixture, to be defined in each test file @pytest.fixture async def setup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pyvlx: MagicMock, + mock_pyvlx: AsyncMock, platform: Platform, ) -> None: """Set up the integration for testing.""" diff --git a/tests/components/velux/snapshots/test_cover.ambr b/tests/components/velux/snapshots/test_cover.ambr index 93001bd95456ba..498bacd93fb7d5 100644 --- a/tests/components/velux/snapshots/test_cover.ambr +++ b/tests/components/velux/snapshots/test_cover.ambr @@ -1,5 +1,261 @@ # serializer version: 1 -# name: test_cover_setup[cover.test_window-entry] +# name: test_blind_entity_setup[mock_blind][cover.test_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4711', + 'unit_of_measurement': None, + }) +# --- +# name: test_blind_entity_setup[mock_blind][cover.test_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 60, + 'current_tilt_position': 75, + 'device_class': 'blind', + 'friendly_name': 'Test Blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-Awning][cover.test_awning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_awning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_Awning', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-Awning][cover.test_awning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'awning', + 'friendly_name': 'Test Awning', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_awning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-GarageDoor][cover.test_garagedoor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_garagedoor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_GarageDoor', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-GarageDoor][cover.test_garagedoor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'garage', + 'friendly_name': 'Test GarageDoor', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_garagedoor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-Gate][cover.test_gate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_gate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_Gate', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-Gate][cover.test_gate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'gate', + 'friendly_name': 'Test Gate', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_gate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-RollerShutter][cover.test_rollershutter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_rollershutter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'serial_RollerShutter', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-RollerShutter][cover.test_rollershutter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 70, + 'device_class': 'shutter', + 'friendly_name': 'Test RollerShutter', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_rollershutter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entity_setup[mock_cover_type-Window][cover.test_window-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30,11 +286,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '123456789', + 'unique_id': 'serial_Window', 'unit_of_measurement': None, }) # --- -# name: test_cover_setup[cover.test_window-state] +# name: test_cover_entity_setup[mock_cover_type-Window][cover.test_window-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 70, diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 3d7745b4d42e68..ef040731314027 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests for the Velux binary sensor platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,22 +16,21 @@ from tests.common import MockConfigEntry +@pytest.fixture +def platform() -> Platform: + """Fixture to specify platform to test.""" + return Platform.BINARY_SENSOR + + +@pytest.mark.usefixtures("setup_integration") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("mock_pyvlx") async def test_rain_sensor_state( hass: HomeAssistant, mock_window: MagicMock, - mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the rain sensor.""" - mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): - # setup config entry - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - test_entity_id = "binary_sensor.test_window_rain_sensor" # simulate no rain detected @@ -62,8 +61,8 @@ async def test_rain_sensor_state( assert state.state == STATE_OFF +@pytest.mark.usefixtures("setup_integration") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("mock_pyvlx") async def test_rain_sensor_device_association( hass: HomeAssistant, mock_window: MagicMock, @@ -73,11 +72,6 @@ async def test_rain_sensor_device_association( ) -> None: """Test the rain sensor is properly associated with its device.""" - mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - test_entity_id = "binary_sensor.test_window_rain_sensor" # Verify entity exists diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 22ad10e1188020..960d025efbd7d1 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -26,7 +26,7 @@ async def test_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_velux_client: AsyncMock, + mock_pyvlx: AsyncMock, ) -> None: """Test starting a flow by user with valid values.""" result = await hass.config_entries.flow.async_init( @@ -53,8 +53,8 @@ async def test_user_flow( } assert not result["result"].unique_id - mock_velux_client.disconnect.assert_called_once() - mock_velux_client.connect.assert_called_once() + mock_pyvlx.disconnect.assert_called_once() + mock_pyvlx.connect.assert_called_once() @pytest.mark.parametrize( @@ -66,14 +66,14 @@ async def test_user_flow( ) async def test_user_errors( hass: HomeAssistant, - mock_velux_client: AsyncMock, + mock_pyvlx: AsyncMock, exception: Exception, error: str, mock_setup_entry: AsyncMock, ) -> None: """Test starting a flow by user but with exceptions.""" - mock_velux_client.connect.side_effect = exception + mock_pyvlx.connect.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -95,9 +95,9 @@ async def test_user_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": error} - mock_velux_client.connect.assert_called_once() + mock_pyvlx.connect.assert_called_once() - mock_velux_client.connect.side_effect = None + mock_pyvlx.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -112,11 +112,11 @@ async def test_user_errors( async def test_user_flow_duplicate_entry( hass: HomeAssistant, - mock_user_config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, ) -> None: """Test initialized flow with a duplicate entry.""" - mock_user_config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -140,7 +140,7 @@ async def test_user_flow_duplicate_entry( async def test_dhcp_discovery( hass: HomeAssistant, - mock_velux_client: AsyncMock, + mock_pyvlx: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: """Test we can setup from dhcp discovery.""" @@ -168,8 +168,8 @@ async def test_dhcp_discovery( } assert result["result"].unique_id == "VELUX_KLF_ABCD" - mock_velux_client.disconnect.assert_called() - mock_velux_client.connect.assert_called() + mock_pyvlx.disconnect.assert_called() + mock_pyvlx.connect.assert_called() @pytest.mark.parametrize( @@ -181,7 +181,7 @@ async def test_dhcp_discovery( ) async def test_dhcp_discovery_errors( hass: HomeAssistant, - mock_velux_client: AsyncMock, + mock_pyvlx: AsyncMock, exception: Exception, error: str, mock_setup_entry: AsyncMock, @@ -196,7 +196,7 @@ async def test_dhcp_discovery_errors( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_confirm" - mock_velux_client.connect.side_effect = exception + mock_pyvlx.connect.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -207,7 +207,7 @@ async def test_dhcp_discovery_errors( assert result["step_id"] == "discovery_confirm" assert result["errors"] == {"base": error} - mock_velux_client.connect.side_effect = None + mock_pyvlx.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -226,7 +226,7 @@ async def test_dhcp_discovery_errors( async def test_dhcp_discovery_already_configured( hass: HomeAssistant, - mock_velux_client: AsyncMock, + mock_pyvlx: AsyncMock, mock_discovered_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, ) -> None: @@ -245,15 +245,15 @@ async def test_dhcp_discovery_already_configured( async def test_dhcp_discover_unique_id( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_velux_client: AsyncMock, - mock_user_config_entry: MockConfigEntry, + mock_pyvlx: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test dhcp discovery when already configured.""" - mock_user_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_user_config_entry.state is ConfigEntryState.LOADED - assert mock_user_config_entry.unique_id is None + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.unique_id is None result = await hass.config_entries.flow.async_init( DOMAIN, @@ -263,20 +263,20 @@ async def test_dhcp_discover_unique_id( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD" + assert mock_config_entry.unique_id == "VELUX_KLF_ABCD" async def test_dhcp_discovery_not_loaded( hass: HomeAssistant, - mock_velux_client: AsyncMock, - mock_user_config_entry: MockConfigEntry, + mock_pyvlx: AsyncMock, + mock_config_entry: MockConfigEntry, mock_setup_entry: AsyncMock, ) -> None: """Test dhcp discovery when entry with same host not loaded.""" - mock_user_config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - assert mock_user_config_entry.state is not ConfigEntryState.LOADED - assert mock_user_config_entry.unique_id is None + assert mock_config_entry.state is not ConfigEntryState.LOADED + assert mock_config_entry.unique_id is None result = await hass.config_entries.flow.async_init( DOMAIN, @@ -286,4 +286,4 @@ async def test_dhcp_discovery_not_loaded( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - assert mock_user_config_entry.unique_id is None + assert mock_config_entry.unique_id is None diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index bd16b88b5f06bd..62e7c5eabb4a03 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import pytest +from pyvlx.opening_device import Awning, GarageDoor, Gate, RollerShutter, Window from homeassistant.components.velux import DOMAIN from homeassistant.const import STATE_CLOSED, STATE_OPEN, Platform @@ -21,14 +22,39 @@ def platform() -> Platform: @pytest.mark.usefixtures("setup_integration") -async def test_cover_setup( +@pytest.mark.parametrize("mock_pyvlx", ["mock_blind"], indirect=True) +async def test_blind_entity_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the entity and validate registry metadata.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.usefixtures("mock_cover_type") +@pytest.mark.parametrize( + "mock_cover_type", [Awning, GarageDoor, Gate, RollerShutter, Window], indirect=True +) +@pytest.mark.parametrize( + "mock_pyvlx", + ["mock_cover_type"], + indirect=True, +) +async def test_cover_entity_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Snapshot the cover entity (registry + state).""" + """Snapshot the entity and validate entity metadata.""" await snapshot_platform( hass, entity_registry, @@ -36,31 +62,39 @@ async def test_cover_setup( mock_config_entry.entry_id, ) - # Get the cover entity setup and test device association + +@pytest.mark.usefixtures("setup_integration") +async def test_cover_device_association( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the cover entity device association.""" + entity_entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entity_entries) == 1 - entry = entity_entries[0] - - assert entry.device_id is not None - device_entry = device_registry.async_get(entry.device_id) - assert device_entry is not None - assert (DOMAIN, f"{123456789}") in device_entry.identifiers - assert device_entry.via_device_id is not None - via_device_entry = device_registry.async_get(device_entry.via_device_id) - assert via_device_entry is not None - assert ( - DOMAIN, - f"gateway_{mock_config_entry.entry_id}", - ) in via_device_entry.identifiers + assert len(entity_entries) >= 1 + + for entry in entity_entries: + assert entry.device_id is not None + device_entry = device_registry.async_get(entry.device_id) + assert device_entry is not None + assert (DOMAIN, entry.unique_id) in device_entry.identifiers + assert device_entry.via_device_id is not None + via_device_entry = device_registry.async_get(device_entry.via_device_id) + assert via_device_entry is not None + assert ( + DOMAIN, + f"gateway_{mock_config_entry.entry_id}", + ) in via_device_entry.identifiers @pytest.mark.usefixtures("setup_integration") async def test_cover_closed( hass: HomeAssistant, mock_window: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: """Test the cover closed state.""" diff --git a/tests/helpers/template/extensions/test_floors.py b/tests/helpers/template/extensions/test_floors.py new file mode 100644 index 00000000000000..97981de129d751 --- /dev/null +++ b/tests/helpers/template/extensions/test_floors.py @@ -0,0 +1,297 @@ +"""Test floor template functions.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, +) + +from tests.common import MockConfigEntry +from tests.helpers.template.helpers import assert_result_info, render_to_info + + +async def test_floors( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, +) -> None: + """Test floors function.""" + + # Test no floors + info = render_to_info(hass, "{{ floors() }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test one floor + floor1 = floor_registry.async_create("First floor") + info = render_to_info(hass, "{{ floors() }}") + assert_result_info(info, [floor1.floor_id]) + assert info.rate_limit is None + + # Test multiple floors + floor2 = floor_registry.async_create("Second floor") + info = render_to_info(hass, "{{ floors() }}") + assert_result_info(info, [floor1.floor_id, floor2.floor_id]) + assert info.rate_limit is None + + +async def test_floor_id( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_id function.""" + + def test(value: str, expected: str | None) -> None: + info = render_to_info(hass, f"{{{{ floor_id('{value}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{value}' | floor_id }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Test non existing floor name + test("Third floor", None) + + # Test wrong value type + info = render_to_info(hass, "{{ floor_id(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test with an actual floor + floor = floor_registry.async_create("First floor") + test("First floor", floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + area_entry_hex = area_registry.async_get_or_create("123abc") + + # Create area, device, entity and assign area to device and entity + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_hex.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_hex.id + ) + + test(area_entry_hex.id, None) + test(device_entry.id, None) + test(entity_entry.entity_id, None) + + # Add floor to area + area_entry_hex = area_registry.async_update( + area_entry_hex.id, floor_id=floor.floor_id + ) + + test(area_entry_hex.id, floor.floor_id) + test(device_entry.id, floor.floor_id) + test(entity_entry.entity_id, floor.floor_id) + + +async def test_floor_name( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_name function.""" + + def test(value: str, expected: str | None) -> None: + info = render_to_info(hass, f"{{{{ floor_name('{value}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{value}' | floor_name }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Test non existing floor name + test("Third floor", None) + + # Test wrong value type + info = render_to_info(hass, "{{ floor_name(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_name }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test existing floor ID + floor = floor_registry.async_create("First floor") + test(floor.floor_id, floor.name) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + area_entry_hex = area_registry.async_get_or_create("123abc") + + # Create area, device, entity and assign area to device and entity + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_hex.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_hex.id + ) + + test(area_entry_hex.id, None) + test(device_entry.id, None) + test(entity_entry.entity_id, None) + + # Add floor to area + area_entry_hex = area_registry.async_update( + area_entry_hex.id, floor_id=floor.floor_id + ) + + test(area_entry_hex.id, floor.name) + test(device_entry.id, floor.name) + test(entity_entry.entity_id, floor.name) + + +async def test_floor_areas( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test floor_areas function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_areas('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_areas(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area = area_registry.async_create("Living room") + area_registry.async_update(area.id, floor_id=floor.floor_id) + + # Get areas by floor ID + info = render_to_info(hass, f"{{{{ floor_areas('{floor.floor_id}') }}}}") + assert_result_info(info, [area.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_areas }}}}") + assert_result_info(info, [area.id]) + assert info.rate_limit is None + + # Get areas by floor name + info = render_to_info(hass, f"{{{{ floor_areas('{floor.name}') }}}}") + assert_result_info(info, [area.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_areas }}}}") + assert_result_info(info, [area.id]) + assert info.rate_limit is None + + +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None diff --git a/tests/helpers/template/test_helpers.py b/tests/helpers/template/test_helpers.py index 64d1c5a93642f7..d166080aca14b4 100644 --- a/tests/helpers/template/test_helpers.py +++ b/tests/helpers/template/test_helpers.py @@ -2,7 +2,15 @@ import pytest -from homeassistant.helpers.template.helpers import raise_no_default +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.template.helpers import raise_no_default, resolve_area_id + +from tests.common import MockConfigEntry def test_raise_no_default() -> None: @@ -12,3 +20,87 @@ def test_raise_no_default() -> None: match="Template error: test got invalid input 'invalid' when rendering or compiling template '' but no default was specified", ): raise_no_default("test", "invalid") + + +async def test_resolve_area_id( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test resolve_area_id function.""" + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Test non existing entity id + assert resolve_area_id(hass, "sensor.fake") is None + + # Test non existing device id (hex value) + assert resolve_area_id(hass, "123abc") is None + + # Test non existing area name + assert resolve_area_id(hass, "fake area name") is None + + # Test wrong value type + assert resolve_area_id(hass, 56) is None + + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + + # Test device with single entity, which has no area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + assert resolve_area_id(hass, device_entry.id) is None + assert resolve_area_id(hass, entity_entry.entity_id) is None + + # Test device ID, entity ID and area name as input with area name that looks like + # a device ID + area_entry_hex = area_registry.async_get_or_create("123abc") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_hex.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_hex.id + ) + + assert resolve_area_id(hass, device_entry.id) == area_entry_hex.id + assert resolve_area_id(hass, entity_entry.entity_id) == area_entry_hex.id + assert resolve_area_id(hass, area_entry_hex.name) == area_entry_hex.id + + # Test device ID, entity ID and area name as input with area name that looks like an + # entity ID + area_entry_entity_id = area_registry.async_get_or_create("sensor.fake") + device_entry = device_registry.async_update_device( + device_entry.id, area_id=area_entry_entity_id.id + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=area_entry_entity_id.id + ) + + assert resolve_area_id(hass, device_entry.id) == area_entry_entity_id.id + assert resolve_area_id(hass, entity_entry.entity_id) == area_entry_entity_id.id + assert resolve_area_id(hass, area_entry_entity_id.name) == area_entry_entity_id.id + + # Make sure that when entity doesn't have an area but its device does, that's what + # gets returned + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, area_id=None + ) + + assert resolve_area_id(hass, entity_entry.entity_id) == area_entry_entity_id.id + + # Test area alias + area_with_alias = area_registry.async_get_or_create("Living Room") + area_registry.async_update(area_with_alias.id, aliases={"lounge", "family room"}) + + assert resolve_area_id(hass, "Living Room") == area_with_alias.id + assert resolve_area_id(hass, "lounge") == area_with_alias.id + assert resolve_area_id(hass, "family room") == area_with_alias.id diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 7548dd283e1dc8..7da8f9f0abb376 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -40,7 +40,6 @@ device_registry as dr, entity, entity_registry as er, - floor_registry as fr, issue_registry as ir, template, translation, @@ -4419,289 +4418,6 @@ async def test_lru_increases_with_many_entities(hass: HomeAssistant) -> None: ) -async def test_floors( - hass: HomeAssistant, - floor_registry: fr.FloorRegistry, -) -> None: - """Test floors function.""" - - # Test no floors - info = render_to_info(hass, "{{ floors() }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test one floor - floor1 = floor_registry.async_create("First floor") - info = render_to_info(hass, "{{ floors() }}") - assert_result_info(info, [floor1.floor_id]) - assert info.rate_limit is None - - # Test multiple floors - floor2 = floor_registry.async_create("Second floor") - info = render_to_info(hass, "{{ floors() }}") - assert_result_info(info, [floor1.floor_id, floor2.floor_id]) - assert info.rate_limit is None - - -async def test_floor_id( - hass: HomeAssistant, - floor_registry: fr.FloorRegistry, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test floor_id function.""" - - def test(value: str, expected: str | None) -> None: - info = render_to_info(hass, f"{{{{ floor_id('{value}') }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{value}' | floor_id }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - # Test non existing floor name - test("Third floor", None) - - # Test wrong value type - info = render_to_info(hass, "{{ floor_id(42) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | floor_id }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test with an actual floor - floor = floor_registry.async_create("First floor") - test("First floor", floor.floor_id) - - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - area_entry_hex = area_registry.async_get_or_create("123abc") - - # Create area, device, entity and assign area to device and entity - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - device_id=device_entry.id, - ) - device_entry = device_registry.async_update_device( - device_entry.id, area_id=area_entry_hex.id - ) - entity_entry = entity_registry.async_update_entity( - entity_entry.entity_id, area_id=area_entry_hex.id - ) - - test(area_entry_hex.id, None) - test(device_entry.id, None) - test(entity_entry.entity_id, None) - - # Add floor to area - area_entry_hex = area_registry.async_update( - area_entry_hex.id, floor_id=floor.floor_id - ) - - test(area_entry_hex.id, floor.floor_id) - test(device_entry.id, floor.floor_id) - test(entity_entry.entity_id, floor.floor_id) - - -async def test_floor_name( - hass: HomeAssistant, - floor_registry: fr.FloorRegistry, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test floor_name function.""" - - def test(value: str, expected: str | None) -> None: - info = render_to_info(hass, f"{{{{ floor_name('{value}') }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{value}' | floor_name }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - # Test non existing floor name - test("Third floor", None) - - # Test wrong value type - info = render_to_info(hass, "{{ floor_name(42) }}") - assert_result_info(info, None) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | floor_name }}") - assert_result_info(info, None) - assert info.rate_limit is None - - # Test existing floor ID - floor = floor_registry.async_create("First floor") - test(floor.floor_id, floor.name) - - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - area_entry_hex = area_registry.async_get_or_create("123abc") - - # Create area, device, entity and assign area to device and entity - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "5678", - config_entry=config_entry, - device_id=device_entry.id, - ) - device_entry = device_registry.async_update_device( - device_entry.id, area_id=area_entry_hex.id - ) - entity_entry = entity_registry.async_update_entity( - entity_entry.entity_id, area_id=area_entry_hex.id - ) - - test(area_entry_hex.id, None) - test(device_entry.id, None) - test(entity_entry.entity_id, None) - - # Add floor to area - area_entry_hex = area_registry.async_update( - area_entry_hex.id, floor_id=floor.floor_id - ) - - test(area_entry_hex.id, floor.name) - test(device_entry.id, floor.name) - test(entity_entry.entity_id, floor.name) - - -async def test_floor_areas( - hass: HomeAssistant, - floor_registry: fr.FloorRegistry, - area_registry: ar.AreaRegistry, -) -> None: - """Test floor_areas function.""" - - # Test non existing floor ID - info = render_to_info(hass, "{{ floor_areas('skyring') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'skyring' | floor_areas }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ floor_areas(42) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | floor_areas }}") - assert_result_info(info, []) - assert info.rate_limit is None - - floor = floor_registry.async_create("First floor") - area = area_registry.async_create("Living room") - area_registry.async_update(area.id, floor_id=floor.floor_id) - - # Get areas by floor ID - info = render_to_info(hass, f"{{{{ floor_areas('{floor.floor_id}') }}}}") - assert_result_info(info, [area.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_areas }}}}") - assert_result_info(info, [area.id]) - assert info.rate_limit is None - - # Get entities by floor name - info = render_to_info(hass, f"{{{{ floor_areas('{floor.name}') }}}}") - assert_result_info(info, [area.id]) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_areas }}}}") - assert_result_info(info, [area.id]) - assert info.rate_limit is None - - -async def test_floor_entities( - hass: HomeAssistant, - floor_registry: fr.FloorRegistry, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test floor_entities function.""" - - # Test non existing floor ID - info = render_to_info(hass, "{{ floor_entities('skyring') }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") - assert_result_info(info, []) - assert info.rate_limit is None - - # Test wrong value type - info = render_to_info(hass, "{{ floor_entities(42) }}") - assert_result_info(info, []) - assert info.rate_limit is None - - info = render_to_info(hass, "{{ 42 | floor_entities }}") - assert_result_info(info, []) - assert info.rate_limit is None - - floor = floor_registry.async_create("First floor") - area1 = area_registry.async_create("Living room") - area2 = area_registry.async_create("Dining room") - area_registry.async_update(area1.id, floor_id=floor.floor_id) - area_registry.async_update(area2.id, floor_id=floor.floor_id) - - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "living_room", - config_entry=config_entry, - ) - entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) - entity_entry = entity_registry.async_get_or_create( - "light", - "hue", - "dining_room", - config_entry=config_entry, - ) - entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) - - # Get entities by floor ID - expected = ["light.hue_living_room", "light.hue_dining_room"] - info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - # Get entities by floor name - info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") - assert_result_info(info, expected) - assert info.rate_limit is None - - async def test_template_thread_safety_checks(hass: HomeAssistant) -> None: """Test template thread safety checks.""" hass.states.async_set("sensor.test", "23")