mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Bump pydrawise to 2026.9.1 (#183141)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dcda418c90
commit
7053c14019
@@ -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.",
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user