From b0149f35bc8a1060fea9443a347cd9b80d91a8e8 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 00:12:55 +0000 Subject: [PATCH 1/6] Report active watering time --- pydrawise/client.py | 8 +++++++- pydrawise/schema.py | 4 ++++ tests/test_client.py | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index 1eb4948..9e98dcc 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -1,6 +1,6 @@ """Client library for interacting with Hydrawise's cloud API.""" -from datetime import datetime +from datetime import datetime, timedelta from functools import cache from importlib import resources import logging @@ -428,11 +428,17 @@ async def get_water_use_summary( and entry.run_event.reported_water_usage is not None ): active_use = entry.run_event.reported_water_usage.value + active_time = entry.run_event.reported_duration if summary.unit == "": summary.unit = entry.run_event.reported_water_usage.unit summary.total_active_use += active_use + summary.total_active_time += active_time summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use + summary.active_time_by_zone_id.setdefault( + entry.run_event.zone.id, timedelta() + ) + summary.active_time_by_zone_id[entry.run_event.zone.id] += active_time # total active and inactive water use for sensor in result["controller"]["sensors"]: diff --git a/pydrawise/schema.py b/pydrawise/schema.py index e969409..6b48419 100644 --- a/pydrawise/schema.py +++ b/pydrawise/schema.py @@ -673,5 +673,9 @@ class ControllerWaterUseSummary: total_use: float = 0.0 total_active_use: float = 0.0 total_inactive_use: float = 0.0 + total_active_time: timedelta = field( + metadata=_duration_conversion("seconds"), default=timedelta() + ) active_use_by_zone_id: dict[int, float] = field(default_factory=dict) + active_time_by_zone_id: dict[int, timedelta] = field(default_factory=dict) unit: str = "" diff --git a/tests/test_client.py b/tests/test_client.py index c4d8e12..4ed4fd5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -581,8 +581,10 @@ async def test_get_water_use_summary( assert "watering" in query assert "flowSummary(" in query assert summary.active_use_by_zone_id[5955343] == 34.000263855044786 + assert summary.active_time_by_zone_id[5955343] == timedelta(seconds=1200) assert summary.total_active_use == 34.000263855044786 assert summary.total_inactive_use == ( 23100.679266065246 if flow_summary_json else 0.0 ) + assert summary.total_active_time == timedelta(seconds=1200) assert summary.unit == "gal" From 1d89f1c7b3e798d5e419e3973c77451eab5120ec Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 01:39:16 +0000 Subject: [PATCH 2/6] Make it work even in the absence of flow sensors --- pydrawise/client.py | 102 +++++++++++++++++++++++++++---------------- pydrawise/schema.py | 4 +- tests/test_client.py | 63 ++++++++++++++++++++------ 3 files changed, 116 insertions(+), 53 deletions(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index 9e98dcc..0f1887c 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -16,6 +16,7 @@ from .schema import ( Controller, ControllerWaterUseSummary, + CustomSensorTypeEnum, DateTime, LocalizedValueType, Sensor, @@ -391,14 +392,21 @@ async def get_water_use_summary( :param controller: The controller whose water use to report. :param start: Start time :param end: End time.""" - selector = self._schema.Query.controller(controllerId=controller.id).select( - self._schema.Controller.sensors.select( - *get_selectors(self._schema, Sensor), - self._schema.Sensor.flowSummary( - start=int(start.timestamp()), - end=int(end.timestamp()), - ).select(*get_selectors(self._schema, SensorFlowSummary)), - ), + has_flow_sensors = controller.sensors and ( + len( + list( + filter( + lambda sensor: sensor.model.sensor_type + is CustomSensorTypeEnum.FLOW, + controller.sensors, + ) + ) + ) + > 0 + ) + selectors = [ + # Request the watering report that contains both the + # amount of water used as well as the watering time. self._schema.Controller.reports.select( self._schema.Reports.watering( **{ @@ -406,10 +414,23 @@ async def get_water_use_summary( "until": int(end.timestamp()), } ).select(*get_selectors(self._schema, WateringReportEntry)), - ), + ) + ] + if has_flow_sensors: + # Only request the flow summary in the presence of flow sensors + selectors.append( + self._schema.Controller.sensors.select( + *get_selectors(self._schema, Sensor), + self._schema.Sensor.flowSummary( + start=int(start.timestamp()), + end=int(end.timestamp()), + ).select(*get_selectors(self._schema, SensorFlowSummary)), + ) + ) + selector = self._schema.Query.controller(controllerId=controller.id).select( + *selectors ) result = await self._query(selector) - summary = ControllerWaterUseSummary() # watering report entries entries = _prune_watering_report_entries( @@ -420,42 +441,47 @@ async def get_water_use_summary( end, ) - # total active water use + # total active water use and time + summary = ControllerWaterUseSummary() for entry in entries: - if ( - entry.run_event is not None - and entry.run_event.zone is not None - and entry.run_event.reported_water_usage is not None - ): - active_use = entry.run_event.reported_water_usage.value + if entry.run_event is not None and entry.run_event.zone is not None: + if ( + entry.run_event.reported_water_usage is not None + and has_flow_sensors + ): + active_use = entry.run_event.reported_water_usage.value + if summary.unit == "": + summary.unit = entry.run_event.reported_water_usage.unit + summary.total_active_use += active_use + summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) + summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use + active_time = entry.run_event.reported_duration - if summary.unit == "": - summary.unit = entry.run_event.reported_water_usage.unit - summary.total_active_use += active_use summary.total_active_time += active_time - summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) - summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use summary.active_time_by_zone_id.setdefault( entry.run_event.zone.id, timedelta() ) summary.active_time_by_zone_id[entry.run_event.zone.id] += active_time # total active and inactive water use - for sensor in result["controller"]["sensors"]: - if ( - "FLOW" in sensor["model"]["sensorType"] - and "flowSummary" in sensor - and (flow_summary := sensor["flowSummary"]) is not None - ): - summary.total_use += flow_summary["totalWaterVolume"]["value"] - if summary.unit == "": - summary.unit = flow_summary["totalWaterVolume"]["unit"] - - # Correct for inaccuracies. The watering report and flow summaries are not always - # updated with the same frequency. - if summary.total_use > summary.total_active_use: - summary.total_inactive_use = summary.total_use - summary.total_active_use - else: - summary.total_use = summary.total_active_use + if has_flow_sensors: + for sensor in result["controller"]["sensors"]: + if ( + "FLOW" in sensor["model"]["sensorType"] + and "flowSummary" in sensor + and (flow_summary := sensor["flowSummary"]) is not None + ): + summary.total_use += flow_summary["totalWaterVolume"]["value"] + if summary.unit == "": + summary.unit = flow_summary["totalWaterVolume"]["unit"] + + # Correct for inaccuracies. The watering report and flow summaries are not always + # updated with the same frequency. + if summary.total_use > summary.total_active_use: + summary.total_inactive_use = ( + summary.total_use - summary.total_active_use + ) + else: + summary.total_use = summary.total_active_use return summary diff --git a/pydrawise/schema.py b/pydrawise/schema.py index 6b48419..eddcaa3 100644 --- a/pydrawise/schema.py +++ b/pydrawise/schema.py @@ -665,7 +665,9 @@ class ControllerWaterUseSummary: Active use means water use during a scheduled or manual zone run. Inactive use means water use when no zone was actively running. This can happen when - faucets (i.e., garden hoses) are installed downstream of the flow meter. + faucets (i.e., garden hoses) are installed downstream of the flow meter. Water use + is only reported in the presence of a flow sensor. Active watering time is always + reported. """ _pydrawise_type = True diff --git a/tests/test_client.py b/tests/test_client.py index 4ed4fd5..a4c83dd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -249,33 +249,40 @@ def watering_report_json(): "reportedCurrent": {"value": 280, "unit": "mA"}, } }, + ] + } + + +@fixture +def watering_report_without_sensor_json(): + yield { + "watering": [ { "runEvent": { - "id": "35220026903", + "id": "35220026902", "zone": { - "id": 5955345, - "number": {"value": 2, "label": "Zone 2"}, - "name": "Front Trees", + "id": 5955343, + "number": {"value": 1, "label": "Zone 1"}, + "name": "Front Lawn", }, - "standardProgram": None, - "advancedProgram": {"id": 4729362, "name": ""}, + "standardProgram": { + "id": 343434, + "name": "", + }, + "advancedProgram": {"id": 4729361, "name": ""}, "reportedStartTime": { - "value": "Fri, 01 Nov 23 04:19:59 -0800", - "timestamp": 1698797999, + "value": "Fri, 01 Dec 23 04:00:00 -0800", + "timestamp": 1701432000, }, "reportedEndTime": { - "value": "Fri, 01 Nov 23 04:39:59 -0800", - "timestamp": 1698799199, + "value": "Fri, 01 Dec 23 04:20:00 -0800", + "timestamp": 1701433200, }, "reportedDuration": 1200, "reportedStatus": { "value": 1, "label": "Normal watering cycle", }, - "reportedWaterUsage": { - "value": 49.00048126864295, - "unit": "gal", - }, "reportedStopReason": { "finishedNormally": True, "description": ["Finished normally"], @@ -588,3 +595,31 @@ async def test_get_water_use_summary( ) assert summary.total_active_time == timedelta(seconds=1200) assert summary.unit == "gal" + + +async def test_get_water_use_summary_without_sensor( + api: Hydrawise, + mock_session, + controller_json, + watering_report_without_sensor_json, +): + mock_session.execute.return_value = { + "controller": { + "reports": watering_report_without_sensor_json, + } + } + ctrl = deserialize(Controller, controller_json) + ctrl.sensors = None + summary = await api.get_water_use_summary( + ctrl, datetime(2023, 12, 1, 0, 0, 0), datetime(2023, 12, 4, 0, 0, 0) + ) + mock_session.execute.assert_awaited_once() + [selector] = mock_session.execute.await_args.args + query = print_ast(selector) + assert "reports" in query + assert "watering" in query + assert 5955343 not in summary.active_use_by_zone_id + assert summary.active_time_by_zone_id[5955343] == timedelta(seconds=1200) + assert summary.total_active_use == 0.0 + assert summary.total_inactive_use == 0.0 + assert summary.total_active_time == timedelta(seconds=1200) From d12721849749fb2a2c4c024f6fb7674558f009cf Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 16:22:32 +0000 Subject: [PATCH 3/6] Address feedback --- pydrawise/client.py | 96 ++++++++++++++++++++++---------------------- pydrawise/schema.py | 20 ++++++--- tests/test_client.py | 4 +- 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index 0f1887c..1433c71 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -4,6 +4,7 @@ from functools import cache from importlib import resources import logging +from typing import cast from gql import Client from gql.dsl import DSLField, DSLMutation, DSLQuery, DSLSchema, DSLSelectable, dsl_gql @@ -21,6 +22,7 @@ LocalizedValueType, Sensor, SensorFlowSummary, + SensorWithFlowSummary, StatusCodeAndSummary, User, WateringReportEntry, @@ -392,17 +394,8 @@ async def get_water_use_summary( :param controller: The controller whose water use to report. :param start: Start time :param end: End time.""" - has_flow_sensors = controller.sensors and ( - len( - list( - filter( - lambda sensor: sensor.model.sensor_type - is CustomSensorTypeEnum.FLOW, - controller.sensors, - ) - ) - ) - > 0 + has_flow_sensors = controller.sensors and any( + s.model.sensor_type == CustomSensorTypeEnum.FLOW for s in controller.sensors ) selectors = [ # Request the watering report that contains both the @@ -443,45 +436,52 @@ async def get_water_use_summary( # total active water use and time summary = ControllerWaterUseSummary() + if has_flow_sensors: + summary.total_use = 0.0 + summary.total_active_use = 0.0 + summary.total_inactive_use = 0.0 for entry in entries: - if entry.run_event is not None and entry.run_event.zone is not None: - if ( - entry.run_event.reported_water_usage is not None - and has_flow_sensors - ): - active_use = entry.run_event.reported_water_usage.value - if summary.unit == "": - summary.unit = entry.run_event.reported_water_usage.unit - summary.total_active_use += active_use - summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) - summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use - - active_time = entry.run_event.reported_duration - summary.total_active_time += active_time - summary.active_time_by_zone_id.setdefault( - entry.run_event.zone.id, timedelta() + if entry.run_event is None or entry.run_event.zone is None: + continue + + if entry.run_event.reported_water_usage is not None and has_flow_sensors: + active_use = entry.run_event.reported_water_usage.value + if summary.unit is None: + summary.unit = entry.run_event.reported_water_usage.unit + summary.total_active_use = ( + cast(float, summary.total_active_use) + active_use ) - summary.active_time_by_zone_id[entry.run_event.zone.id] += active_time + summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) + summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use - # total active and inactive water use - if has_flow_sensors: - for sensor in result["controller"]["sensors"]: - if ( - "FLOW" in sensor["model"]["sensorType"] - and "flowSummary" in sensor - and (flow_summary := sensor["flowSummary"]) is not None - ): - summary.total_use += flow_summary["totalWaterVolume"]["value"] - if summary.unit == "": - summary.unit = flow_summary["totalWaterVolume"]["unit"] - - # Correct for inaccuracies. The watering report and flow summaries are not always - # updated with the same frequency. - if summary.total_use > summary.total_active_use: - summary.total_inactive_use = ( - summary.total_use - summary.total_active_use - ) - else: - summary.total_use = summary.total_active_use + active_time = entry.run_event.reported_duration + summary.total_active_time += active_time + summary.active_time_by_zone_id.setdefault( + entry.run_event.zone.id, timedelta() + ) + summary.active_time_by_zone_id[entry.run_event.zone.id] += active_time + + if not has_flow_sensors: + return summary + + # total inactive water use + for sensor_json in result["controller"]["sensors"]: + sensor = deserialize(SensorWithFlowSummary, sensor_json) + if ( + sensor.flow_summary + and sensor.model.sensor_type == CustomSensorTypeEnum.FLOW + ): + summary.total_use += sensor.flow_summary.total_water_volume.value + if summary.unit is None: + summary.unit = sensor.flow_summary.total_water_volume.unit + + # Correct for inaccuracies. The watering report and flow summaries are not always + # updated with the same frequency. + if cast(float, summary.total_use) > cast(float, summary.total_active_use): + summary.total_inactive_use = cast(float, summary.total_use) - cast( + float, summary.total_active_use + ) + else: + summary.total_use = summary.total_active_use return summary diff --git a/pydrawise/schema.py b/pydrawise/schema.py index eddcaa3..c48d37a 100644 --- a/pydrawise/schema.py +++ b/pydrawise/schema.py @@ -524,6 +524,16 @@ class Sensor: status: SensorStatus = field(default_factory=SensorStatus) +@dataclass +@type_name("Sensor") +class SensorWithFlowSummary(Sensor): + """A Sensor, as returned by its `flowSummary` method.""" + + flow_summary: Optional[SensorFlowSummary] = _optional_field( + default_factory=SensorFlowSummary + ) + + @dataclass class _WaterTime: """A water time duration.""" @@ -672,12 +682,12 @@ class ControllerWaterUseSummary: _pydrawise_type = True - total_use: float = 0.0 - total_active_use: float = 0.0 - total_inactive_use: float = 0.0 total_active_time: timedelta = field( metadata=_duration_conversion("seconds"), default=timedelta() ) - active_use_by_zone_id: dict[int, float] = field(default_factory=dict) active_time_by_zone_id: dict[int, timedelta] = field(default_factory=dict) - unit: str = "" + total_use: Optional[float] = None + total_active_use: Optional[float] = None + total_inactive_use: Optional[float] = None + active_use_by_zone_id: dict[int, float] = field(default_factory=dict) + unit: Optional[str] = None diff --git a/tests/test_client.py b/tests/test_client.py index a4c83dd..8e4c93e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -620,6 +620,6 @@ async def test_get_water_use_summary_without_sensor( assert "watering" in query assert 5955343 not in summary.active_use_by_zone_id assert summary.active_time_by_zone_id[5955343] == timedelta(seconds=1200) - assert summary.total_active_use == 0.0 - assert summary.total_inactive_use == 0.0 + assert summary.total_active_use == None + assert summary.total_inactive_use == None assert summary.total_active_time == timedelta(seconds=1200) From 9cbf161bbb347a219a9a5e765db1c263ca73d108 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 16:39:16 +0000 Subject: [PATCH 4/6] Some mypy related cleanup --- pydrawise/client.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index 1433c71..c66bd63 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -436,10 +436,7 @@ async def get_water_use_summary( # total active water use and time summary = ControllerWaterUseSummary() - if has_flow_sensors: - summary.total_use = 0.0 - summary.total_active_use = 0.0 - summary.total_inactive_use = 0.0 + total_active_use = 0.0 for entry in entries: if entry.run_event is None or entry.run_event.zone is None: continue @@ -448,9 +445,7 @@ async def get_water_use_summary( active_use = entry.run_event.reported_water_usage.value if summary.unit is None: summary.unit = entry.run_event.reported_water_usage.unit - summary.total_active_use = ( - cast(float, summary.total_active_use) + active_use - ) + total_active_use += active_use summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0) summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use @@ -465,23 +460,26 @@ async def get_water_use_summary( return summary # total inactive water use + total_use = 0.0 + total_inactive_use = 0.0 for sensor_json in result["controller"]["sensors"]: sensor = deserialize(SensorWithFlowSummary, sensor_json) if ( sensor.flow_summary and sensor.model.sensor_type == CustomSensorTypeEnum.FLOW ): - summary.total_use += sensor.flow_summary.total_water_volume.value + total_use += sensor.flow_summary.total_water_volume.value if summary.unit is None: summary.unit = sensor.flow_summary.total_water_volume.unit # Correct for inaccuracies. The watering report and flow summaries are not always # updated with the same frequency. - if cast(float, summary.total_use) > cast(float, summary.total_active_use): - summary.total_inactive_use = cast(float, summary.total_use) - cast( - float, summary.total_active_use - ) + if total_use > total_active_use: + total_inactive_use = total_use - total_active_use else: - summary.total_use = summary.total_active_use + total_use = total_active_use + summary.total_use = total_use + summary.total_active_use = total_active_use + summary.total_inactive_use = total_inactive_use return summary From e572de4c39754737732251d2bf1d96b1d1990398 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 16:40:27 +0000 Subject: [PATCH 5/6] Some mypy related cleanup --- pydrawise/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index c66bd63..abea320 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -437,6 +437,8 @@ async def get_water_use_summary( # total active water use and time summary = ControllerWaterUseSummary() total_active_use = 0.0 + total_use = 0.0 + total_inactive_use = 0.0 for entry in entries: if entry.run_event is None or entry.run_event.zone is None: continue @@ -460,8 +462,6 @@ async def get_water_use_summary( return summary # total inactive water use - total_use = 0.0 - total_inactive_use = 0.0 for sensor_json in result["controller"]["sensors"]: sensor = deserialize(SensorWithFlowSummary, sensor_json) if ( From a9987ba8dafdab1e299e63a2b9d2d35f82a62f50 Mon Sep 17 00:00:00 2001 From: Thomas Kistler Date: Fri, 7 Jun 2024 22:37:48 +0000 Subject: [PATCH 6/6] Remove unused import --- pydrawise/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pydrawise/client.py b/pydrawise/client.py index abea320..4faa8cb 100644 --- a/pydrawise/client.py +++ b/pydrawise/client.py @@ -4,7 +4,6 @@ from functools import cache from importlib import resources import logging -from typing import cast from gql import Client from gql.dsl import DSLField, DSLMutation, DSLQuery, DSLSchema, DSLSelectable, dsl_gql