diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ea5a5801e69e5b..ee5a8a666105c7 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -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 @@ 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, ) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index ed21e96cd0bb92..3a61908ee2d6cb 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -6,25 +6,32 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_reauth_confirm( 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 @@ def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: 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: diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index e82a4ec1588488..4721a9fb154f4f 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,7 +4,7 @@ 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.""" diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 74c63cbe7588b7..47543aa2f8f9b2 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -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%]" } } }, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 1addaf1ec927af..62cd81a048127d 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -7,7 +7,7 @@ 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 @@ 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] diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 2de7fb1da9a5e3..ad3a97fa6e0b5b 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -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, diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index cf723d885e102b..594286b7f0102e 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ 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