From b3fdfc03f116d361724e60d4ac1196642872742c Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 29 Mar 2023 02:08:48 -0700 Subject: [PATCH] Improve handling of timezones and daylight savings --- README.md | 2 +- pyproject.toml | 3 ++- src/opower/opower.py | 49 ++++++++++++++++-------------------- src/opower/utilities/base.py | 8 ++++++ src/opower/utilities/pge.py | 5 ++++ 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c4266c6..1775f70 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ pytest # Run demo python src/demo.py --help -# To output debug logs to a file: +# To output debug logs to a file, change DEBUG_LOG_RESPONSE to True in opower.py and run: python src/demo.py --verbose 2> out.txt # Build package diff --git a/pyproject.toml b/pyproject.toml index 11e258a..ae2ee3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opower" -version = "0.0.1" +version = "0.0.2" license = {text = "Apache-2.0"} authors = [ { name="tronikos", email="tronikos@gmail.com" }, @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.7" dependencies = [ "aiohttp>=3.8", + "arrow>=1.2", "asyncio>=3.4.3", ] diff --git a/src/opower/opower.py b/src/opower/opower.py index 8d669fe..4dbe41a 100644 --- a/src/opower/opower.py +++ b/src/opower/opower.py @@ -1,7 +1,7 @@ """Implementation of opower.com JSON API.""" import dataclasses -from datetime import date, datetime, timedelta +from datetime import date, datetime from enum import Enum import json import logging @@ -10,11 +10,13 @@ from urllib.parse import urlencode import aiohttp +import arrow from multidict import CIMultiDict from .utilities import UtilityBase _LOGGER = logging.getLogger(__file__) +DEBUG_LOG_RESPONSE = False class MeterType(Enum): @@ -128,10 +130,6 @@ def _get_form_action_url_and_hidden_inputs(html: str): return action_url, inputs -def _strip_time(date_time: datetime): - return date_time.astimezone().replace(hour=0, minute=0, second=0, microsecond=0) - - class Opower: """Class that can get historical and forecasted usage/cost from an utility.""" @@ -206,7 +204,8 @@ async def async_get_forecast(self) -> list[Forecast]: _LOGGER.debug("Fetching: %s", url) async with self.session.get(url) as resp: result = await resp.json() - _LOGGER.debug("Fetched: %s", json.dumps(result, indent=2)) + if DEBUG_LOG_RESPONSE: + _LOGGER.debug("Fetched: %s", json.dumps(result, indent=2)) forecasts = [] for forecast in result["accountForecasts"]: forecasts.append( @@ -244,7 +243,8 @@ async def _async_get_customer(self): "/customers/current" ) as resp: self.customer = await resp.json() - _LOGGER.debug("Fetched: %s", json.dumps(self.customer, indent=2)) + if DEBUG_LOG_RESPONSE: + _LOGGER.debug("Fetched: %s", json.dumps(self.customer, indent=2)) assert self.customer return self.customer @@ -335,16 +335,12 @@ async def _async_get_dated_data( return await self._async_fetch( url, aggregate_type, start_date, end_date ) - raise ValueError("Missing start_date") - + raise ValueError("start_date is required unless aggregate_type=BILL") if end_date is None: - end_date = datetime.max - end_date = min(datetime.now(), end_date) + raise ValueError("end_date is required unless aggregate_type=BILL") - start_date = _strip_time(start_date) - end_date = _strip_time(end_date) + timedelta(days=1) - assert start_date - assert end_date + start = arrow.get(start_date.date(), self.utility.timezone()) + end = arrow.get(end_date.date(), self.utility.timezone()).shift(days=1) max_request_days = None if aggregate_type == AggregateType.DAY: @@ -353,32 +349,30 @@ async def _async_get_dated_data( max_request_days = 26 # Fetch data in batches in reverse chronological order - # until we reach start_date or there is no fetched data + # until we reach start or there is no fetched data # (non bill data are available up to 3 years ago). result: list[Any] = [] - req_end = end_date + req_end = end while True: - req_start = start_date - if max_request_days: - req_start = max(start_date, req_end - timedelta(days=max_request_days)) + req_start = start + if max_request_days is not None: + req_start = max(start, req_end.shift(days=-max_request_days)) if req_start >= req_end: return result reads = await self._async_fetch(url, aggregate_type, req_start, req_end) if not reads: return result result = reads + result - req_end = req_start - timedelta(days=1) + req_end = req_start.shift(days=-1) async def _async_fetch( self, url: str, aggregate_type: AggregateType, - start_date: datetime | None = None, - end_date: datetime | None = None, + start_date: datetime | arrow.Arrow | None = None, + end_date: datetime | arrow.Arrow | None = None, ) -> list[Any]: - convert_to_date = not ( - aggregate_type == AggregateType.DAY and "/cws/cost/" in url - ) + convert_to_date = "/cws/utilities/" in url params = {"aggregateType": aggregate_type.value} if start_date: params["startDate"] = ( @@ -391,5 +385,6 @@ async def _async_fetch( _LOGGER.debug("Fetching: %s?%s", url, urlencode(params)) async with self.session.get(url, params=params) as resp: result = await resp.json() - _LOGGER.debug("Fetched: %s", json.dumps(result, indent=2)) + if DEBUG_LOG_RESPONSE: + _LOGGER.debug("Fetched: %s", json.dumps(result, indent=2)) return result["reads"] diff --git a/src/opower/utilities/base.py b/src/opower/utilities/base.py index 4250325..06d2923 100644 --- a/src/opower/utilities/base.py +++ b/src/opower/utilities/base.py @@ -24,6 +24,14 @@ def subdomain() -> str: """Return the opower.com subdomain for this utility.""" raise NotImplementedError + @staticmethod + def timezone() -> str: + """Return the timezone. + + Should match the siteTimeZoneId of the API responses. + """ + raise NotImplementedError + @staticmethod async def login( session: aiohttp.ClientSession, username: str, password: str diff --git a/src/opower/utilities/pge.py b/src/opower/utilities/pge.py index 7d215dd..a4e7f4c 100644 --- a/src/opower/utilities/pge.py +++ b/src/opower/utilities/pge.py @@ -19,6 +19,11 @@ def subdomain() -> str: """Return the opower.com subdomain for this utility.""" return "pge" + @staticmethod + def timezone() -> str: + """Return the timezone.""" + return "America/Los_Angeles" + @staticmethod async def login( session: aiohttp.ClientSession, username: str, password: str