diff --git a/CODEOWNERS b/CODEOWNERS index 206c31810051..c7a1b38d075a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2106,6 +2106,8 @@ CLAUDE.md @home-assistant/core /tests/components/wiim/ @Linkplay2020 /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj +/homeassistant/components/willow/ @paxprz +/tests/components/willow/ @paxprz /homeassistant/components/window/ @home-assistant/core /tests/components/window/ @home-assistant/core /homeassistant/components/wirelesstag/ @sergeymaysak diff --git a/homeassistant/components/willow/__init__.py b/homeassistant/components/willow/__init__.py new file mode 100644 index 000000000000..b0fba546f21f --- /dev/null +++ b/homeassistant/components/willow/__init__.py @@ -0,0 +1,54 @@ +"""The Willow integration.""" + +from pywillow import WillowClient + +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import DOMAIN +from .coordinator import WillowConfigEntry, WillowDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: WillowConfigEntry) -> bool: + """Set up Willow from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + "OAuth2 implementation temporarily unavailable, will retry" + ) from err + + session = OAuth2Session(hass, entry, implementation) + + client = WillowClient( + aiohttp_client.async_get_clientsession(hass), + session.token[CONF_ACCESS_TOKEN], + ) + coordinator = WillowDataUpdateCoordinator( + hass, + entry, + client, + session, + ) + 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: WillowConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/willow/application_credentials.py b/homeassistant/components/willow/application_credentials.py new file mode 100644 index 000000000000..4363ee41c45c --- /dev/null +++ b/homeassistant/components/willow/application_credentials.py @@ -0,0 +1,55 @@ +"""Application credentials platform for the Willow integration.""" + +from typing import Any, override + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + LocalOAuth2Implementation, +) + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +DEFAULT_EXPIRES_IN = 10 * 365 * 24 * 60 * 60 + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation.""" + return WillowOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + credential.client_secret, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ) + + +class WillowOAuth2Implementation(LocalOAuth2Implementation): + """Willow OAuth2 implementation.""" + + @override + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve the authorization code to tokens.""" + token = await super().async_resolve_external_data(external_data) + return self._normalize_token(token) + + @override + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh a token.""" + if not token.get("refresh_token"): + return token + + new_token = await super()._async_refresh_token(token) + return self._normalize_token(new_token) + + def _normalize_token(self, token: dict) -> dict: + """Normalize Willow token response.""" + # Willow tokens have no expiry, so we use a long-lived default + if token.get("expires_in") is None: + token["expires_in"] = DEFAULT_EXPIRES_IN + + return token diff --git a/homeassistant/components/willow/config_flow.py b/homeassistant/components/willow/config_flow.py new file mode 100644 index 000000000000..c3204c4bf99c --- /dev/null +++ b/homeassistant/components/willow/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Willow.""" + +import logging +from typing import Any, override + +from pywillow import WillowAuthError, WillowClient + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Willow OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + @override + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + @override + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + scopes = ["read"] + return {"scope": " ".join(scopes)} + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow start.""" + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Willow"), + ) + return await super().async_step_user(user_input) + + @override + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an OAuth config entry after validating the account.""" + + willow = WillowClient( + session=aiohttp_client.async_get_clientsession(self.hass), + token=data[CONF_TOKEN][CONF_ACCESS_TOKEN], + ) + + try: + profile = await willow.get_profile() + except WillowAuthError: + return self.async_abort(reason="invalid_auth") + except Exception: + self.logger.exception("Unexpected error") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(str(profile["id"])) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=profile["username"], + data=data, + ) diff --git a/homeassistant/components/willow/const.py b/homeassistant/components/willow/const.py new file mode 100644 index 000000000000..6fc71ca24692 --- /dev/null +++ b/homeassistant/components/willow/const.py @@ -0,0 +1,16 @@ +"""Constants for the Willow integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "willow" +LOGGER = logging.getLogger(__package__) +MANUFACTURER = "PW Willow Pty Ltd" +SCAN_INTERVAL = timedelta(minutes=15) + +OAUTH2_AUTHORIZE = "https://api.plantwithwillow.com.au/oauth/authorize/" +OAUTH2_TOKEN = "https://api.plantwithwillow.com.au/oauth/token/" +OAUTH2_CLIENT_ID = "ea4a4aed-9de2-4dd3-bbe4-7ef657cffdda" +OAUTH2_CLIENT_SECRET = ( + "df58fd78e62310b77be94290788d1439766982b0056928d5d26b3a3c526dded2" +) diff --git a/homeassistant/components/willow/coordinator.py b/homeassistant/components/willow/coordinator.py new file mode 100644 index 000000000000..8e95d7d94273 --- /dev/null +++ b/homeassistant/components/willow/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the Willow integration.""" + +from typing import override + +from aiohttp import ClientError +from pywillow import ( + WillowApiError, + WillowAuthError, + WillowClient, + WillowDevice, + WillowProfile, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + +type WillowConfigEntry = ConfigEntry[WillowDataUpdateCoordinator] + + +class WillowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, WillowDevice]]): + """Coordinator for Willow data updates.""" + + config_entry: WillowConfigEntry + profile: WillowProfile + + def __init__( + self, + hass: HomeAssistant, + config_entry: WillowConfigEntry, + client: WillowClient, + oauth_session: OAuth2Session, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self._oauth_session = oauth_session + + @override + async def _async_setup(self) -> None: + """Fetch the Willow profile once.""" + await self._oauth_session.async_ensure_token_valid() + self.client.update_token(self._oauth_session.token[CONF_ACCESS_TOKEN]) + try: + self.profile = await self.client.get_profile() + except WillowAuthError as err: + raise ConfigEntryAuthFailed from err + except (ClientError, WillowApiError) as err: + raise UpdateFailed(f"Unable to fetch Willow profile: {err}") from err + + @override + async def _async_update_data(self) -> dict[str, WillowDevice]: + """Fetch Willow devices.""" + await self._oauth_session.async_ensure_token_valid() + self.client.update_token(self._oauth_session.token[CONF_ACCESS_TOKEN]) + try: + devices = await self.client.get_devices() + except WillowAuthError as err: + raise ConfigEntryAuthFailed from err + except (ClientError, WillowApiError) as err: + raise UpdateFailed(f"Unable to fetch Willow data: {err}") from err + + return {device["sensor_id"]: device for device in devices} diff --git a/homeassistant/components/willow/manifest.json b/homeassistant/components/willow/manifest.json new file mode 100644 index 000000000000..b17d696c09ab --- /dev/null +++ b/homeassistant/components/willow/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "willow", + "name": "Willow", + "codeowners": ["@paxprz"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/willow", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pywillow==0.1.2"] +} diff --git a/homeassistant/components/willow/quality_scale.yaml b/homeassistant/components/willow/quality_scale.yaml new file mode 100644 index 000000000000..11f337cbbb0f --- /dev/null +++ b/homeassistant/components/willow/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This 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: This integration does not have any custom actions. + docs-conditions: + status: exempt + comment: This integration does not have any custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any custom triggers. + entity-event-setup: + status: exempt + comment: Entities of this integration do 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 + action-exceptions: + status: exempt + comment: This integration does not have custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not have an options flow. + 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: todo + discovery-update-info: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: The icons come from the device class. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not raise any repairable issues. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/willow/sensor.py b/homeassistant/components/willow/sensor.py new file mode 100644 index 000000000000..e03ed4010d6a --- /dev/null +++ b/homeassistant/components/willow/sensor.py @@ -0,0 +1,144 @@ +"""Support for Willow sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from pywillow import WillowDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WillowConfigEntry, WillowDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class WillowSensorEntityDescription(SensorEntityDescription): + """Describe a Willow sensor entity.""" + + value_fn: Callable[[WillowDevice], StateType] + + +SENSOR_DESCRIPTIONS: tuple[WillowSensorEntityDescription, ...] = ( + WillowSensorEntityDescription( + key="battery_life", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device["battery_life"], + ), + WillowSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda device: ( + reading["temperature"] + if (reading := device["latest_reading"]) + else None + ) + ), + ), + WillowSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda device: ( + reading["humidity"] if (reading := device["latest_reading"]) else None + ) + ), + ), + WillowSensorEntityDescription( + key="moisture", + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda device: ( + reading["moisture"] if (reading := device["latest_reading"]) else None + ) + ), + ), + WillowSensorEntityDescription( + key="light", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + value_fn=( + lambda device: ( + reading["light"] if (reading := device["latest_reading"]) else None + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WillowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Willow sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + WillowSensor(coordinator, device, description) + for device in coordinator.data.values() + for description in SENSOR_DESCRIPTIONS + ) + + +class WillowSensor(CoordinatorEntity[WillowDataUpdateCoordinator], SensorEntity): + """Representation of a Willow sensor.""" + + entity_description: WillowSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WillowDataUpdateCoordinator, + device: WillowDevice, + description: WillowSensorEntityDescription, + ) -> None: + """Initialize the Willow sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._sensor_id = str(device["sensor_id"]) + self._attr_unique_id = f"{self._sensor_id}_{description.key}" + plant = device["user_plant"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._sensor_id)}, + manufacturer=MANUFACTURER, + model="Willow Sensor", + name=plant["name"], + sw_version=device["version"], + suggested_area=plant["location"], + ) + + @property + @override + def native_value(self) -> StateType: + """Return the native value.""" + return self.entity_description.value_fn(self.coordinator.data[self._sensor_id]) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._sensor_id in self.coordinator.data diff --git a/homeassistant/components/willow/strings.json b/homeassistant/components/willow/strings.json new file mode 100644 index 000000000000..10129f3290fa --- /dev/null +++ b/homeassistant/components/willow/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 06fe7da634be..efad42bc6a57 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -49,6 +49,7 @@ APPLICATION_CREDENTIALS = [ "volvo", "watts", "weheat", + "willow", "withings", "xbox", "yale", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0270b20fb6bd..dec26af9bfff 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -894,6 +894,7 @@ FLOWS = { "wiffi", "wiim", "wilight", + "willow", "withings", "wiz", "wled", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 588d6edbda3f..190556a56d3f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8219,6 +8219,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "willow": { + "name": "Willow", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "wirelesstag": { "name": "Wireless Sensor Tags", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ec509d97bfa8..ffc67f3b66dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2901,6 +2901,9 @@ pywemo==1.4.0 # homeassistant.components.wilight pywilight==0.0.74 +# homeassistant.components.willow +pywillow==0.1.2 + # homeassistant.components.wiz pywizlight==0.6.3 diff --git a/tests/components/willow/__init__.py b/tests/components/willow/__init__.py new file mode 100644 index 000000000000..d3f001251bfe --- /dev/null +++ b/tests/components/willow/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Willow integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Willow integration for testing.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/willow/conftest.py b/tests/components/willow/conftest.py new file mode 100644 index 000000000000..3b389112da95 --- /dev/null +++ b/tests/components/willow/conftest.py @@ -0,0 +1,105 @@ +"""Fixtures for the Willow integration tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.willow.const import ( + DOMAIN, + OAUTH2_CLIENT_ID, + OAUTH2_CLIENT_SECRET, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +USER_ID = 42 +ACCESS_TOKEN = "mock-access-token" +REFRESH_TOKEN = "mock-refresh-token" + +# Willow imports its own client credential (in async_step_user) without an +# explicit auth_domain, so application_credentials defaults the auth_domain +# to the integration domain. That value is the auth_implementation stored on +# entries created by the flow. +IMPL_DOMAIN = DOMAIN + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Bypass the integration setup so the config flow can be tested in isolation.""" + with patch( + "homeassistant.components.willow.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_willow_client() -> Generator[MagicMock]: + """Patch WillowClient wherever it is instantiated.""" + with ( + patch( + "homeassistant.components.willow.WillowClient", autospec=True + ) as client_class, + patch( + "homeassistant.components.willow.config_flow.WillowClient", + new=client_class, + ), + ): + client = client_class.return_value + client.get_profile.return_value = load_json_object_fixture( + "profile.json", DOMAIN + ) + client.get_devices.return_value = load_json_array_fixture( + "devices.json", DOMAIN + ) + yield client + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> float: + """Fixture to set the OAuth token expiration time in the future.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: float) -> MockConfigEntry: + """Return a Willow OAuth2 config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="garden@example.com", + unique_id=str(USER_ID), + data={ + "auth_implementation": IMPL_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN, + "refresh_token": REFRESH_TOKEN, + "expires_at": expires_at, + "expires_in": 3600, + "token_type": "Bearer", + }, + }, + entry_id="01J5TX5A0FF6G5V0QJX6HBC94T", + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Willow"), + ) diff --git a/tests/components/willow/fixtures/devices.json b/tests/components/willow/fixtures/devices.json new file mode 100644 index 000000000000..66bae22136ad --- /dev/null +++ b/tests/components/willow/fixtures/devices.json @@ -0,0 +1,20 @@ +[ + { + "id": 1, + "sensor_id": "SENSOR123", + "battery_life": 88, + "version": "1.2.3", + "user_plant": { + "id": 10, + "name": "Basil", + "location": "Kitchen" + }, + "latest_reading": { + "timestamp": "2026-05-08T12:00:00+00:00", + "temperature": 21.5, + "humidity": 55.0, + "moisture": 30.0, + "light": 1200.0 + } + } +] diff --git a/tests/components/willow/fixtures/profile.json b/tests/components/willow/fixtures/profile.json new file mode 100644 index 000000000000..9bf344935360 --- /dev/null +++ b/tests/components/willow/fixtures/profile.json @@ -0,0 +1,5 @@ +{ + "id": 42, + "username": "garden@example.com", + "profile_image": null +} diff --git a/tests/components/willow/snapshots/test_sensor.ambr b/tests/components/willow/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..5cf97fee2b78 --- /dev/null +++ b/tests/components/willow/snapshots/test_sensor.ambr @@ -0,0 +1,279 @@ +# serializer version: 1 +# name: test_all_entities[sensor.kitchen_basil_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_basil_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'willow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SENSOR123_battery_life', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Basil Battery', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_basil_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_basil_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'willow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SENSOR123_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'humidity', + : 'Basil Humidity', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_basil_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55.0', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_basil_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Illuminance', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'willow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SENSOR123_light', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'illuminance', + : 'Basil Illuminance', + : , + : 'lx', + }), + 'context': , + 'entity_id': 'sensor.kitchen_basil_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200.0', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_moisture-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_basil_moisture', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Moisture', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Moisture', + 'platform': 'willow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SENSOR123_moisture', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_moisture-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'moisture', + : 'Basil Moisture', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_basil_moisture', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_basil_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'willow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SENSOR123_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.kitchen_basil_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Basil Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.kitchen_basil_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.5', + }) +# --- diff --git a/tests/components/willow/test_application_credentials.py b/tests/components/willow/test_application_credentials.py new file mode 100644 index 000000000000..d6dc20b7d719 --- /dev/null +++ b/tests/components/willow/test_application_credentials.py @@ -0,0 +1,103 @@ +"""Tests for the Willow application credentials platform.""" + +import pytest + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.components.willow.application_credentials import ( + DEFAULT_EXPIRES_IN, + WillowOAuth2Implementation, + async_get_auth_implementation, +) +from homeassistant.components.willow.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant + +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture +def implementation(hass: HomeAssistant) -> WillowOAuth2Implementation: + """Return a Willow OAuth2 implementation.""" + return WillowOAuth2Implementation( + hass, + DOMAIN, + "client-id", + "client-secret", + "https://example.test/authorize", + OAUTH2_TOKEN, + ) + + +async def test_async_get_auth_implementation(hass: HomeAssistant) -> None: + """The platform returns a Willow OAuth2 implementation.""" + implementation = await async_get_auth_implementation( + hass, DOMAIN, ClientCredential("client-id", "client-secret") + ) + assert isinstance(implementation, WillowOAuth2Implementation) + + +async def test_resolve_external_data_adds_default_expiry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + implementation: WillowOAuth2Implementation, +) -> None: + """A token without expires_in is normalized to the long-lived default.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={"access_token": "abc", "refresh_token": "def"}, + ) + + token = await implementation.async_resolve_external_data( + {"code": "code", "state": {"redirect_uri": "https://example.test/cb"}} + ) + + assert token["expires_in"] == DEFAULT_EXPIRES_IN + + +async def test_resolve_external_data_keeps_provided_expiry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + implementation: WillowOAuth2Implementation, +) -> None: + """A token that already has expires_in is left untouched.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={"access_token": "abc", "refresh_token": "def", "expires_in": 60}, + ) + + token = await implementation.async_resolve_external_data( + {"code": "code", "state": {"redirect_uri": "https://example.test/cb"}} + ) + + assert token["expires_in"] == 60 + + +async def test_refresh_token_without_refresh_token_is_noop( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + implementation: WillowOAuth2Implementation, +) -> None: + """Refreshing a token that has no refresh_token returns it unchanged.""" + token = {"access_token": "abc", "expires_in": 3600} + + new_token = await implementation.async_refresh_token(token) + + assert new_token["access_token"] == "abc" + assert new_token["expires_in"] == 3600 + assert len(aioclient_mock.mock_calls) == 0 + + +async def test_refresh_token_normalizes_expiry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + implementation: WillowOAuth2Implementation, +) -> None: + """A refreshed token without expires_in is normalized.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={"access_token": "new", "refresh_token": "keep"}, + ) + + token = await implementation.async_refresh_token({"refresh_token": "old"}) + + assert token["access_token"] == "new" + assert token["expires_in"] == DEFAULT_EXPIRES_IN diff --git a/tests/components/willow/test_config_flow.py b/tests/components/willow/test_config_flow.py new file mode 100644 index 000000000000..6ba8e1ab6189 --- /dev/null +++ b/tests/components/willow/test_config_flow.py @@ -0,0 +1,142 @@ +"""Test the Willow config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qs, urlparse + +import pytest +from pywillow import WillowAuthError + +from homeassistant.components.willow.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_CLIENT_ID, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import ACCESS_TOKEN, IMPL_DOMAIN, REFRESH_TOKEN, USER_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URI = "https://example.com/auth/external/callback" + +pytestmark = pytest.mark.usefixtures("current_request_with_host", "setup_credentials") + + +async def _initiate_user_flow(hass: HomeAssistant) -> dict: + """Start the OAuth2 user flow and return the EXTERNAL_STEP result.""" + return await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + +async def _complete_oauth( + hass: HomeAssistant, + result: dict, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Drive the OAuth2 callback through the token exchange.""" + state = config_entry_oauth2_flow._encode_jwt( + hass, + {"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI}, + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 60, + }, + ) + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_willow_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Walk a happy-path OAuth2 flow end to end.""" + result = await _initiate_user_flow(hass) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + state = config_entry_oauth2_flow._encode_jwt( + hass, + {"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI}, + ) + parsed = urlparse(result["url"]) + query = {key: value[0] for key, value in parse_qs(parsed.query).items()} + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == OAUTH2_AUTHORIZE + assert query["response_type"] == "code" + assert query["client_id"] == OAUTH2_CLIENT_ID + assert query["redirect_uri"] == REDIRECT_URI + assert query["state"] == state + assert query["scope"] == "read" + + await _complete_oauth(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "garden@example.com" + assert result["result"].unique_id == str(USER_ID) + assert result["data"]["auth_implementation"] == IMPL_DOMAIN + assert result["data"]["token"]["access_token"] == ACCESS_TOKEN + mock_willow_client.get_profile.assert_awaited_once() + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Authenticating an already-configured account aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await _initiate_user_flow(hass) + await _complete_oauth(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (WillowAuthError, "invalid_auth"), + (Exception("boom"), "unknown"), + ], +) +async def test_profile_errors_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_willow_client: MagicMock, + side_effect: Exception, + reason: str, +) -> None: + """A failing profile lookup aborts the flow with the mapped reason.""" + mock_willow_client.get_profile.side_effect = side_effect + + result = await _initiate_user_flow(hass) + await _complete_oauth(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason diff --git a/tests/components/willow/test_init.py b/tests/components/willow/test_init.py new file mode 100644 index 000000000000..3349a3b97e77 --- /dev/null +++ b/tests/components/willow/test_init.py @@ -0,0 +1,105 @@ +"""Tests for the Willow integration setup.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pywillow import WillowApiError, WillowAuthError + +from homeassistant.components.willow.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("setup_credentials") + +ENTITY_ID = "sensor.kitchen_basil_temperature" + + +async def test_setup_unload( + hass: HomeAssistant, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """The integration loads and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_retries_on_api_failure( + hass: HomeAssistant, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A non-auth API failure surfaces as a setup retry.""" + mock_willow_client.get_devices.side_effect = TimeoutError("boom") + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_error_on_authentication_error( + hass: HomeAssistant, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A rejected access token at setup puts the entry in an error state.""" + mock_willow_client.get_profile.side_effect = WillowAuthError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +@pytest.mark.usefixtures("mock_willow_client") +async def test_setup_retries_when_implementation_missing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Missing OAuth2 implementation defers setup as not-ready.""" + with patch( + "homeassistant.components.willow.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError("gone"), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "side_effect", + [ + pytest.param(WillowAuthError, id="auth_error"), + pytest.param(TimeoutError("boom"), id="api_error"), + pytest.param(WillowApiError("boom"), id="willow_api_error"), + ], +) +async def test_poll_failure_marks_entities_unavailable( + hass: HomeAssistant, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + side_effect: Exception, +) -> None: + """A failed poll marks the sensors unavailable.""" + await setup_integration(hass, mock_config_entry) + mock_willow_client.get_devices.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/willow/test_sensor.py b/tests/components/willow/test_sensor.py new file mode 100644 index 000000000000..9366ce4032c2 --- /dev/null +++ b/tests/components/willow/test_sensor.py @@ -0,0 +1,49 @@ +"""Tests for the Willow sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = pytest.mark.usefixtures("setup_credentials") + +ENTITY_ID = "sensor.kitchen_basil_temperature" + + +@pytest.mark.usefixtures("mock_willow_client") +async def test_all_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot every Willow sensor entity.""" + with patch("homeassistant.components.willow._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_unknown_without_reading( + hass: HomeAssistant, + mock_willow_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A reading sensor reports unknown when the device has no latest reading.""" + devices = mock_willow_client.get_devices.return_value + devices[0]["latest_reading"] = None + + with patch("homeassistant.components.willow._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN