Bump pydrawise to 2026.9.1 (#183141)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Knowles
2026-10-04 10:51:04 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent dcda418c90
commit 7053c14019
8 changed files with 141 additions and 29 deletions
@@ -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:
@@ -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,
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2026.9.0"]
"requirements": ["pydrawise==2026.9.1"]
}
@@ -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.",
+1 -1
View File
@@ -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
+17 -3
View File
@@ -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
@@ -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(
+62 -2
View File
@@ -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: