Willow (Plant With Willow) integration with Home Assistant (#177366)

Co-authored-by: Prakash Prajapati <115608116+paxprz-tekkon@users.noreply.github.com>
This commit is contained in:
Prakash Prajapati
2026-09-08 19:38:00 +02:00
committed by GitHub
co-authored by Prakash Prajapati
parent 3cc583c604
commit 15f2310179
23 changed files with 1368 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -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)
@@ -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
@@ -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,
)
+16
View File
@@ -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"
)
@@ -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}
@@ -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"]
}
@@ -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
+144
View File
@@ -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
@@ -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%]"
}
}
}
}
+1
View File
@@ -49,6 +49,7 @@ APPLICATION_CREDENTIALS = [
"volvo",
"watts",
"weheat",
"willow",
"withings",
"xbox",
"yale",
+1
View File
@@ -894,6 +894,7 @@ FLOWS = {
"wiffi",
"wiim",
"wilight",
"willow",
"withings",
"wiz",
"wled",
@@ -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",
+3
View File
@@ -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
+12
View File
@@ -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()
+105
View File
@@ -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"),
)
@@ -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
}
}
]
@@ -0,0 +1,5 @@
{
"id": 42,
"username": "garden@example.com",
"profile_image": null
}
@@ -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({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.kitchen_basil_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'battery',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Basil Battery',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'sensor.kitchen_basil_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '88',
})
# ---
# name: test_all_entities[sensor.kitchen_basil_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kitchen_basil_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'humidity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Basil Humidity',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'sensor.kitchen_basil_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '55.0',
})
# ---
# name: test_all_entities[sensor.kitchen_basil_illuminance-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kitchen_basil_illuminance',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Illuminance',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ILLUMINANCE: 'illuminance'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'illuminance',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Basil Illuminance',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'lx',
}),
'context': <ANY>,
'entity_id': 'sensor.kitchen_basil_illuminance',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1200.0',
})
# ---
# name: test_all_entities[sensor.kitchen_basil_moisture-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kitchen_basil_moisture',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Moisture',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MOISTURE: 'moisture'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'moisture',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Basil Moisture',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'sensor.kitchen_basil_moisture',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '30.0',
})
# ---
# name: test_all_entities[sensor.kitchen_basil_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kitchen_basil_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.kitchen_basil_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Basil Temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.kitchen_basil_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '21.5',
})
# ---
@@ -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
+142
View File
@@ -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
+105
View File
@@ -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
+49
View File
@@ -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