diff --git a/pydrawise/auth.py b/pydrawise/auth.py index c4f0a34..9e11ecc 100644 --- a/pydrawise/auth.py +++ b/pydrawise/auth.py @@ -1,19 +1,33 @@ """Authentication support for the Hydrawise v2 GraphQL API.""" +from asyncio import Lock +from dataclasses import dataclass from datetime import datetime, timedelta -from threading import Lock import aiohttp +from .base import BaseAuth +from .const import CLIENT_ID, CLIENT_SECRET, REQUEST_TIMEOUT, REST_URL, TOKEN_URL from .exceptions import NotAuthorizedError -CLIENT_ID = "hydrawise_app" -CLIENT_SECRET = "zn3CrjglwNV1" -TOKEN_URL = "https://app.hydrawise.com/api/v2/oauth/access-token" -DEFAULT_TIMEOUT = 60 +DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60) +_INVALID_API_KEY = "API key not valid" -class Auth: +@dataclass +class Token: + """Authentication token.""" + + token: str + refresh: str + type: str + expires: datetime + + def __str__(self) -> str: + return f"{self.type} {self.token}" + + +class Auth(BaseAuth): """Authentication support for the Hydrawise GraphQL API.""" def __init__(self, username: str, password: str) -> None: @@ -25,10 +39,7 @@ def __init__(self, username: str, password: str) -> None: self.__username = username self.__password = password self._lock = Lock() - self._token: str | None = None - self._token_type: str | None = None - self._token_expires: datetime | None = None - self._refresh_token: str | None = None + self._token: Token | None = None async def _fetch_token_locked(self, refresh=False): data = { @@ -38,7 +49,7 @@ async def _fetch_token_locked(self, refresh=False): if refresh: assert self._token is not None data["grant_type"] = "refresh_token" - data["refresh_token"] = self._refresh_token + data["refresh_token"] = self._token.refresh else: data["grant_type"] = "password" data["scope"] = "all" @@ -53,23 +64,26 @@ async def _fetch_token_locked(self, refresh=False): ) as resp: resp_json = await resp.json() if "error" in resp_json: - self._token_type = None self._token = None - self._token_expires = None raise NotAuthorizedError(resp_json["message"]) - self._token = resp_json["access_token"] - self._refresh_token = resp_json["refresh_token"] - self._token_type = resp_json["token_type"] - self._token_expires = datetime.now() + timedelta( - seconds=resp_json["expires_in"] + self._token = Token( + token=resp_json["access_token"], + refresh=resp_json["refresh_token"], + type=resp_json["token_type"], + expires=datetime.now() + timedelta(seconds=resp_json["expires_in"]), ) + async def check(self) -> bool: + """Validates that the credentials are valid.""" + await self.check_token() + return True + async def check_token(self): """Checks a token and refreshes if necessary.""" - with self._lock: + async with self._lock: if self._token is None: await self._fetch_token_locked(refresh=False) - elif self._token_expires - datetime.now() < timedelta(minutes=5): + elif self._token.expires - datetime.now() < timedelta(minutes=5): await self._fetch_token_locked(refresh=True) async def token(self) -> str: @@ -78,5 +92,46 @@ async def token(self) -> str: :rtype: string """ await self.check_token() - with self._lock: - return f"{self._token_type} {self._token}" + async with self._lock: + return str(self._token) + + +class RestAuth(BaseAuth): + """Authentication support for the Hydrawise REST API.""" + + def __init__(self, api_key: str) -> None: + """Initializer.""" + self._api_key = api_key + + async def get(self, path: str, **kwargs) -> dict: + """Perform an authenticated GET request and return the JSON response.""" + url = f"{REST_URL}/{path}" + params = {"api_key": self._api_key} + params.update(kwargs) + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=REQUEST_TIMEOUT) as resp: + if resp.status == 404 and await resp.text() == _INVALID_API_KEY: + raise NotAuthorizedError(_INVALID_API_KEY) + resp.raise_for_status() + return await resp.json() + + async def check(self) -> bool: + """Validates that the credentials are valid.""" + await self.get("customerdetails.php") + return True + + +class HybridAuth(Auth, RestAuth): + """Authentication support for the Hydrawise GraphQL & REST APIs.""" + + def __init__(self, username: str, password: str, api_key: str) -> None: + """Initializer.""" + Auth.__init__(self, username, password) + RestAuth.__init__(self, api_key) + + async def _check_api_token(self): + await self.get("customerdetails.php") + + async def check(self) -> bool: + await super().check() + return True diff --git a/pydrawise/base.py b/pydrawise/base.py index d9ec605..519ba96 100644 --- a/pydrawise/base.py +++ b/pydrawise/base.py @@ -3,7 +3,27 @@ from abc import ABC, abstractmethod from datetime import datetime -from .schema import Controller, User, Zone, ZoneSuspension +from .schema import ( + Controller, + ControllerWaterUseSummary, + Sensor, + SensorFlowSummary, + User, + WateringReportEntry, + Zone, + ZoneSuspension, +) + + +class BaseAuth(ABC): + """Base class for Authentication objects.""" + + @abstractmethod + async def check(self) -> bool: + """Validates that the credentials are valid. + + Returns True on success, otherwise should raise NotAuthorizedError. + """ class HydrawiseBase(ABC): @@ -130,3 +150,44 @@ async def delete_zone_suspension(self, suspension: ZoneSuspension) -> None: :param suspension: The suspension to delete. """ + + @abstractmethod + async def get_sensors(self, controller: Controller) -> list[Sensor]: + """Retrieves sensors associated with the given controller. + + :param controller: Controller whose sensors to fetch. + :rtype: list[Sensor] + """ + + @abstractmethod + async def get_water_flow_summary( + self, controller: Controller, sensor: Sensor, start: datetime, end: datetime + ) -> SensorFlowSummary: + """Retrieves the water flow summary for a given sensor. + + :param controller: Controller that controls the sensor. + :param sensor: Sensor for which a water flow summary is fetched. + :param start: + :param end: + :rtype: list[Sensor] + """ + + @abstractmethod + async def get_watering_report( + self, controller: Controller, start: datetime, end: datetime + ) -> list[WateringReportEntry]: + """Retrieves a watering report for the given controller and time period. + + :param controller: The controller whose watering report to generate. + :param start: Start time. + :param end: End time.""" + + @abstractmethod + async def get_water_use_summary( + self, controller: Controller, start: datetime, end: datetime + ) -> ControllerWaterUseSummary: + """Calculate the water use for the given controller and time period. + + :param controller: The controller whose water use to report. + :param start: Start time + :param end: End time.""" diff --git a/pydrawise/client.py b/pydrawise/client.py index 8053dfb..14784a1 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -1,4 +1,4 @@ -"""Client library for interacting with Hydrawise's cloud API.""" +"""Asynchronous client library for interacting with Hydrawise's GraphQL API.""" import logging from datetime import datetime, timedelta @@ -10,6 +10,7 @@ from .auth import Auth from .base import HydrawiseBase +from .const import DEFAULT_APP_ID, GRAPHQL_URL from .exceptions import MutationError from .schema import ( DSL_SCHEMA, @@ -32,8 +33,6 @@ # GQL is quite chatty in logs by default. gql_log.setLevel(logging.ERROR) -API_URL = "https://app.hydrawise.com/api/v2/graph" - def _prune_watering_report_entries( entries: list[WateringReportEntry], start: datetime, end: datetime @@ -71,7 +70,7 @@ class Hydrawise(HydrawiseBase): Should be instantiated with an Auth object that handles authentication and low-level transport. """ - def __init__(self, auth: Auth, app_id: str = "pydrawise") -> None: + def __init__(self, auth: Auth, app_id: str = DEFAULT_APP_ID) -> None: """Initializes the client. :param auth: Handles authentication and transport. @@ -82,7 +81,7 @@ def __init__(self, auth: Auth, app_id: str = "pydrawise") -> None: async def _client(self) -> Client: headers = {"Authorization": await self._auth.token()} - transport = AIOHTTPTransport(url=API_URL, headers=headers) + transport = AIOHTTPTransport(url=GRAPHQL_URL, headers=headers) return Client(transport=transport, parse_results=True) async def _query(self, selector: DSLSelectable) -> dict: diff --git a/pydrawise/const.py b/pydrawise/const.py new file mode 100644 index 0000000..e4bd093 --- /dev/null +++ b/pydrawise/const.py @@ -0,0 +1,14 @@ +"""Constants.""" + +from aiohttp import ClientTimeout + +GRAPHQL_URL = "https://app.hydrawise.com/api/v2/graph" +TOKEN_URL = "https://app.hydrawise.com/api/v2/oauth/access-token" +REST_URL = "https://api.hydrawise.com/api/v1" + +CLIENT_ID = "hydrawise_app" +CLIENT_SECRET = "zn3CrjglwNV1" + +DEFAULT_APP_ID = "pydrawise" + +REQUEST_TIMEOUT = ClientTimeout(total=30) diff --git a/pydrawise/exceptions.py b/pydrawise/exceptions.py index 3c1c6d6..c0c9a4f 100644 --- a/pydrawise/exceptions.py +++ b/pydrawise/exceptions.py @@ -23,3 +23,7 @@ class MutationError(Error): class UnknownError(Error): """Raised when an unknown problem occurs.""" + + +class ThrottledError(Error): + """Raised when a request has been throttled.""" diff --git a/pydrawise/hybrid.py b/pydrawise/hybrid.py new file mode 100644 index 0000000..9fc9fd1 --- /dev/null +++ b/pydrawise/hybrid.py @@ -0,0 +1,252 @@ +"""Client library for interacting with Hydrawise APIs. + +This utilizes both the GraphQL and REST APIs. +""" + +from asyncio import Lock +from dataclasses import dataclass +from datetime import datetime, timedelta +from functools import wraps +from typing import Awaitable, Callable, Coroutine, ParamSpec, TypeVar + +from .auth import HybridAuth +from .base import HydrawiseBase +from .client import Hydrawise +from .const import DEFAULT_APP_ID +from .exceptions import ThrottledError +from .schema import ( + Controller, + ControllerWaterUseSummary, + Sensor, + SensorFlowSummary, + User, + WateringReportEntry, + Zone, + ZoneSuspension, +) + + +@dataclass +class Throttler: + epoch_interval: timedelta + last_epoch: datetime = datetime.min + tokens_per_epoch: int = 1 + tokens: int = 0 + + def check(self, tokens: int = 1) -> bool: + if datetime.now() > self.last_epoch + self.epoch_interval: + return tokens <= self.tokens_per_epoch + return (self.tokens + tokens) <= self.tokens_per_epoch + + def mark(self) -> None: + if (now := datetime.now()) > self.last_epoch + self.epoch_interval: + self.last_epoch = now + self.tokens = 1 + return + self.tokens += 1 + + +T = TypeVar("T") +P = ParamSpec("P") + + +def throttle(fn: Callable[P, Awaitable[T]]) -> Callable[P, Coroutine[None, None, T]]: + cache: dict[str, T] = {} + + @wraps(fn) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + assert len(args) > 1 + assert isinstance(args[0], HybridClient) + self: HybridClient = args[0] + k = str(args[1].id if isinstance(args[1], Controller) else args[1]) + async with self._lock: + if self._gql_throttle.check(): + v = await fn(*args, **kwargs) + self._gql_throttle.mark() + cache[k] = v + elif k not in cache: + raise ThrottledError + return cache[k] + + return wrapper + + +class HybridClient(HydrawiseBase): + def __init__( + self, + auth: HybridAuth, + app_id: str = DEFAULT_APP_ID, + gql_client: Hydrawise | None = None, + ) -> None: + if gql_client is None: + gql_client = Hydrawise(auth, app_id) + self._gql_client = gql_client + self._auth = auth + self._lock = Lock() + self._user: User | None = None + self._controllers: dict[int, Controller] = {} + self._zones: dict[int, Zone] = {} + self._gql_throttle = Throttler( + epoch_interval=timedelta(minutes=30), tokens_per_epoch=2 + ) + self._rest_throttle = Throttler( + epoch_interval=timedelta(minutes=1), tokens_per_epoch=2 + ) + + async def get_user(self, fetch_zones: bool = True) -> User: + async with self._lock: + if self._user is None or self._gql_throttle.check(): + self._user = await self._gql_client.get_user(fetch_zones=fetch_zones) + self._gql_throttle.mark() + for controller in self._user.controllers: + self._controllers[controller.id] = controller + for zone in controller.zones: + self._zones[zone.id] = zone + elif fetch_zones: + # If we're not fetching zones, there's nothing to update. + # The REST API doesn't return anything useful for a User. + await self._update_zones() + + return self._user + + async def get_controllers( + self, fetch_zones: bool = True, fetch_sensors: bool = True + ) -> list[Controller]: + async with self._lock: + if not self._controllers or self._gql_throttle.check(): + controllers = await self._gql_client.get_controllers( + fetch_zones, fetch_sensors + ) + self._gql_throttle.mark() + for controller in controllers: + self._controllers[controller.id] = controller + for zone in controller.zones: + self._zones[zone.id] = zone + elif fetch_zones: + # If we're not fetching zones, there's nothing to update. + # The REST API doesn't return anything useful for a User. + await self._update_zones() + return list(self._controllers.values()) + + async def get_controller(self, controller_id: int) -> Controller: + async with self._lock: + if not self._controllers.get(controller_id) or self._gql_throttle.check(): + self._controllers[ + controller_id + ] = await self._gql_client.get_controller(controller_id) + self._gql_throttle.mark() + return self._controllers[controller_id] + + async def get_zones(self, controller: Controller) -> list[Zone]: + async with self._lock: + if not self._controllers.get(controller.id) or self._gql_throttle.check(): + zones = await self._gql_client.get_zones(controller) + self._gql_throttle.mark() + if controller.id not in self._controllers: + self._controllers[controller.id] = controller + self._controllers[controller.id].zones = zones + for zone in zones: + self._zones[zone.id] = zone + else: + await self._update_zones(controller) + + return self._controllers[controller.id].zones + + async def _update_zones(self, controller: Controller | None = None): + if controller: + controller_ids = [controller.id] + else: + controller_ids = list(self._controllers.keys()) + + if not self._rest_throttle.check(len(controller_ids)): + # We don't have enough quota to update everything, so update nothing. + return + + for controller_id in controller_ids: + json = await self._auth.get( + "statusschedule.php", controller_id=controller_id + ) + self._rest_throttle.mark() + self._rest_throttle.epoch_interval = timedelta(seconds=json["nextpoll"]) + for zone_json in json["relays"]: + if zone := self._zones.get(zone_json["relay_id"]): + zone.update_with_json(zone_json) + else: + # Not an ideal case. This means we discovered a Zone from the + # REST API, which means we get incomplete data. + self._zones[zone_json["relay_id"]] = Zone.from_json(zone_json) + + @throttle + async def get_zone(self, zone_id: int) -> Zone: + # The REST API doesn't allow us to fetch a single zone, so we'll just + # query the GraphQL API instead. + # + # Since we don't know what controller a particular zone is associated + # with without inspecting each controller, we don't bother with updating + # the _zones cache. + # + # This method isn't used by HomeAssistant, so the inconsistency is + # probably fine. + return await self._gql_client.get_zone(zone_id) + + async def start_zone( + self, + zone: Zone, + mark_run_as_scheduled: bool = False, + custom_run_duration: int = 0, + ) -> None: + return await self._gql_client.start_zone( + zone, mark_run_as_scheduled, custom_run_duration + ) + + async def stop_zone(self, zone: Zone) -> None: + return await self._gql_client.stop_zone(zone) + + async def start_all_zones( + self, + controller: Controller, + mark_run_as_scheduled: bool = False, + custom_run_duration: int = 0, + ) -> None: + return await self._gql_client.start_all_zones( + controller, mark_run_as_scheduled, custom_run_duration + ) + + async def stop_all_zones(self, controller: Controller) -> None: + return await self._gql_client.stop_all_zones(controller) + + async def suspend_zone(self, zone: Zone, until: datetime) -> None: + return await self._gql_client.suspend_zone(zone, until) + + async def resume_zone(self, zone: Zone) -> None: + return await self._gql_client.resume_zone(zone) + + async def suspend_all_zones(self, controller: Controller, until: datetime) -> None: + return await self._gql_client.suspend_all_zones(controller, until) + + async def resume_all_zones(self, controller: Controller) -> None: + return await self._gql_client.resume_all_zones(controller) + + async def delete_zone_suspension(self, suspension: ZoneSuspension) -> None: + return await self._gql_client.delete_zone_suspension(suspension) + + @throttle + async def get_sensors(self, controller: Controller) -> list[Sensor]: + return await self._gql_client.get_sensors(controller) + + async def get_water_flow_summary( + self, controller: Controller, sensor: Sensor, start: datetime, end: datetime + ) -> SensorFlowSummary: + return await self._gql_client.get_water_flow_summary( + controller, sensor, start, end + ) + + async def get_watering_report( + self, controller: Controller, start: datetime, end: datetime + ) -> list[WateringReportEntry]: + return await self._gql_client.get_watering_report(controller, start, end) + + async def get_water_use_summary( + self, controller: Controller, start: datetime, end: datetime + ) -> ControllerWaterUseSummary: + return await self._gql_client.get_water_use_summary(controller, start, end) diff --git a/pydrawise/legacy.py b/pydrawise/legacy.py index 1f47dee..3da10b0 100644 --- a/pydrawise/legacy.py +++ b/pydrawise/legacy.py @@ -1,268 +1,30 @@ -"""API for interacting with Hydrawise sprinkler controllers. +"""Client library for interacting with Hydrawise's REST API. This library should remain compatible with https://github.com/ptcryan/hydrawiser. """ -from datetime import datetime, timedelta import time from typing import Any -import aiohttp import requests -from .base import HydrawiseBase +from .auth import RestAuth +from .const import REST_URL from .exceptions import NotInitializedError, UnknownError -from .schema import ( - Controller, - ControllerHardware, - ScheduledZoneRun, - ScheduledZoneRuns, - User, - Zone, - ZoneStatus, - ZoneSuspension, -) - -_BASE_URL = "https://api.hydrawise.com/api/v1" +from .rest import RestClient + _TIMEOUT = 10 # seconds -class LegacyHydrawiseAsync(HydrawiseBase): +class LegacyHydrawiseAsync(RestClient): """Async client library for interacting with the Hydrawise v1 API. - This should remain compatible with client.Hydrawise. + This is for compatibility with previous pydrawise versions. Please + prefer to use rest.RestClient instead. """ def __init__(self, user_token: str) -> None: - self._api_key = user_token - - async def _get(self, path: str, **kwargs) -> dict: - url = f"{_BASE_URL}/{path}" - params = {"api_key": self._api_key} - params.update(kwargs) - async with aiohttp.ClientSession() as session: - async with session.get( - url, params=params, timeout=aiohttp.ClientTimeout(total=_TIMEOUT) - ) as resp: - resp.raise_for_status() - return await resp.json() - - async def get_user(self, fetch_zones: bool = True) -> User: - """Retrieves the currently authenticated user. - - :param fetch_zones: When True, also fetch zones. - :rtype: User - """ - resp_json = await self._get("customerdetails.php") - user = User( - id=0, - customer_id=resp_json["customer_id"], - name="", - email="", - controllers=[_controller_from_json(c) for c in resp_json["controllers"]], - ) - if fetch_zones: - for controller in user.controllers: - controller.zones = await self.get_zones(controller) - return user - - async def get_controllers(self) -> list[Controller]: - """Retrieves all controllers associated with the currently authenticated user. - - :rtype: list[Controller] - """ - resp_json = await self._get("customerdetails.php", type="controllers") - controllers = [_controller_from_json(c) for c in resp_json["controllers"]] - for controller in controllers: - controller.zones = await self.get_zones(controller) - return controllers - - async def get_controller(self, controller_id: int) -> Controller: - """Retrieves a single controller by its unique identifier. - - :param controller_id: Unique identifier for the controller to retrieve. - :rtype: Controller - """ - _ = controller_id # unused - raise NotImplementedError - - async def get_zones(self, controller: Controller) -> list[Zone]: - """Retrieves zones associated with the given controller. - - :param controller: Controller whose zones to fetch. - :rtype: list[Zone] - """ - resp_json = await self._get("statusschedule.php", controller_id=controller.id) - return [_zone_from_json(z) for z in resp_json["relays"]] - - async def get_zone(self, zone_id: int) -> Zone: - """Retrieves a zone by its unique identifier. - - :param zone_id: The zone's unique identifier. - :rtype: Zone - """ - _ = zone_id # unused - raise NotImplementedError - - async def start_zone( - self, - zone: Zone, - mark_run_as_scheduled: bool = False, - custom_run_duration: int = 0, - ) -> None: - """Starts a zone's run cycle. - - :param zone: The zone to start. - :param mark_run_as_scheduled: Whether to mark the zone as having run as scheduled. - :param custom_run_duration: Duration (in seconds) to run the zone. If not - specified (or zero), will run for its default configured time. - """ - _ = mark_run_as_scheduled # unused. - params = { - "action": "run", - "relay_id": zone.id, - "period_id": 999, - } - if custom_run_duration > 0: - params["custom"] = custom_run_duration - await self._get("setzone.php", **params) - - async def stop_zone(self, zone: Zone) -> None: - """Stops a zone. - - :param zone: The zone to stop. - """ - await self._get("setzone.php", action="stop", relay_id=zone.id) - - async def start_all_zones( - self, - controller: Controller, - mark_run_as_scheduled: bool = False, - custom_run_duration: int = 0, - ) -> None: - """Starts all zones attached to a controller. - - :param controller: The controller whose zones to start. - :param mark_run_as_scheduled: Whether to mark the zones as having run as scheduled. - :param custom_run_duration: Duration (in seconds) to run the zones. If not - specified (or zero), will run for each zone's default configured time. - """ - _ = mark_run_as_scheduled # unused - params = { - "action": "runall", - "controller_id": controller.id, - "period_id": 999, - } - if custom_run_duration > 0: - params["custom"] = custom_run_duration - await self._get("setzone.php", **params) - - async def stop_all_zones(self, controller: Controller) -> None: - """Stops all zones attached to a controller. - - :param controller: The controller whose zones to stop. - """ - await self._get("setzone.php", action="stopall", controller_id=controller.id) - - async def suspend_zone(self, zone: Zone, until: datetime) -> None: - """Suspends a zone's schedule. - - :param zone: The zone to suspend. - :param until: When the suspension should end. - """ - await self._get( - "setzone.php", - action="suspend", - relay_id=zone.id, - period_id=999, - custom=int(until.timestamp()), - ) - - async def resume_zone(self, zone: Zone) -> None: - """Resumes a zone's schedule. - - :param zone: The zone whose schedule to resume. - """ - await self._get("setzone.php", action="suspend", relay_id=zone.id, period_id=0) - - async def suspend_all_zones(self, controller: Controller, until: datetime) -> None: - """Suspends the schedule of all zones attached to a given controller. - - :param controller: The controller whose zones to suspend. - :param until: When the suspension should end. - """ - await self._get( - "setzone.php", - action="suspendall", - controller_id=controller.id, - period_id=999, - custom=int(until.timestamp()), - ) - - async def resume_all_zones(self, controller: Controller) -> None: - """Resumes the schedule of all zones attached to the given controller. - - :param controller: The controller whose zones to resume. - """ - await self._get( - "setzone.php", action="suspendall", period_id=0, controller_id=controller.id - ) - - async def delete_zone_suspension(self, suspension: ZoneSuspension) -> None: - """Removes a specific zone suspension. - - Useful when there are multiple suspensions for a zone in effect. - - :param suspension: The suspension to delete. - """ - _ = suspension # unused - raise NotImplementedError - - -def _controller_from_json(controller_json: dict) -> Controller: - return Controller( - id=controller_json["controller_id"], - name=controller_json["name"], - hardware=ControllerHardware( - serial_number=controller_json["serial_number"], - ), - last_contact_time=datetime.fromtimestamp(controller_json["last_contact"]), - online=True, - ) - - -def _zone_from_json(zone_json: dict) -> Zone: - current_run = None - next_run = None - suspended_until = None - if zone_json["time"] == 1: - current_run = ScheduledZoneRun( - remaining_time=timedelta(seconds=zone_json["run"]), - ) - elif zone_json["time"] == 1576800000: - suspended_until = datetime.max - else: - now = datetime.now().replace(microsecond=0) - start_time = now + timedelta(seconds=zone_json["time"]) - duration = timedelta(seconds=zone_json["run"]) - next_run = ScheduledZoneRun( - start_time=start_time, - end_time=start_time + duration, - normal_duration=duration, - duration=duration, - ) - return Zone( - id=zone_json["relay_id"], - number=zone_json["relay"], - name=zone_json["name"], - scheduled_runs=ScheduledZoneRuns( - current_run=current_run, - next_run=next_run, - ), - status=ZoneStatus( - suspended_until=suspended_until, - ), - ) + super().__init__(RestAuth(user_token)) class LegacyHydrawise: @@ -332,7 +94,7 @@ def update_controller_info(self) -> bool: return True def _get(self, path: str, **kwargs) -> dict: - url = f"{_BASE_URL}/{path}" + url = f"{REST_URL}/{path}" params = {"api_key": self._api_key} params.update(kwargs) resp = requests.get(url, params=params, timeout=_TIMEOUT) diff --git a/pydrawise/rest.py b/pydrawise/rest.py new file mode 100644 index 0000000..0e9978d --- /dev/null +++ b/pydrawise/rest.py @@ -0,0 +1,224 @@ +"""Asynchronous client library for interacting with Hydrawise's REST API.""" + +from datetime import datetime, timedelta + +import aiohttp + +from pydrawise.schema import ( + ControllerWaterUseSummary, + Sensor, + SensorFlowSummary, + WateringReportEntry, +) + +from .auth import RestAuth +from .base import HydrawiseBase +from .schema import Controller, User, Zone, ZoneSuspension + +_TIMEOUT = aiohttp.ClientTimeout(total=10) + + +class RestClient(HydrawiseBase): + """Async client library for interacting with the Hydrawise v1 REST API. + + This should remain compatible with client.Hydrawise. + """ + + def __init__(self, auth: RestAuth) -> None: + self._auth = auth + self.next_poll = timedelta(0) + + async def _get(self, path: str, **kwargs) -> dict: + json = await self._auth.get(path, **kwargs) + if "nextpoll" in json: + self.next_poll = timedelta(seconds=json["nextpoll"]) + return json + + async def get_user(self, fetch_zones: bool = True) -> User: + """Retrieves the currently authenticated user. + + :param fetch_zones: When True, also fetch zones. + :rtype: User + """ + resp_json = await self._get("customerdetails.php") + user = User( + id=0, + customer_id=resp_json["customer_id"], + name="", + email="", + controllers=[Controller.from_json(c) for c in resp_json["controllers"]], + ) + if fetch_zones: + for controller in user.controllers: + controller.zones = await self.get_zones(controller) + return user + + async def get_controllers(self) -> list[Controller]: + """Retrieves all controllers associated with the currently authenticated user. + + :rtype: list[Controller] + """ + resp_json = await self._get("customerdetails.php", type="controllers") + controllers = [Controller.from_json(c) for c in resp_json["controllers"]] + for controller in controllers: + controller.zones = await self.get_zones(controller) + return controllers + + async def get_controller(self, controller_id: int) -> Controller: + """Retrieves a single controller by its unique identifier. + + :param controller_id: Unique identifier for the controller to retrieve. + :rtype: Controller + """ + _ = controller_id # unused + raise NotImplementedError + + async def get_zones(self, controller: Controller) -> list[Zone]: + """Retrieves zones associated with the given controller. + + :param controller: Controller whose zones to fetch. + :rtype: list[Zone] + """ + resp_json = await self._get("statusschedule.php", controller_id=controller.id) + return [Zone.from_json(z) for z in resp_json["relays"]] + + async def get_zone(self, zone_id: int) -> Zone: + """Retrieves a zone by its unique identifier. + + :param zone_id: The zone's unique identifier. + :rtype: Zone + """ + _ = zone_id # unused + raise NotImplementedError + + async def start_zone( + self, + zone: Zone, + mark_run_as_scheduled: bool = False, + custom_run_duration: int = 0, + ) -> None: + """Starts a zone's run cycle. + + :param zone: The zone to start. + :param mark_run_as_scheduled: Whether to mark the zone as having run as scheduled. + :param custom_run_duration: Duration (in seconds) to run the zone. If not + specified (or zero), will run for its default configured time. + """ + _ = mark_run_as_scheduled # unused. + params = { + "action": "run", + "relay_id": zone.id, + "period_id": 999, + } + if custom_run_duration > 0: + params["custom"] = custom_run_duration + await self._get("setzone.php", **params) + + async def stop_zone(self, zone: Zone) -> None: + """Stops a zone. + + :param zone: The zone to stop. + """ + await self._get("setzone.php", action="stop", relay_id=zone.id) + + async def start_all_zones( + self, + controller: Controller, + mark_run_as_scheduled: bool = False, + custom_run_duration: int = 0, + ) -> None: + """Starts all zones attached to a controller. + + :param controller: The controller whose zones to start. + :param mark_run_as_scheduled: Whether to mark the zones as having run as scheduled. + :param custom_run_duration: Duration (in seconds) to run the zones. If not + specified (or zero), will run for each zone's default configured time. + """ + _ = mark_run_as_scheduled # unused + params = { + "action": "runall", + "controller_id": controller.id, + "period_id": 999, + } + if custom_run_duration > 0: + params["custom"] = custom_run_duration + await self._get("setzone.php", **params) + + async def stop_all_zones(self, controller: Controller) -> None: + """Stops all zones attached to a controller. + + :param controller: The controller whose zones to stop. + """ + await self._get("setzone.php", action="stopall", controller_id=controller.id) + + async def suspend_zone(self, zone: Zone, until: datetime) -> None: + """Suspends a zone's schedule. + + :param zone: The zone to suspend. + :param until: When the suspension should end. + """ + await self._get( + "setzone.php", + action="suspend", + relay_id=zone.id, + period_id=999, + custom=int(until.timestamp()), + ) + + async def resume_zone(self, zone: Zone) -> None: + """Resumes a zone's schedule. + + :param zone: The zone whose schedule to resume. + """ + await self._get("setzone.php", action="suspend", relay_id=zone.id, period_id=0) + + async def suspend_all_zones(self, controller: Controller, until: datetime) -> None: + """Suspends the schedule of all zones attached to a given controller. + + :param controller: The controller whose zones to suspend. + :param until: When the suspension should end. + """ + await self._get( + "setzone.php", + action="suspendall", + controller_id=controller.id, + period_id=999, + custom=int(until.timestamp()), + ) + + async def resume_all_zones(self, controller: Controller) -> None: + """Resumes the schedule of all zones attached to the given controller. + + :param controller: The controller whose zones to resume. + """ + await self._get( + "setzone.php", action="suspendall", period_id=0, controller_id=controller.id + ) + + async def delete_zone_suspension(self, suspension: ZoneSuspension) -> None: + """Removes a specific zone suspension. + + Useful when there are multiple suspensions for a zone in effect. + + :param suspension: The suspension to delete. + """ + _ = suspension # unused + raise NotImplementedError + + async def get_sensors(self, controller: Controller) -> list[Sensor]: + raise NotImplementedError + + async def get_water_flow_summary( + self, controller: Controller, sensor: Sensor, start: datetime, end: datetime + ) -> SensorFlowSummary: + raise NotImplementedError + + async def get_watering_report( + self, controller: Controller, start: datetime, end: datetime + ) -> list[WateringReportEntry]: + raise NotImplementedError + + async def get_water_use_summary( + self, controller: Controller, start: datetime, end: datetime + ) -> ControllerWaterUseSummary: + raise NotImplementedError diff --git a/pydrawise/schema.py b/pydrawise/schema.py index e68afe3..5d6ca31 100644 --- a/pydrawise/schema.py +++ b/pydrawise/schema.py @@ -30,14 +30,15 @@ def _optional_field(*args, **kwargs): return field(*args, **kwargs) -def default_datetime() -> datetime: - """Default datetime factory for fields in this module. - - Abstracted so it can be mocked out. +def _now() -> datetime: + """Current datetime. :meta private: """ - return datetime.now() + return datetime.now().replace(microsecond=0) + + +default_datetime = _now def _duration_conversion(unit: str) -> conversion: @@ -429,6 +430,41 @@ class Zone(BaseZone): status: ZoneStatus = field(default_factory=ZoneStatus) suspensions: list[ZoneSuspension] = field(default_factory=list) + @classmethod + def from_json(cls, zone_json: dict) -> Zone: + zone = Zone( + id=zone_json["relay_id"], + number=zone_json["relay"], + name=zone_json["name"], + ) + zone.update_with_json(zone_json) + return zone + + def update_with_json(self, zone_json: dict) -> None: + current_run = None + next_run = None + suspended_until = None + if zone_json["time"] == 1: + current_run = ScheduledZoneRun( + remaining_time=timedelta(seconds=zone_json["run"]), + ) + elif zone_json["time"] == 1576800000: + suspended_until = datetime.max + else: + start_time = _now() + timedelta(seconds=zone_json["time"]) + duration = timedelta(seconds=zone_json["run"]) + next_run = ScheduledZoneRun( + start_time=start_time, + end_time=start_time + duration, + normal_duration=duration, + duration=duration, + ) + self.scheduled_runs = ScheduledZoneRuns( + current_run=current_run, + next_run=next_run, + ) + self.status = ZoneStatus(suspended_until=suspended_until) + @dataclass class ProgramStartTimeApplication: @@ -653,6 +689,22 @@ class Controller: ) status: Optional[ControllerStatus] = None + @classmethod + def from_json(cls, controller_json: dict) -> Controller: + controller = Controller( + id=controller_json["controller_id"], + name=controller_json["name"], + hardware=ControllerHardware( + serial_number=controller_json["serial_number"], + ), + ) + controller.update_with_json(controller_json) + return controller + + def update_with_json(self, controller_json: dict) -> None: + self.last_contact_time = datetime.fromtimestamp(controller_json["last_contact"]) + self.online = True + @dataclass class UnitsSummary: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..216e953 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,437 @@ +from unittest import mock + +from pytest import fixture + +from pydrawise.schema import Controller, Sensor, User, Zone +from pydrawise.schema_utils import deserialize + + +@fixture +def rain_sensor(rain_sensor_json): + yield deserialize(Sensor, rain_sensor_json) + + +@fixture +def rain_sensor_json(): + yield { + "id": 337844, + "name": "Rain sensor ", + "model": { + "id": 3318, + "name": "Rain Sensor (normally closed wire)", + "active": True, + "offLevel": 1, + "offTimer": 0, + "delay": 0, + "divisor": 0, + "flowRate": 0, + "sensorType": "LEVEL_CLOSED", + }, + "status": { + "waterFlow": None, + "active": False, + }, + } + + +@fixture +def flow_sensor_json(): + yield { + "id": 337845, + "name": "Flow meter", + "model": { + "id": 3324, + "name": "1, 1½ or 2 inch NPT Flow Meter", + "active": True, + "offLevel": 0, + "offTimer": 0, + "delay": 0, + "divisor": 0.52834, + "flowRate": 3.7854, + "sensorType": "FLOW", + }, + "status": { + "waterFlow": { + "value": 542.0042035155608, + "unit": "gal", + }, + "active": None, + }, + } + + +@fixture +def flow_summary_json(request): + if request.param: + yield {"totalWaterVolume": {"value": 23134.67952992029, "unit": "gal"}} + else: + yield None + + +@fixture +def user(user_json): + yield deserialize(User, user_json) + + +@fixture +def user_json(controller_json): + yield { + "id": 1234, + "customerId": 2222, + "name": "My Name", + "email": "me@asdf.com", + "controllers": [controller_json], + } + + +@fixture +def controller(controller_json): + yield deserialize(Controller, controller_json) + + +@fixture +def controller_json(rain_sensor_json, flow_sensor_json): + yield { + "id": 9876, + "name": "Main Controller", + "softwareVersion": "s0", + "hardware": { + "serialNumber": "A0B1C2D3", + "version": "1.0", + "status": "All good!", + "model": { + "name": "HPC 10", + "description": "HPC 10 Station Controller", + }, + "firmware": [{"type": "A", "version": "1.0"}], + }, + "lastContactTime": { + "timestamp": 1672531200, + "value": "Sun, 01 Jan 23 00:12:00", + }, + "lastAction": { + "timestamp": 1672531200, + "value": "Sun, 01 Jan 23 00:12:00", + }, + "online": True, + "sensors": [rain_sensor_json, flow_sensor_json], + "permittedProgramStartTimes": [], + "status": { + "summary": "All good!", + "online": True, + "actualWaterTime": {"value": 10}, + "normalWaterTime": {"value": 10}, + "lastContact": { + "timestamp": 1672531200, + "value": "Sun, 01 Jan 23 00:12:00", + }, + }, + } + + +@fixture +def zone(zone_json): + yield deserialize(Zone, zone_json) + + +@fixture +def zone_json(): + yield { + "id": 0x10A, + "number": { + "value": 1, + "label": "One", + }, + "name": "Zone A", + "wateringSettings": { + "fixedWateringAdjustment": 100, + "cycleAndSoakSettings": None, + "advancedProgram": { + "id": 4729361, + "name": "", + "schedulingMethod": {"value": 0, "label": "Time Based"}, + "monthlyWateringAdjustments": [ + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + ], + "appliesToZones": [ + { + "id": 5955343, + "number": {"value": 1, "label": "Zone 1"}, + "name": "Front Lawn", + } + ], + "zoneSpecific": True, + "advancedProgramId": 5655942, + "wateringFrequency": { + "label": "Frequency", + "period": { + "value": None, + "label": "Every Program Start Time", + }, + "description": ( + "Every Program Start Time unless modified by your " + "Watering Triggers" + ), + }, + "runTimeGroup": { + "id": 49923604, + "name": None, + "duration": 20, + }, + }, + }, + "scheduledRuns": { + "summary": "", + "currentRun": None, + "nextRun": None, + "status": None, + }, + "pastRuns": {"lastRun": None, "runs": []}, + "status": { + "relativeWaterBalance": 0, + "suspendedUntil": { + "timestamp": 1672531200, + "value": "Sun, 01 Jan 23 00:12:00", + }, + }, + "suspensions": [], + } + + +@fixture +def watering_report_json(): + yield { + "watering": [ + { + "runEvent": { + "id": "35220026902", + "zone": { + "id": 5955343, + "number": {"value": 1, "label": "Zone 1"}, + "name": "Front Lawn", + }, + "standardProgram": { + "id": 343434, + "name": "", + }, + "advancedProgram": {"id": 4729361, "name": ""}, + "reportedStartTime": { + "value": "Fri, 01 Dec 23 04:00:00 -0800", + "timestamp": 1701432000, + }, + "reportedEndTime": { + "value": "Fri, 01 Dec 23 04:20:00 -0800", + "timestamp": 1701433200, + }, + "reportedDuration": 1200, + "reportedStatus": { + "value": 1, + "label": "Normal watering cycle", + }, + "reportedWaterUsage": { + "value": 34.000263855044786, + "unit": "gal", + }, + "reportedStopReason": { + "finishedNormally": True, + "description": ["Finished normally"], + }, + "reportedCurrent": {"value": 280, "unit": "mA"}, + } + }, + ] + } + + +@fixture +def watering_report_without_sensor_json(): + yield { + "watering": [ + { + "runEvent": { + "id": "35220026902", + "zone": { + "id": 5955343, + "number": {"value": 1, "label": "Zone 1"}, + "name": "Front Lawn", + }, + "standardProgram": { + "id": 343434, + "name": "", + }, + "advancedProgram": {"id": 4729361, "name": ""}, + "reportedStartTime": { + "value": "Fri, 01 Dec 23 04:00:00 -0800", + "timestamp": 1701432000, + }, + "reportedEndTime": { + "value": "Fri, 01 Dec 23 04:20:00 -0800", + "timestamp": 1701433200, + }, + "reportedDuration": 1200, + "reportedStatus": { + "value": 1, + "label": "Normal watering cycle", + }, + "reportedStopReason": { + "finishedNormally": True, + "description": ["Finished normally"], + }, + "reportedCurrent": {"value": 280, "unit": "mA"}, + } + }, + ] + } + + +@fixture +def customer_details(): + yield { + "controller_id": 9876, + "customer_id": 2222, + "current_controller": "Home Controller", + "controllers": [ + { + "name": "Main Controller", + "last_contact": 1672531200, + "serial_number": "A0B1C2D3", + "controller_id": 9876, + "status": "Unknown", + }, + { + "name": "Other Controller", + "last_contact": 1672531200, + "serial_number": "1310b36091", + "controller_id": 63507, + "status": "Unknown", + }, + ], + } + + +@fixture +def status_schedule(): + yield { + "expanders": [], + "master": 0, + "master_post_timer": 0, + "master_timer": 0, + "message": "", + "nextpoll": 60, + "options": 1, + "relays": [ + { + "name": "Zone A", + "period": 259200, + "relay": 1, + "relay_id": 0x10A, + "run": 1800, + "stop": 1, + "time": 5400, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone B", + "period": 259200, + "relay": 2, + "relay_id": 0x10B, + "run": 1788, + "stop": 1, + "time": 1, + "timestr": "Now", + "type": 106, + }, + { + "name": "Zone C", + "period": 259200, + "relay": 3, + "relay_id": 0x10C, + "run": 1800, + "stop": 1, + "time": 1576800000, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone D", + "period": 259200, + "relay": 4, + "relay_id": 0x10D, + "run": 180, + "stop": 1, + "time": 335997, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone E", + "period": 259200, + "relay": 5, + "relay_id": 0x10E, + "run": 1800, + "stop": 1, + "time": 336177, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone F", + "period": 259200, + "relay": 6, + "relay_id": 0x10F, + "run": 1800, + "stop": 1, + "time": 337977, + "timestr": "Sat", + "type": 1, + }, + ], + "sensors": [ + { + "input": 0, + "mode": 1, + "offtimer": 0, + "relays": [ + {"id": 0x10A}, + {"id": 0x10B}, + {"id": 0x10C}, + {"id": 0x10D}, + {"id": 0x10E}, + {"id": 0x10F}, + ], + "timer": 0, + "type": 1, + } + ], + "simRelays": 1, + "stupdate": 0, + "time": 1672531200, + } + + +@fixture +def success_status(): + yield {"message": "Successful message", "message_type": "info"} + + +@fixture +def mock_request(customer_details, status_schedule): + with mock.patch("requests.get") as req: + controller_info_resp = mock.Mock(return_code=200) + controller_info_resp.json.return_value = customer_details + controller_status_resp = mock.Mock(return_code=200) + controller_status_resp.json.return_value = status_schedule + req.side_effect = [controller_info_resp, controller_status_resp] + yield req diff --git a/tests/test_client.py b/tests/test_client.py index ca565b1..d13856a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -39,275 +39,9 @@ def api(mock_auth, mock_client): yield api -@fixture -def rain_sensor_json(): - yield { - "id": 337844, - "name": "Rain sensor ", - "model": { - "id": 3318, - "name": "Rain Sensor (normally closed wire)", - "active": True, - "offLevel": 1, - "offTimer": 0, - "delay": 0, - "divisor": 0, - "flowRate": 0, - "sensorType": "LEVEL_CLOSED", - }, - "status": { - "waterFlow": None, - "active": False, - }, - } - - -@fixture -def flow_sensor_json(): - yield { - "id": 337845, - "name": "Flow meter", - "model": { - "id": 3324, - "name": "1, 1½ or 2 inch NPT Flow Meter", - "active": True, - "offLevel": 0, - "offTimer": 0, - "delay": 0, - "divisor": 0.52834, - "flowRate": 3.7854, - "sensorType": "FLOW", - }, - "status": { - "waterFlow": { - "value": 542.0042035155608, - "unit": "gal", - }, - "active": None, - }, - } - - -@fixture -def flow_summary_json(request): - if request.param: - yield {"totalWaterVolume": {"value": 23134.67952992029, "unit": "gal"}} - else: - yield None - - -@fixture -def controller_json(rain_sensor_json, flow_sensor_json): - yield { - "id": 9876, - "name": "Main Controller", - "softwareVersion": "s0", - "hardware": { - "serialNumber": "A0B1C2D3", - "version": "1.0", - "status": "All good!", - "model": { - "name": "HPC 10", - "description": "HPC 10 Station Controller", - }, - "firmware": [{"type": "A", "version": "1.0"}], - }, - "lastContactTime": { - "timestamp": 1672531200, - "value": "Sun, 01 Jan 23 00:12:00", - }, - "lastAction": { - "timestamp": 1672531200, - "value": "Sun, 01 Jan 23 00:12:00", - }, - "online": True, - "sensors": [rain_sensor_json, flow_sensor_json], - "permittedProgramStartTimes": [], - "status": { - "summary": "All good!", - "online": True, - "actualWaterTime": {"value": 10}, - "normalWaterTime": {"value": 10}, - "lastContact": { - "timestamp": 1672531200, - "value": "Sun, 01 Jan 23 00:12:00", - }, - }, - } - - -@fixture -def zone_json(): - yield { - "id": 1, - "number": { - "value": 1, - "label": "One", - }, - "name": "Zone A", - "wateringSettings": { - "fixedWateringAdjustment": 100, - "cycleAndSoakSettings": None, - "advancedProgram": { - "id": 4729361, - "name": "", - "schedulingMethod": {"value": 0, "label": "Time Based"}, - "monthlyWateringAdjustments": [ - 100, - 100, - 100, - 100, - 100, - 100, - 100, - 100, - 100, - 100, - 100, - 100, - ], - "appliesToZones": [ - { - "id": 5955343, - "number": {"value": 1, "label": "Zone 1"}, - "name": "Front Lawn", - } - ], - "zoneSpecific": True, - "advancedProgramId": 5655942, - "wateringFrequency": { - "label": "Frequency", - "period": { - "value": None, - "label": "Every Program Start Time", - }, - "description": ( - "Every Program Start Time unless modified by your " - "Watering Triggers" - ), - }, - "runTimeGroup": { - "id": 49923604, - "name": None, - "duration": 20, - }, - }, - }, - "scheduledRuns": { - "summary": "", - "currentRun": None, - "nextRun": None, - "status": None, - }, - "pastRuns": {"lastRun": None, "runs": []}, - "status": { - "relativeWaterBalance": 0, - "suspendedUntil": { - "timestamp": 1672531200, - "value": "Sun, 01 Jan 23 00:12:00", - }, - }, - "suspensions": [], - } - - -@fixture -def watering_report_json(): - yield { - "watering": [ - { - "runEvent": { - "id": "35220026902", - "zone": { - "id": 5955343, - "number": {"value": 1, "label": "Zone 1"}, - "name": "Front Lawn", - }, - "standardProgram": { - "id": 343434, - "name": "", - }, - "advancedProgram": {"id": 4729361, "name": ""}, - "reportedStartTime": { - "value": "Fri, 01 Dec 23 04:00:00 -0800", - "timestamp": 1701432000, - }, - "reportedEndTime": { - "value": "Fri, 01 Dec 23 04:20:00 -0800", - "timestamp": 1701433200, - }, - "reportedDuration": 1200, - "reportedStatus": { - "value": 1, - "label": "Normal watering cycle", - }, - "reportedWaterUsage": { - "value": 34.000263855044786, - "unit": "gal", - }, - "reportedStopReason": { - "finishedNormally": True, - "description": ["Finished normally"], - }, - "reportedCurrent": {"value": 280, "unit": "mA"}, - } - }, - ] - } - - -@fixture -def watering_report_without_sensor_json(): - yield { - "watering": [ - { - "runEvent": { - "id": "35220026902", - "zone": { - "id": 5955343, - "number": {"value": 1, "label": "Zone 1"}, - "name": "Front Lawn", - }, - "standardProgram": { - "id": 343434, - "name": "", - }, - "advancedProgram": {"id": 4729361, "name": ""}, - "reportedStartTime": { - "value": "Fri, 01 Dec 23 04:00:00 -0800", - "timestamp": 1701432000, - }, - "reportedEndTime": { - "value": "Fri, 01 Dec 23 04:20:00 -0800", - "timestamp": 1701433200, - }, - "reportedDuration": 1200, - "reportedStatus": { - "value": 1, - "label": "Normal watering cycle", - }, - "reportedStopReason": { - "finishedNormally": True, - "description": ["Finished normally"], - }, - "reportedCurrent": {"value": 280, "unit": "mA"}, - } - }, - ] - } - - -async def test_get_user(api: Hydrawise, mock_session, controller_json, zone_json): - controller_json["zones"] = [zone_json] - mock_session.execute.return_value = { - "me": { - "id": 1234, - "customerId": 1, - "name": "My Name", - "email": "me@asdf.com", - "controllers": [controller_json], - } - } +async def test_get_user(api: Hydrawise, mock_session, user_json, zone_json): + user_json["controllers"][0]["zones"] = [zone_json] + mock_session.execute.return_value = {"me": user_json} user = await api.get_user() mock_session.execute.assert_awaited_once() [selector] = mock_session.execute.await_args.args @@ -315,22 +49,15 @@ async def test_get_user(api: Hydrawise, mock_session, controller_json, zone_json assert "controllers {" in query assert query.count("zones {") == 2 assert user.id == 1234 + assert user.customer_id == 2222 assert user.name == "My Name" assert user.email == "me@asdf.com" assert len(user.controllers) == 1 assert len(user.controllers[0].zones) == 1 -async def test_get_user_no_zones(api: Hydrawise, mock_session, controller_json): - mock_session.execute.return_value = { - "me": { - "id": 1234, - "customerId": 1, - "name": "My Name", - "email": "me@asdf.com", - "controllers": [controller_json], - } - } +async def test_get_user_no_zones(api: Hydrawise, mock_session, user_json): + mock_session.execute.return_value = {"me": user_json} user = await api.get_user(fetch_zones=False) mock_session.execute.assert_awaited_once() [selector] = mock_session.execute.await_args.args @@ -338,6 +65,7 @@ async def test_get_user_no_zones(api: Hydrawise, mock_session, controller_json): assert "controllers {" in query assert query.count("zones {") == 1 assert user.id == 1234 + assert user.customer_id == 2222 assert user.name == "My Name" assert user.email == "me@asdf.com" assert len(user.controllers) == 1 @@ -433,7 +161,7 @@ async def test_start_zone(api: Hydrawise, mock_session, zone_json): [selector] = mock_session.execute.await_args.args query = print_ast(selector) assert "startZone(" in query - assert "zoneId: 1" in query + assert "zoneId: 266" in query assert "markRunAsScheduled: false" in query assert "customRunDuration: 10" in query @@ -446,7 +174,7 @@ async def test_stop_zone(api: Hydrawise, mock_session, zone_json): [selector] = mock_session.execute.await_args.args query = print_ast(selector) assert "stopZone(" in query - assert "zoneId: 1" in query + assert "zoneId: 266" in query async def test_start_all_zones(api: Hydrawise, mock_session, controller_json): @@ -481,7 +209,7 @@ async def test_suspend_zone(api: Hydrawise, mock_session, zone_json): [selector] = mock_session.execute.await_args.args query = print_ast(selector) assert "suspendZone(" in query - assert "zoneId: 1" in query + assert "zoneId: 266" in query assert 'until: "Sun, 01 Jan 23 00:12:00 +0000"' in query @@ -493,7 +221,7 @@ async def test_resume_zone(api: Hydrawise, mock_session, zone_json): [selector] = mock_session.execute.await_args.args query = print_ast(selector) assert "resumeZone(" in query - assert "zoneId: 1" in query + assert "zoneId: 266" in query async def test_suspend_all_zones(api: Hydrawise, mock_session, controller_json): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py new file mode 100644 index 0000000..580b067 --- /dev/null +++ b/tests/test_hybrid.py @@ -0,0 +1,276 @@ +from copy import deepcopy +from datetime import datetime, timedelta +from unittest.mock import create_autospec + +from freezegun import freeze_time +from pytest import fixture + +from pydrawise import hybrid +from pydrawise.auth import HybridAuth +from pydrawise.client import Hydrawise + +FROZEN_TIME = "2023-01-01 01:00:00" + + +@fixture +def hybrid_auth(): + mock_auth = create_autospec(HybridAuth, instance=True, api_key="__api_key__") + mock_auth.token.return_value = "__token__" + yield mock_auth + + +@fixture +def mock_gql_client(): + yield create_autospec(Hydrawise, instance=True, spec_set=True) + + +@fixture +def api(hybrid_auth, mock_gql_client): + yield hybrid.HybridClient(hybrid_auth, gql_client=mock_gql_client) + + +def test_throttler(): + with freeze_time(FROZEN_TIME) as frozen_time: + throttle = hybrid.Throttler(epoch_interval=timedelta(seconds=60)) + assert throttle.check() + throttle.mark() + assert not throttle.check() + + # Increasing tokens_per_epoch allows another token to be consumed + throttle.tokens_per_epoch = 2 + assert throttle.check() + + # Advancing time resets the throttler, allowing 2 tokens again + frozen_time.tick(timedelta(seconds=61)) + assert throttle.check(2) + + +async def test_get_user(api, hybrid_auth, mock_gql_client, user, zone, status_schedule): + with freeze_time(FROZEN_TIME): + user.controllers[0].zones = [zone] + assert user.controllers[0].zones[0].status.suspended_until != datetime.max + + # First fetch should query the GraphQL API + mock_gql_client.get_user.return_value = deepcopy(user) + assert await api.get_user() == user + mock_gql_client.get_user.assert_awaited_once_with(fetch_zones=True) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_user.reset_mock() + assert await api.get_user() == user + mock_gql_client.get_user.assert_awaited_once_with(fetch_zones=True) + + # Third fetch should query the REST API because we're out of tokens + mock_gql_client.get_user.reset_mock() + status_schedule["relays"] = [status_schedule["relays"][0]] + status_schedule["relays"][0]["time"] = 1576800000 + status_schedule["relays"][0]["name"] = "Zone A from REST API" + hybrid_auth.get.return_value = status_schedule + user2 = await api.get_user() + mock_gql_client.get_user.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=user.controllers[0].id + ) + assert user2.controllers[0].zones[0].status.suspended_until == datetime.max + assert user2.controllers[0].zones[0].name == "Zone A" + + # Fourth fetch should query the REST API again + hybrid_auth.get.reset_mock() + assert await api.get_user() == user2 + mock_gql_client.get_user.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=user.controllers[0].id + ) + + # Fifth fetch should not make any calls and instead return cached data + hybrid_auth.get.reset_mock() + assert await api.get_user() == user2 + mock_gql_client.get_user.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + +async def test_get_controllers( + api, hybrid_auth, mock_gql_client, controller, zone, status_schedule +): + with freeze_time(FROZEN_TIME) as frozen_time: + controller.zones = [deepcopy(zone)] + assert controller.zones[0].status.suspended_until != datetime.max + + # First fetch should query the GraphQL API + mock_gql_client.get_controllers.return_value = [deepcopy(controller)] + assert await api.get_controllers() == [controller] + mock_gql_client.get_controllers.assert_awaited_once_with(True, True) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_controllers.reset_mock() + assert await api.get_controllers() == [controller] + mock_gql_client.get_controllers.assert_awaited_once_with(True, True) + + # Third fetch should query the REST API because we're out of tokens + mock_gql_client.get_controllers.reset_mock() + status_schedule["relays"] = [status_schedule["relays"][0]] + status_schedule["relays"][0]["time"] = 1576800000 + status_schedule["relays"][0]["name"] = "Zone A from REST API" + hybrid_auth.get.return_value = status_schedule + [controller2] = await api.get_controllers() + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + assert controller2.zones[0].status.suspended_until == datetime.max + assert controller2.zones[0].name == "Zone A" + + # Fourth fetch should query the REST API again + hybrid_auth.get.reset_mock() + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + + # Fifth fetch should not make any calls and instead return cached data + hybrid_auth.get.reset_mock() + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + # After 1 minute, we can query the REST API again. + # But it thinks we're polling too fast and tells us to back off. + # Make sure that we listen. + frozen_time.tick(timedelta(seconds=61)) + hybrid_auth.get.reset_mock() + status_schedule["nextpoll"] = 120 + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + # We can still make one more call + hybrid_auth.get.reset_mock() + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + # Now we have to return cached data until the throttler resets. + hybrid_auth.get.reset_mock() + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + # Allow the throttler to refresh. Now we can make more calls. + frozen_time.tick(timedelta(seconds=121)) + hybrid_auth.get.reset_mock() + assert await api.get_controllers() == [controller2] + mock_gql_client.get_controllers.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + + +async def test_get_controller(api, hybrid_auth, mock_gql_client, controller, zone): + with freeze_time(FROZEN_TIME): + controller.zones = [deepcopy(zone)] + assert controller.zones[0].status.suspended_until != datetime.max + + # First fetch should query the GraphQL API + mock_gql_client.get_controller.return_value = deepcopy(controller) + assert await api.get_controller(controller.id) == controller + mock_gql_client.get_controller.assert_awaited_once_with(controller.id) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_controller.reset_mock() + assert await api.get_controller(controller.id) == controller + mock_gql_client.get_controller.assert_awaited_once_with(controller.id) + + # Third fetch should not make any calls and instead return cached data + mock_gql_client.get_controller.reset_mock() + assert await api.get_controller(controller.id) == controller + mock_gql_client.get_controller.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + +async def test_get_zones( + api, hybrid_auth, mock_gql_client, controller, zone, status_schedule +): + with freeze_time(FROZEN_TIME): + assert zone.status.suspended_until != datetime.max + + # First fetch should query the GraphQL API + mock_gql_client.get_zones.return_value = [deepcopy(zone)] + assert await api.get_zones(controller) == [zone] + mock_gql_client.get_zones.assert_awaited_once_with(controller) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_zones.reset_mock() + assert await api.get_zones(controller) == [zone] + mock_gql_client.get_zones.assert_awaited_once_with(controller) + + # Third fetch should query the REST API because we're out of tokens + mock_gql_client.get_zones.reset_mock() + status_schedule["relays"] = [status_schedule["relays"][0]] + status_schedule["relays"][0]["time"] = 1576800000 + status_schedule["relays"][0]["name"] = "Zone A from REST API" + hybrid_auth.get.return_value = status_schedule + [zone2] = await api.get_zones(controller) + mock_gql_client.get_zones.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + assert zone2.status.suspended_until == datetime.max + assert zone2.name == "Zone A" + + # Fourth fetch should query the REST API again + hybrid_auth.get.reset_mock() + assert await api.get_zones(controller) == [zone2] + mock_gql_client.get_zones.assert_not_awaited() + hybrid_auth.get.assert_awaited_once_with( + "statusschedule.php", controller_id=controller.id + ) + + # Fifth fetch should not make any calls and instead return cached data + hybrid_auth.get.reset_mock() + assert await api.get_zones(controller) == [zone2] + mock_gql_client.get_zones.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + +async def test_get_zone(api, hybrid_auth, mock_gql_client, zone): + with freeze_time(FROZEN_TIME): + assert zone.status.suspended_until != datetime.max + + # First fetch should query the GraphQL API + mock_gql_client.get_zone.return_value = deepcopy(zone) + assert await api.get_zone(zone.id) == zone + mock_gql_client.get_zone.assert_awaited_once_with(zone.id) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_zone.reset_mock() + assert await api.get_zone(zone.id) == zone + mock_gql_client.get_zone.assert_awaited_once_with(zone.id) + + # Third fetch should not make any calls and instead return cached data + mock_gql_client.get_zone.reset_mock() + assert await api.get_zone(zone.id) == zone + mock_gql_client.get_zone.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() + + +async def test_get_sensors(api, hybrid_auth, mock_gql_client, controller, rain_sensor): + sensor = rain_sensor + with freeze_time(FROZEN_TIME): + # First fetch should query the GraphQL API + mock_gql_client.get_sensors.return_value = [deepcopy(sensor)] + assert await api.get_sensors(controller) == [sensor] + mock_gql_client.get_sensors.assert_awaited_once_with(controller) + + # Second fetch should also query the GraphQL API + mock_gql_client.get_sensors.reset_mock() + assert await api.get_sensors(controller) == [sensor] + mock_gql_client.get_sensors.assert_awaited_once_with(controller) + + # Third fetch should not make any calls and instead return cached data + mock_gql_client.get_sensors.reset_mock() + assert await api.get_sensors(controller) == [sensor] + mock_gql_client.get_sensors.assert_not_awaited() + hybrid_auth.get.assert_not_awaited() diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 92a94dc..6502e74 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -1,699 +1,216 @@ -from datetime import datetime, timedelta -import re from unittest import mock -from aiohttp import ClientTimeout -from aioresponses import aioresponses from freezegun import freeze_time -from pytest import fixture from pydrawise import legacy -from pydrawise.schema import Controller, Zone API_KEY = "__api_key__" -@fixture -def customer_details(): - yield { - "controller_id": 52496, - "customer_id": 47076, - "current_controller": "Home Controller", - "controllers": [ - { - "name": "Home Controller", - "last_contact": 1693292420, - "serial_number": "0310b36090", - "controller_id": 52496, - "status": "Unknown", - }, - { - "name": "Other Controller", - "last_contact": 1693292420, - "serial_number": "1310b36091", - "controller_id": 63507, - "status": "Unknown", - }, - ], - } - - -@fixture -def status_schedule(): - yield { - "expanders": [], - "master": 0, - "master_post_timer": 0, - "master_timer": 0, - "message": "", - "nextpoll": 60, - "options": 1, - "relays": [ - { - "name": "Drips - House", - "period": 259200, - "relay": 1, - "relay_id": 5965394, - "run": 1800, - "stop": 1, - "time": 5400, - "timestr": "Sat", - "type": 1, - }, - { - "name": "Drips - Fence", - "period": 259200, - "relay": 2, - "relay_id": 5965395, - "run": 1788, - "stop": 1, - "time": 1, - "timestr": "Now", - "type": 106, - }, - { - "name": "Rotary - Front", - "period": 259200, - "relay": 3, - "relay_id": 5965396, - "run": 1800, - "stop": 1, - "time": 1576800000, - "timestr": "Sat", - "type": 1, +def test_update(mock_request, customer_details, status_schedule): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.assert_has_calls( + [ + mock.call( + "https://api.hydrawise.com/api/v1/customerdetails.php", + params={"api_key": API_KEY, "type": "controllers"}, + timeout=10, + ), + mock.call( + "https://api.hydrawise.com/api/v1/statusschedule.php", + params={"api_key": API_KEY}, + timeout=10, + ), + ] + ) + assert client.controller_info == customer_details + assert client.controller_status == status_schedule + + +def test_attributes(mock_request, customer_details, status_schedule): + client = legacy.LegacyHydrawise(API_KEY) + assert client.current_controller == customer_details["controllers"][0] + assert client.status == "Unknown" + assert client.controller_id == 9876 + assert client.customer_id == 2222 + assert client.num_relays == 6 + assert client.relays == status_schedule["relays"] + assert list(client.relays_by_id.keys()) == [ + 0x10A, + 0x10B, + 0x10C, + 0x10D, + 0x10E, + 0x10F, + ] + assert list(client.relays_by_zone_number.keys()) == [1, 2, 3, 4, 5, 6] + assert client.name == "Main Controller" + assert client.sensors == status_schedule["sensors"] + assert client.running is None + + +@mock.patch("requests.get") +def test_attributes_not_initialized(mock_request): + mock_request.side_effect = NotImplementedError + client = legacy.LegacyHydrawise(API_KEY, load_on_init=False) + assert client.controller_info == {} + assert client.controller_status == {} + assert client.current_controller == {} + assert client.status is None + assert client.controller_id is None + assert client.customer_id is None + assert client.num_relays == 0 + assert client.relays == [] + assert client.relays_by_id == {} + assert client.relays_by_zone_number == {} + assert client.name is None + assert client.sensors == [] + assert client.running is None + + +def test_suspend_zone(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.suspend_zone(1, 1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspend", + "custom": 1672617600, + "period_id": 999, + "relay_id": 0x10A, }, - { - "name": "Sprays - Side L", - "period": 259200, - "relay": 4, - "relay_id": 5965397, - "run": 180, - "stop": 1, - "time": 335997, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_suspend_zone_unsuspend(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.suspend_zone(0, 1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspend", + "period_id": 0, + "relay_id": 0x10A, }, - { - "name": "Rotary - Back N", - "period": 259200, - "relay": 5, - "relay_id": 5965398, - "run": 1800, - "stop": 1, - "time": 336177, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_suspend_zone_all(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.suspend_zone(1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspendall", + "custom": 1672617600, + "period_id": 999, }, - { - "name": "Rotary - Back C", - "period": 259200, - "relay": 6, - "relay_id": 5965399, - "run": 1800, - "stop": 1, - "time": 337977, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_run_zone(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.run_zone(1, 1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "run", + "custom": 60, + "period_id": 999, + "relay_id": 0x10A, }, - { - "name": "Rotary - Back F", - "period": 259200, - "relay": 7, - "relay_id": 5965400, - "run": 1200, - "stop": 1, - "time": 339777, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_run_zone_all(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.run_zone(1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "runall", + "custom": 60, + "period_id": 999, }, - { - "name": "Sprays - Side R", - "period": 259200, - "relay": 8, - "relay_id": 5965401, - "run": 480, - "stop": 1, - "time": 340977, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_run_zone_stop(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.run_zone(0, 1) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "stop", + "period_id": 0, + "relay_id": 0x10A, }, - { - "name": "Sprays - Drivew", - "period": 259200, - "relay": 9, - "relay_id": 5965402, - "run": 900, - "stop": 1, - "time": 341457, - "timestr": "Sat", - "type": 1, + timeout=10, + ) + + +def test_run_zone_stop_all(mock_request, success_status): + client = legacy.LegacyHydrawise(API_KEY) + mock_request.reset_mock(return_value=True, side_effect=True) + + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = success_status + + with freeze_time("2023-01-01 00:00:00"): + assert client.run_zone(0) == success_status + mock_request.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "stopall", + "period_id": 0, }, - ], - "sensors": [ - { - "input": 0, - "mode": 1, - "offtimer": 0, - "relays": [ - {"id": 5965394}, - {"id": 5965395}, - {"id": 5965396}, - {"id": 5965397}, - {"id": 5965398}, - {"id": 5965399}, - {"id": 5965400}, - {"id": 5965401}, - {"id": 5965402}, - ], - "timer": 0, - "type": 1, - } - ], - "simRelays": 1, - "stupdate": 0, - "time": 1693303803, - } - - -@fixture -def success_status(): - yield {"message": "Successful message", "message_type": "info"} - - -@fixture -def mock_request(customer_details, status_schedule): - with mock.patch("requests.get") as req: - controller_info_resp = mock.Mock(return_code=200) - controller_info_resp.json.return_value = customer_details - controller_status_resp = mock.Mock(return_code=200) - controller_status_resp.json.return_value = status_schedule - req.side_effect = [controller_info_resp, controller_status_resp] - yield req - - -class TestLegacyHydrawiseAsync: - """Test the LegacyHydrawiseAsync class.""" - - async def test_get_user( - self, customer_details: dict, status_schedule: dict - ) -> None: - """Test the get_user method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with freeze_time("2023-01-01 01:00:00"): - with aioresponses() as m: - m.get( - f"https://api.hydrawise.com/api/v1/customerdetails.php?api_key={API_KEY}", - status=200, - payload=customer_details, - ) - m.get( - f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=52496", - status=200, - payload=status_schedule, - ) - m.get( - f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=63507", - status=200, - payload=status_schedule, - ) - user = await client.get_user() - assert user.customer_id == 47076 - assert [c.id for c in user.controllers] == [52496, 63507] - want_zones = [ - 5965394, - 5965395, - 5965396, - 5965397, - 5965398, - 5965399, - 5965400, - 5965401, - 5965402, - ] - assert [z.id for z in user.controllers[0].zones] == want_zones - assert [z.id for z in user.controllers[1].zones] == want_zones - - async def test_get_controllers( - self, customer_details: dict, status_schedule: dict - ) -> None: - """Test the get_controllers method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with freeze_time("2023-01-01 01:00:00"): - with aioresponses() as m: - m.get( - f"https://api.hydrawise.com/api/v1/customerdetails.php?api_key={API_KEY}&type=controllers", - status=200, - payload=customer_details, - ) - m.get( - f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=52496", - status=200, - payload=status_schedule, - ) - m.get( - f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=63507", - status=200, - payload=status_schedule, - ) - controllers = await client.get_controllers() - assert [c.id for c in controllers] == [52496, 63507] - assert controllers[0].name == "Home Controller" - assert controllers[1].name == "Other Controller" - assert controllers[0].hardware.serial_number == "0310b36090" - assert controllers[1].hardware.serial_number == "1310b36091" - want_last_contact_time = datetime(2023, 8, 29, 7, 0, 20) - assert controllers[0].last_contact_time == want_last_contact_time - assert controllers[1].last_contact_time == want_last_contact_time - want_zones = [ - 5965394, - 5965395, - 5965396, - 5965397, - 5965398, - 5965399, - 5965400, - 5965401, - 5965402, - ] - assert [z.id for z in controllers[0].zones] == want_zones - assert [z.id for z in controllers[1].zones] == want_zones - - async def test_get_zones(self, status_schedule: dict) -> None: - """Test the get_zones method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with freeze_time("2023-01-01 01:00:00"): - with aioresponses() as m: - m.get( - f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=12345", - status=200, - payload=status_schedule, - ) - zones = await client.get_zones(Controller(id=12345)) - assert [z.id for z in zones] == [ - 5965394, - 5965395, - 5965396, - 5965397, - 5965398, - 5965399, - 5965400, - 5965401, - 5965402, - ] - assert zones[0].name == "Drips - House" - assert zones[0].number == 1 - assert zones[0].scheduled_runs.current_run is None - next_run = zones[0].scheduled_runs.next_run - assert next_run.start_time == datetime(2023, 1, 1, 2, 30) - assert next_run.normal_duration == timedelta(seconds=1800) - assert next_run.duration == timedelta(seconds=1800) - - assert zones[1].name == "Drips - Fence" - assert zones[1].number == 2 - current_run = zones[1].scheduled_runs.current_run - assert current_run.start_time == datetime(2023, 1, 1, 1, 0, 0) - assert current_run.end_time == datetime(2023, 1, 1, 1, 0, 0) - assert current_run.normal_duration == timedelta(minutes=0) - assert current_run.duration == timedelta(minutes=0) - assert current_run.remaining_time == timedelta(seconds=1788) - assert zones[1].scheduled_runs.next_run is None - - assert zones[2].name == "Rotary - Front" - assert zones[2].number == 3 - assert zones[2].scheduled_runs.current_run is None - assert zones[2].scheduled_runs.next_run is None - assert zones[2].status.suspended_until == datetime.max - - async def test_start_zone(self, success_status: dict) -> None: - """Test the start_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - zone = mock.create_autospec(Zone) - zone.id = 12345 - await client.start_zone(zone) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "run", - "relay_id": 12345, - "period_id": 999, - }, - timeout=ClientTimeout(total=10), - ) - - async def test_stop_zone(self, success_status: dict) -> None: - """Test the stop_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - zone = mock.create_autospec(Zone) - zone.id = 12345 - await client.stop_zone(zone) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={"api_key": API_KEY, "action": "stop", "relay_id": 12345}, - timeout=ClientTimeout(total=10), - ) - - async def test_start_all_zones(self, success_status: dict) -> None: - """Test the start_all_zones method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - await client.start_all_zones(Controller(id=1111)) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "runall", - "period_id": 999, - "controller_id": 1111, - }, - timeout=ClientTimeout(total=10), - ) - - async def test_stop_all_zones(self, success_status: dict) -> None: - """Test the stop_all_zones method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - await client.stop_all_zones(Controller(id=1111)) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={"api_key": API_KEY, "action": "stopall", "controller_id": 1111}, - timeout=ClientTimeout(total=10), - ) - - async def test_suspend_zone(self, success_status: dict) -> None: - """Test the suspend_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - zone = mock.create_autospec(Zone) - zone.id = 12345 - await client.suspend_zone(zone, datetime(2023, 1, 2, 1, 0)) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspend", - "relay_id": 12345, - "period_id": 999, - "custom": 1672621200, - }, - timeout=ClientTimeout(total=10), - ) - - async def test_resume_zone(self, success_status: dict) -> None: - """Test the resume_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - zone = mock.create_autospec(Zone) - zone.id = 12345 - await client.resume_zone(zone) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspend", - "relay_id": 12345, - "period_id": 0, - }, - timeout=ClientTimeout(total=10), - ) - - async def test_suspend_all_zones(self, success_status: dict) -> None: - """Test the suspend_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - await client.suspend_all_zones( - Controller(id=1111), datetime(2023, 1, 2, 1, 0) - ) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspendall", - "period_id": 999, - "custom": 1672621200, - "controller_id": 1111, - }, - timeout=ClientTimeout(total=10), - ) - - async def test_resume_all_zones(self, success_status: dict) -> None: - """Test the suspend_zone method.""" - client = legacy.LegacyHydrawiseAsync(API_KEY) - with aioresponses() as m: - m.get( - re.compile("https://api.hydrawise.com/api/v1/setzone.php"), - status=200, - payload=success_status, - ) - await client.resume_all_zones(Controller(id=1111)) - m.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspendall", - "period_id": 0, - "controller_id": 1111, - }, - timeout=ClientTimeout(total=10), - ) - - -class TestLegacyHydrawise: - def test_update(self, mock_request, customer_details, status_schedule): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.assert_has_calls( - [ - mock.call( - "https://api.hydrawise.com/api/v1/customerdetails.php", - params={"api_key": API_KEY, "type": "controllers"}, - timeout=10, - ), - mock.call( - "https://api.hydrawise.com/api/v1/statusschedule.php", - params={"api_key": API_KEY}, - timeout=10, - ), - ] + timeout=10, ) - assert client.controller_info == customer_details - assert client.controller_status == status_schedule - - def test_attributes(self, mock_request, customer_details, status_schedule): - client = legacy.LegacyHydrawise(API_KEY) - assert client.current_controller == customer_details["controllers"][0] - assert client.status == "Unknown" - assert client.controller_id == 52496 - assert client.customer_id == 47076 - assert client.num_relays == 9 - assert client.relays == status_schedule["relays"] - assert list(client.relays_by_id.keys()) == [ - 5965394, - 5965395, - 5965396, - 5965397, - 5965398, - 5965399, - 5965400, - 5965401, - 5965402, - ] - assert list(client.relays_by_zone_number.keys()) == [1, 2, 3, 4, 5, 6, 7, 8, 9] - assert client.name == "Home Controller" - assert client.sensors == status_schedule["sensors"] - assert client.running is None - - @mock.patch("requests.get") - def test_attributes_not_initialized(self, mock_request): - mock_request.side_effect = NotImplementedError - client = legacy.LegacyHydrawise(API_KEY, load_on_init=False) - assert client.controller_info == {} - assert client.controller_status == {} - assert client.current_controller == {} - assert client.status is None - assert client.controller_id is None - assert client.customer_id is None - assert client.num_relays == 0 - assert client.relays == [] - assert client.relays_by_id == {} - assert client.relays_by_zone_number == {} - assert client.name is None - assert client.sensors == [] - assert client.running is None - - def test_suspend_zone(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.suspend_zone(1, 1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspend", - "custom": 1672617600, - "period_id": 999, - "relay_id": 5965394, - }, - timeout=10, - ) - - def test_suspend_zone_unsuspend(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.suspend_zone(0, 1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspend", - "period_id": 0, - "relay_id": 5965394, - }, - timeout=10, - ) - - def test_suspend_zone_all(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.suspend_zone(1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "suspendall", - "custom": 1672617600, - "period_id": 999, - }, - timeout=10, - ) - - def test_run_zone(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.run_zone(1, 1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "run", - "custom": 60, - "period_id": 999, - "relay_id": 5965394, - }, - timeout=10, - ) - - def test_run_zone_all(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.run_zone(1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "runall", - "custom": 60, - "period_id": 999, - }, - timeout=10, - ) - - def test_run_zone_stop(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.run_zone(0, 1) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "stop", - "period_id": 0, - "relay_id": 5965394, - }, - timeout=10, - ) - - def test_run_zone_stop_all(self, mock_request, success_status): - client = legacy.LegacyHydrawise(API_KEY) - mock_request.reset_mock(return_value=True, side_effect=True) - - mock_request.return_value.status_code = 200 - mock_request.return_value.json.return_value = success_status - - with freeze_time("2023-01-01 00:00:00"): - assert client.run_zone(0) == success_status - mock_request.assert_called_once_with( - "https://api.hydrawise.com/api/v1/setzone.php", - params={ - "api_key": API_KEY, - "action": "stopall", - "period_id": 0, - }, - timeout=10, - ) diff --git a/tests/test_rest.py b/tests/test_rest.py new file mode 100644 index 0000000..9dc6dad --- /dev/null +++ b/tests/test_rest.py @@ -0,0 +1,316 @@ +import re +from datetime import datetime, timedelta +from unittest import mock + +from aioresponses import aioresponses +from freezegun import freeze_time +from pytest import fixture, raises + +from pydrawise import rest +from pydrawise.auth import RestAuth +from pydrawise.const import REQUEST_TIMEOUT +from pydrawise.exceptions import NotAuthorizedError +from pydrawise.schema import Controller, Zone + +API_KEY = "__api_key__" + + +@fixture +def rest_auth(): + return RestAuth(API_KEY) + + +async def test_get_user_error(rest_auth: RestAuth) -> None: + """Test that errors are handled correctly.""" + client = rest.RestClient(rest_auth) + with freeze_time("2023-01-01 01:00:00"): + with aioresponses() as m: + m.get( + f"https://api.hydrawise.com/api/v1/customerdetails.php?api_key={API_KEY}", + status=404, + body="API key not valid", + ) + with raises(NotAuthorizedError): + await client.get_user() + + +async def test_get_user( + rest_auth: RestAuth, customer_details: dict, status_schedule: dict +) -> None: + """Test the get_user method.""" + client = rest.RestClient(rest_auth) + with freeze_time("2023-01-01 01:00:00"): + with aioresponses() as m: + m.get( + f"https://api.hydrawise.com/api/v1/customerdetails.php?api_key={API_KEY}", + status=200, + payload=customer_details, + ) + m.get( + f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=9876", + status=200, + payload=status_schedule, + ) + m.get( + f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=63507", + status=200, + payload=status_schedule, + ) + user = await client.get_user() + assert user.customer_id == 2222 + assert [c.id for c in user.controllers] == [9876, 63507] + want_zones = [0x10A, 0x10B, 0x10C, 0x10D, 0x10E, 0x10F] + assert [z.id for z in user.controllers[0].zones] == want_zones + assert [z.id for z in user.controllers[1].zones] == want_zones + assert client.next_poll == timedelta(seconds=60) + + +async def test_get_controllers( + rest_auth: RestAuth, customer_details: dict, status_schedule: dict +) -> None: + """Test the get_controllers method.""" + client = rest.RestClient(rest_auth) + with freeze_time("2023-01-01 01:00:00"): + with aioresponses() as m: + m.get( + f"https://api.hydrawise.com/api/v1/customerdetails.php?api_key={API_KEY}&type=controllers", + status=200, + payload=customer_details, + ) + m.get( + f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=9876", + status=200, + payload=status_schedule, + ) + m.get( + f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=63507", + status=200, + payload=status_schedule, + ) + controllers = await client.get_controllers() + assert [c.id for c in controllers] == [9876, 63507] + assert controllers[0].name == "Main Controller" + assert controllers[1].name == "Other Controller" + assert controllers[0].hardware.serial_number == "A0B1C2D3" + assert controllers[1].hardware.serial_number == "1310b36091" + want_last_contact_time = datetime(2023, 1, 1, 0, 0, 0) + assert controllers[0].last_contact_time == want_last_contact_time + assert controllers[1].last_contact_time == want_last_contact_time + want_zones = [0x10A, 0x10B, 0x10C, 0x10D, 0x10E, 0x10F] + assert [z.id for z in controllers[0].zones] == want_zones + assert [z.id for z in controllers[1].zones] == want_zones + + +async def test_get_zones(rest_auth: RestAuth, status_schedule: dict) -> None: + """Test the get_zones method.""" + client = rest.RestClient(rest_auth) + with freeze_time("2023-01-01 01:00:00"): + with aioresponses() as m: + m.get( + f"https://api.hydrawise.com/api/v1/statusschedule.php?api_key={API_KEY}&controller_id=12345", + status=200, + payload=status_schedule, + ) + zones = await client.get_zones(Controller(id=12345)) + assert [z.id for z in zones] == [0x10A, 0x10B, 0x10C, 0x10D, 0x10E, 0x10F] + assert zones[0].name == "Zone A" + assert zones[0].number == 1 + assert zones[0].scheduled_runs.current_run is None + next_run = zones[0].scheduled_runs.next_run + assert next_run is not None + assert next_run.start_time == datetime(2023, 1, 1, 2, 30) + assert next_run.normal_duration == timedelta(seconds=1800) + assert next_run.duration == timedelta(seconds=1800) + + assert zones[1].name == "Zone B" + assert zones[1].number == 2 + current_run = zones[1].scheduled_runs.current_run + assert current_run is not None + assert current_run.start_time == datetime(2023, 1, 1, 1, 0, 0) + assert current_run.end_time == datetime(2023, 1, 1, 1, 0, 0) + assert current_run.normal_duration == timedelta(minutes=0) + assert current_run.duration == timedelta(minutes=0) + assert current_run.remaining_time == timedelta(seconds=1788) + assert zones[1].scheduled_runs.next_run is None + + assert zones[2].name == "Zone C" + assert zones[2].number == 3 + assert zones[2].scheduled_runs.current_run is None + assert zones[2].scheduled_runs.next_run is None + assert zones[2].status.suspended_until == datetime.max + + +async def test_start_zone(rest_auth: RestAuth, success_status: dict) -> None: + """Test the start_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + zone = mock.create_autospec(Zone) + zone.id = 12345 + await client.start_zone(zone) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "run", + "relay_id": 12345, + "period_id": 999, + }, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_stop_zone(rest_auth: RestAuth, success_status: dict) -> None: + """Test the stop_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + zone = mock.create_autospec(Zone) + zone.id = 12345 + await client.stop_zone(zone) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={"api_key": API_KEY, "action": "stop", "relay_id": 12345}, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_start_all_zones(rest_auth: RestAuth, success_status: dict) -> None: + """Test the start_all_zones method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + await client.start_all_zones(Controller(id=1111)) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "runall", + "period_id": 999, + "controller_id": 1111, + }, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_stop_all_zones(rest_auth: RestAuth, success_status: dict) -> None: + """Test the stop_all_zones method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + await client.stop_all_zones(Controller(id=1111)) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={"api_key": API_KEY, "action": "stopall", "controller_id": 1111}, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_suspend_zone(rest_auth: RestAuth, success_status: dict) -> None: + """Test the suspend_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + zone = mock.create_autospec(Zone) + zone.id = 12345 + await client.suspend_zone(zone, datetime(2023, 1, 2, 1, 0)) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspend", + "relay_id": 12345, + "period_id": 999, + "custom": 1672621200, + }, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_resume_zone(rest_auth: RestAuth, success_status: dict) -> None: + """Test the resume_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + zone = mock.create_autospec(Zone) + zone.id = 12345 + await client.resume_zone(zone) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspend", + "relay_id": 12345, + "period_id": 0, + }, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_suspend_all_zones(rest_auth: RestAuth, success_status: dict) -> None: + """Test the suspend_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + await client.suspend_all_zones(Controller(id=1111), datetime(2023, 1, 2, 1, 0)) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspendall", + "period_id": 999, + "custom": 1672621200, + "controller_id": 1111, + }, + timeout=REQUEST_TIMEOUT, + ) + + +async def test_resume_all_zones(rest_auth: RestAuth, success_status: dict) -> None: + """Test the suspend_zone method.""" + client = rest.RestClient(rest_auth) + with aioresponses() as m: + m.get( + re.compile("https://api.hydrawise.com/api/v1/setzone.php"), + status=200, + payload=success_status, + ) + await client.resume_all_zones(Controller(id=1111)) + m.assert_called_once_with( + "https://api.hydrawise.com/api/v1/setzone.php", + params={ + "api_key": API_KEY, + "action": "suspendall", + "period_id": 0, + "controller_id": 1111, + }, + timeout=REQUEST_TIMEOUT, + )