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

View File

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

View File

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

View File

@ -6,14 +6,22 @@
"description": "Please provide the username and password for your Hydrawise cloud account:", "description": "Please provide the username and password for your Hydrawise cloud account:",
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "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": { "reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]", "title": "[%key:common::config_flow::title::reauth%]",
"description": "The Hydrawise integration needs to re-authenticate your account", "description": "The Hydrawise integration needs to re-authenticate your account",
"data": { "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 datetime import timedelta
from typing import Any from typing import Any
from pydrawise import Hydrawise, Zone from pydrawise import HydrawiseBase, Zone
from homeassistant.components.switch import ( from homeassistant.components.switch import (
SwitchDeviceClass, SwitchDeviceClass,
@ -28,8 +28,8 @@ from .entity import HydrawiseEntity
class HydrawiseSwitchEntityDescription(SwitchEntityDescription): class HydrawiseSwitchEntityDescription(SwitchEntityDescription):
"""Describes Hydrawise binary sensor.""" """Describes Hydrawise binary sensor."""
turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]]
value_fn: Callable[[Zone], bool] value_fn: Callable[[Zone], bool]

View File

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

View File

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