Use the new hybrid Hydrawise client (#136522)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
David Knowles 2025-01-29 05:06:59 -05:00 committed by GitHub
parent 04d1d80917
commit b73203fdf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 118 additions and 48 deletions

View File

@ -1,9 +1,9 @@
"""Support for Hydrawise cloud."""
from pydrawise import auth, client
from pydrawise import auth, hybrid
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@ -21,16 +21,21 @@ PLATFORMS: list[Platform] = [
Platform.VALVE,
]
_REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Hydrawise from a config entry."""
if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data:
# The GraphQL API requires username and password to authenticate. If either is
# missing, reauth is required.
if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS):
# If we are missing any required authentication keys, trigger a reauth flow.
raise ConfigEntryAuthFailed
hydrawise = client.Hydrawise(
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]),
hydrawise = hybrid.HybridClient(
auth.HybridAuth(
config_entry.data[CONF_USERNAME],
config_entry.data[CONF_PASSWORD],
config_entry.data[CONF_API_KEY],
),
app_id=APP_ID,
)

View File

@ -6,25 +6,32 @@ from collections.abc import Mapping
from typing import Any
from aiohttp import ClientError
from pydrawise import auth as pydrawise_auth, client
from pydrawise import auth as pydrawise_auth, hybrid
from pydrawise.exceptions import NotAuthorizedError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from .const import APP_ID, DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_API_KEY): str,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{vol.Required(CONF_PASSWORD): str, vol.Required(CONF_API_KEY): str}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hydrawise."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -34,14 +41,19 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
return self._show_user_form({})
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
unique_id, errors = await _authenticate(username, password)
api_key = user_input[CONF_API_KEY]
unique_id, errors = await _authenticate(username, password, api_key)
if errors:
return self._show_user_form(errors)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=username,
data={CONF_USERNAME: username, CONF_PASSWORD: password},
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_API_KEY: api_key,
},
)
def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult:
@ -65,14 +77,20 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
username = reauth_entry.data[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
user_id, errors = await _authenticate(username, password)
api_key = user_input[CONF_API_KEY]
user_id, errors = await _authenticate(username, password, api_key)
if user_id is None:
return self._show_reauth_form(errors)
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password}
reauth_entry,
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_API_KEY: api_key,
},
)
def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult:
@ -82,14 +100,14 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN):
async def _authenticate(
username: str, password: str
username: str, password: str, api_key: str
) -> tuple[str | None, dict[str, str]]:
"""Authenticate with the Hydrawise API."""
unique_id = None
errors: dict[str, str] = {}
auth = pydrawise_auth.Auth(username, password)
auth = pydrawise_auth.HybridAuth(username, password, api_key)
try:
await auth.token()
await auth.check()
except NotAuthorizedError:
errors["base"] = "invalid_auth"
except TimeoutError:
@ -99,7 +117,7 @@ async def _authenticate(
return unique_id, errors
try:
api = client.Hydrawise(auth, app_id=APP_ID)
api = hybrid.HybridClient(auth, app_id=APP_ID)
# Don't fetch zones because we don't need them yet.
user = await api.get_user(fetch_zones=False)
except TimeoutError:

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pydrawise import Hydrawise
from pydrawise import HydrawiseBase
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
from homeassistant.core import HomeAssistant
@ -38,7 +38,7 @@ class HydrawiseUpdateCoordinators:
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""Base class for Hydrawise Data Update Coordinators."""
api: Hydrawise
api: HydrawiseBase
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
@ -49,7 +49,7 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
integration are updated in a timely manner.
"""
def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
@ -82,7 +82,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
def __init__(
self,
hass: HomeAssistant,
api: Hydrawise,
api: HydrawiseBase,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""

View File

@ -6,14 +6,22 @@
"description": "Please provide the username and password for your Hydrawise cloud account:",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Hydrawise integration needs to re-authenticate your account",
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::hydrawise::config::step::user::data_description::api_key%]"
}
}
},

View File

@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from pydrawise import Hydrawise, Zone
from pydrawise import HydrawiseBase, Zone
from homeassistant.components.switch import (
SwitchDeviceClass,
@ -28,8 +28,8 @@ from .entity import HydrawiseEntity
class HydrawiseSwitchEntityDescription(SwitchEntityDescription):
"""Describes Hydrawise binary sensor."""
turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]]
turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
value_fn: Callable[[Zone], bool]

View File

@ -63,7 +63,7 @@ def mock_pydrawise(
controller_water_use_summary: ControllerWaterUseSummary,
) -> Generator[AsyncMock]:
"""Mock Hydrawise."""
with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise:
with patch("pydrawise.hybrid.HybridClient", autospec=True) as mock_pydrawise:
user.controllers = [controller]
controller.sensors = sensors
mock_pydrawise.return_value.get_user.return_value = user
@ -76,8 +76,8 @@ def mock_pydrawise(
@pytest.fixture
def mock_auth() -> Generator[AsyncMock]:
"""Mock pydrawise Auth."""
with patch("pydrawise.auth.Auth", autospec=True) as mock_auth:
"""Mock pydrawise HybridAuth."""
with patch("pydrawise.auth.HybridAuth", autospec=True) as mock_auth:
yield mock_auth.return_value
@ -215,6 +215,7 @@ def mock_config_entry() -> MockConfigEntry:
data={
CONF_USERNAME: "asfd@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "abc123",
},
unique_id="hydrawise-customerid",
version=1,

View File

@ -9,7 +9,7 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.hydrawise.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -35,7 +35,11 @@ async def test_form(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"},
{
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
},
)
mock_pydrawise.get_user.return_value = user
await hass.async_block_till_done()
@ -45,9 +49,10 @@ async def test_form(
assert result["data"] == {
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
}
assert len(mock_setup_entry.mock_calls) == 1
mock_auth.token.assert_awaited_once_with()
mock_auth.check.assert_awaited_once_with()
mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False)
@ -60,7 +65,11 @@ async def test_form_api_error(
init_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}
data = {
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
}
result = await hass.config_entries.flow.async_configure(
init_result["flow_id"], data
)
@ -77,11 +86,18 @@ async def test_form_auth_connect_timeout(
hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock
) -> None:
"""Test we handle connection timeout errors."""
mock_auth.token.side_effect = TimeoutError
mock_auth.check.side_effect = TimeoutError
init_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN,
context={
"source": config_entries.SOURCE_USER,
},
)
data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}
data = {
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
}
result = await hass.config_entries.flow.async_configure(
init_result["flow_id"], data
)
@ -89,7 +105,7 @@ async def test_form_auth_connect_timeout(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "timeout_connect"}
mock_auth.token.reset_mock(side_effect=True)
mock_auth.check.reset_mock(side_effect=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"], data)
assert result["type"] is FlowResultType.CREATE_ENTRY
@ -102,7 +118,11 @@ async def test_form_client_connect_timeout(
init_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}
data = {
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
}
result = await hass.config_entries.flow.async_configure(
init_result["flow_id"], data
)
@ -120,19 +140,23 @@ async def test_form_not_authorized_error(
hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock
) -> None:
"""Test we handle API errors."""
mock_auth.token.side_effect = NotAuthorizedError
mock_auth.check.side_effect = NotAuthorizedError
init_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}
data = {
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
}
result = await hass.config_entries.flow.async_configure(
init_result["flow_id"], data
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
mock_auth.token.reset_mock(side_effect=True)
mock_auth.check.reset_mock(side_effect=True)
result = await hass.config_entries.flow.async_configure(result["flow_id"], data)
assert result["type"] is FlowResultType.CREATE_ENTRY
@ -150,6 +174,7 @@ async def test_reauth(
data={
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "bad-password",
CONF_API_KEY: "__api-key__",
},
unique_id="hydrawise-12345",
)
@ -165,7 +190,11 @@ async def test_reauth(
mock_pydrawise.get_user.return_value = user
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "__password__"}
result["flow_id"],
{
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
},
)
await hass.async_block_till_done()
@ -183,6 +212,7 @@ async def test_reauth_fails(
data={
CONF_USERNAME: "asdf@asdf.com",
CONF_PASSWORD: "bad-password",
CONF_API_KEY: "__api-key__",
},
unique_id="hydrawise-12345",
)
@ -191,18 +221,26 @@ async def test_reauth_fails(
result = await mock_config_entry.start_reauth_flow(hass)
assert result["step_id"] == "reauth_confirm"
mock_auth.token.side_effect = NotAuthorizedError
mock_auth.check.side_effect = NotAuthorizedError
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "__password__"}
result["flow_id"],
{
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
mock_auth.token.reset_mock(side_effect=True)
mock_auth.check.reset_mock(side_effect=True)
mock_pydrawise.get_user.return_value = user
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "__password__"}
result["flow_id"],
{
CONF_PASSWORD: "__password__",
CONF_API_KEY: "__api-key__",
},
)
assert result["type"] is FlowResultType.ABORT