From 7053c1401903e9c8eea41b13a5b3ae48eb5b68bb Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 4 Oct 2026 04:51:04 -0400 Subject: [PATCH] Bump pydrawise to 2026.9.1 (#183141) Co-authored-by: Claude Opus 5.5 --- .../components/hydrawise/config_flow.py | 6 +- .../components/hydrawise/coordinator.py | 58 +++++++++++------ .../components/hydrawise/manifest.json | 2 +- .../components/hydrawise/strings.json | 8 +++ requirements_all.txt | 2 +- .../components/hydrawise/test_config_flow.py | 20 +++++- .../hydrawise/test_entity_availability.py | 10 ++- tests/components/hydrawise/test_init.py | 64 ++++++++++++++++++- 8 files changed, 141 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index a339a72023b8..7a521ed31fee 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -5,7 +5,7 @@ from typing import Any, override from aiohttp import ClientError import probatio -from pydrawise import auth as pydrawise_auth, hybrid +from pydrawise import APIError, auth as pydrawise_auth, hybrid from pydrawise.exceptions import NotAuthorizedError from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -122,9 +122,11 @@ async def _authenticate( 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 NotAuthorizedError: + errors["base"] = "invalid_auth" except TimeoutError: errors["base"] = "timeout_connect" - except ClientError as ex: + except (APIError, ClientError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 670ad3bcb54d..590a8ae40525 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -1,17 +1,19 @@ """DataUpdateCoordinator for the Hydrawise integration.""" -from collections.abc import Callable, Iterable +from collections.abc import AsyncIterator, Callable, Iterable +from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import override -from pydrawise import HydrawiseBase +from pydrawise import APIError, HydrawiseBase, NotAuthorizedError from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import now from .const import ( @@ -53,6 +55,22 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): api: HydrawiseBase config_entry: HydrawiseConfigEntry + @asynccontextmanager + async def _handle_api_errors(self) -> AsyncIterator[None]: + """Translate pydrawise errors into coordinator errors.""" + try: + yield + except NotAuthorizedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except APIError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + ) from err + class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): """The main Hydrawise Data Update Coordinator. @@ -95,15 +113,16 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): # Don't fetch zones. We'll fetch them for each controller later. # This is to prevent 502 errors in some cases. # See: https://github.com/home-assistant/core/issues/120128 - data = HydrawiseData(user=await self.api.get_user(fetch_zones=False)) - for controller in data.user.controllers: - data.controllers[controller.id] = controller - controller.zones = await self.api.get_zones(controller) - for zone in controller.zones: - data.zones[zone.id] = zone - data.zone_id_to_controller[zone.id] = controller - for sensor in controller.sensors: - data.sensors[sensor.id] = sensor + async with self._handle_api_errors(): + data = HydrawiseData(user=await self.api.get_user(fetch_zones=False)) + for controller in data.user.controllers: + data.controllers[controller.id] = controller + controller.zones = await self.api.get_zones(controller) + for zone in controller.zones: + data.zones[zone.id] = zone + data.zone_id_to_controller[zone.id] = controller + for sensor in controller.sensors: + data.sensors[sensor.id] = sensor return data @callback @@ -221,12 +240,15 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" daily_water_summary: dict[int, ControllerWaterUseSummary] = {} - for controller in self._main_coordinator.data.controllers.values(): - daily_water_summary[controller.id] = await self.api.get_water_use_summary( - controller, - now().replace(hour=0, minute=0, second=0, microsecond=0), - now(), - ) + async with self._handle_api_errors(): + for controller in self._main_coordinator.data.controllers.values(): + daily_water_summary[ + controller.id + ] = await self.api.get_water_use_summary( + controller, + now().replace(hour=0, minute=0, second=0, microsecond=0), + now(), + ) main_data = self._main_coordinator.data return HydrawiseData( user=main_data.user, diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 43286d9d7732..2a3266c60f25 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2026.9.0"] + "requirements": ["pydrawise==2026.9.1"] } diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index ebd97400e284..192cce54ea0c 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -73,6 +73,14 @@ } } }, + "exceptions": { + "api_error": { + "message": "Error communicating with the Hydrawise API" + }, + "invalid_auth": { + "message": "Authentication with the Hydrawise API failed" + } + }, "services": { "resume": { "description": "Resumes an irrigation zone's automatic watering schedule.", diff --git a/requirements_all.txt b/requirements_all.txt index b41eb530cf0c..c1560707762c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2184,7 +2184,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2026.9.0 +pydrawise==2026.9.1 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index 594286b7f010..a7a6ddf139d0 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError +from pydrawise import APIError from pydrawise.exceptions import NotAuthorizedError from pydrawise.schema import User import pytest @@ -56,11 +57,24 @@ async def test_form( mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ClientError("XXX"), "cannot_connect"), + (APIError("unavailable"), "cannot_connect"), + (NotAuthorizedError("HTTP 401"), "invalid_auth"), + ], +) async def test_form_api_error( - hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock, user: User + hass: HomeAssistant, + mock_auth: AsyncMock, + mock_pydrawise: AsyncMock, + user: User, + side_effect: Exception, + error: str, ) -> None: """Test we handle API errors.""" - mock_pydrawise.get_user.side_effect = ClientError("XXX") + mock_pydrawise.get_user.side_effect = side_effect init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -74,7 +88,7 @@ async def test_form_api_error( init_result["flow_id"], data ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} mock_pydrawise.get_user.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user diff --git a/tests/components/hydrawise/test_entity_availability.py b/tests/components/hydrawise/test_entity_availability.py index 27587425c31d..e3804768b7f0 100644 --- a/tests/components/hydrawise/test_entity_availability.py +++ b/tests/components/hydrawise/test_entity_availability.py @@ -6,7 +6,9 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from pydrawise import APIError from pydrawise.schema import Controller +import pytest from homeassistant.components.hydrawise.const import WATER_USE_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry @@ -31,22 +33,26 @@ async def test_controller_offline( _test_availability(hass, config_entry, entity_registry) +@pytest.mark.parametrize("side_effect", [ClientError, APIError("unavailable")]) async def test_api_offline( hass: HomeAssistant, mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], entity_registry: er.EntityRegistry, mock_pydrawise: AsyncMock, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + side_effect: Exception, ) -> None: """Test availability of sensors when API call fails.""" config_entry = await mock_add_config_entry() mock_pydrawise.get_user.reset_mock(return_value=True) - mock_pydrawise.get_user.side_effect = ClientError - mock_pydrawise.get_water_use_summary.side_effect = ClientError + mock_pydrawise.get_user.side_effect = side_effect + mock_pydrawise.get_water_use_summary.side_effect = side_effect freezer.tick(WATER_USE_SCAN_INTERVAL + timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() _test_availability(hass, config_entry, entity_registry) + assert "Unexpected error" not in caplog.text def _test_availability( diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 6a69e1fad420..9cc48b17eee2 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -5,11 +5,13 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from pydrawise import APIError, NotAuthorizedError from pydrawise.schema import Controller, User, Zone import pytest from homeassistant.components.hydrawise.const import DOMAIN, MAIN_SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry @@ -17,17 +19,75 @@ from homeassistant.helpers.device_registry import DeviceRegistry from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.parametrize("side_effect", [ClientError, APIError("unavailable")]) async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: AsyncMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + side_effect: Exception, ) -> None: """Test that a connection error triggers a retry.""" - mock_pydrawise.get_user.side_effect = ClientError + mock_pydrawise.get_user.side_effect = side_effect mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_auth_error_starts_reauth( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: AsyncMock +) -> None: + """Test that an authorization error during setup starts a reauth flow.""" + mock_pydrawise.get_user.side_effect = NotAuthorizedError("HTTP 401") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) + + +async def test_update_api_error( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that an API error during a refresh is an update failure, not unexpected.""" + assert hass.states.get("binary_sensor.zone_one_watering").state != STATE_UNAVAILABLE + + mock_pydrawise.get_user.side_effect = APIError("{'message': 'unavailable'}") + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("binary_sensor.zone_one_watering").state == STATE_UNAVAILABLE + assert "Unexpected error fetching hydrawise data" not in caplog.text + assert "Error communicating with the Hydrawise API" in caplog.text + + mock_pydrawise.get_user.side_effect = None + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("binary_sensor.zone_one_watering").state != STATE_UNAVAILABLE + + +async def test_update_auth_error_starts_reauth( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that an authorization error during a refresh starts a reauth flow.""" + mock_pydrawise.get_user.side_effect = NotAuthorizedError("HTTP 401") + freezer.tick(MAIN_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert any(mock_added_config_entry.async_get_active_flows(hass, {"reauth"})) + + async def test_update_version( hass: HomeAssistant, mock_config_entry_legacy: MockConfigEntry ) -> None: