From 7d44b74444bd60a7f51aa2b71d9f265fcaa00d26 Mon Sep 17 00:00:00 2001 From: Konstantin Deev Date: Tue, 22 Aug 2023 22:19:13 -0500 Subject: [PATCH] Use external pyonwater library (#45) * use external pyonwater library * pun pre-commit --------- Co-authored-by: Konstantin Deev --- custom_components/eyeonwater/__init__.py | 3 +- custom_components/eyeonwater/binary_sensor.py | 3 +- custom_components/eyeonwater/config_flow.py | 2 +- custom_components/eyeonwater/coordinator.py | 17 +- custom_components/eyeonwater/eow.py | 410 ------------------ custom_components/eyeonwater/manifest.json | 4 +- custom_components/eyeonwater/sensor.py | 2 +- custom_components/eyeonwater/test.py | 3 +- 8 files changed, 18 insertions(+), 426 deletions(-) delete mode 100644 custom_components/eyeonwater/eow.py diff --git a/custom_components/eyeonwater/__init__.py b/custom_components/eyeonwater/__init__.py index caa6f16..5099b80 100644 --- a/custom_components/eyeonwater/__init__.py +++ b/custom_components/eyeonwater/__init__.py @@ -2,6 +2,8 @@ import asyncio import logging +from pyonwater import EyeOnWaterAuthError + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -18,7 +20,6 @@ SCAN_INTERVAL, ) from .coordinator import EyeOnWaterData -from .eow import EyeOnWaterAuthError _LOGGER = logging.getLogger(__name__) _LOGGER.addHandler(logging.StreamHandler()) diff --git a/custom_components/eyeonwater/binary_sensor.py b/custom_components/eyeonwater/binary_sensor.py index 7c67510..7f365fe 100644 --- a/custom_components/eyeonwater/binary_sensor.py +++ b/custom_components/eyeonwater/binary_sensor.py @@ -1,4 +1,6 @@ """Support for EyeOnWater binary sensors.""" +from pyonwater import Meter + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -13,7 +15,6 @@ ) from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN -from .eow import Meter FLAG_SENSORS = [ BinarySensorEntityDescription( diff --git a/custom_components/eyeonwater/config_flow.py b/custom_components/eyeonwater/config_flow.py index 49f93d4..7b881ac 100644 --- a/custom_components/eyeonwater/config_flow.py +++ b/custom_components/eyeonwater/config_flow.py @@ -4,6 +4,7 @@ from typing import Any from aiohttp import ClientError +from pyonwater import Account, Client, EyeOnWaterAPIError, EyeOnWaterAuthError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -12,7 +13,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN -from .eow import Account, Client, EyeOnWaterAPIError, EyeOnWaterAuthError CONF_EOW_HOSTNAME_COM = "eyeonwater.com" CONF_EOW_HOSTNAME_CA = "eyeonwater.ca" diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index 744c731..d02ce57 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -3,6 +3,15 @@ import logging from typing import List +from pyonwater import ( + Account, + Client, + EyeOnWaterAPIError, + EyeOnWaterAuthError, + EyeOnWaterResponseIsEmpty, + Meter, +) + from homeassistant.components.recorder.models import StatisticData, StatisticMetaData from homeassistant.components.recorder.statistics import async_import_statistics from homeassistant.config_entries import ConfigEntry @@ -12,14 +21,6 @@ from .config_flow import create_account_from_config from .const import WATER_METER_NAME -from .eow import ( - Account, - Client, - EyeOnWaterAPIError, - EyeOnWaterAuthError, - EyeOnWaterResponseIsEmpty, - Meter, -) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/eyeonwater/eow.py b/custom_components/eyeonwater/eow.py deleted file mode 100644 index e340b04..0000000 --- a/custom_components/eyeonwater/eow.py +++ /dev/null @@ -1,410 +0,0 @@ -"""EyeOnWater API integration.""" -from __future__ import annotations - -import datetime -import json -import logging -from typing import TYPE_CHECKING, Any -import urllib.parse - -from dateutil import parser -import pytz -from tenacity import retry, retry_if_exception_type - -if TYPE_CHECKING: - from aiohttp import ClientSession - -AUTH_ENDPOINT = "account/signin" -DASHBOARD_ENDPOINT = "/dashboard/" -SEARCH_ENDPOINT = "/api/2/residential/new_search" -CONSUMPTION_ENDPOINT = "/api/2/residential/consumption?eow=True" - -MEASUREMENT_GALLONS = "GAL" -MEASUREMENT_100_GALLONS = "100 GAL" -MEASUREMENT_10_GALLONS = "10 GAL" -MEASUREMENT_CF = ["CF", "CUBIC_FEET"] -MEASUREMENT_CCF = "CCF" -MEASUREMENT_KILOGALLONS = "KGAL" -MEASUREMENT_CUBICMETERS = ["CM", "CUBIC_METER"] - -METER_UUID_FIELD = "meter_uuid" -READ_UNITS_FIELD = "units" -READ_AMOUNT_FIELD = "full_read" - -TOKEN_EXPIRATION = datetime.timedelta(minutes=15) - -_LOGGER = logging.getLogger(__name__) - - -class EyeOnWaterException(Exception): - """Base exception for more specific exceptions to inherit from.""" - - -class EyeOnWaterAuthError(EyeOnWaterException): - """Exception for authentication failures. - - Either wrong username or wrong password. - """ - - -class EyeOnWaterRateLimitError(EyeOnWaterException): - """Exception for reaching the ratelimit. - - Either too many login attempts or too many requests. - """ - - -class EyeOnWaterAuthExpired(EyeOnWaterException): - """Exception for when a token is no longer valid.""" - - -class EyeOnWaterAPIError(EyeOnWaterException): - """General exception for unknown API responses.""" - - -class EyeOnWaterResponseIsEmpty(EyeOnWaterException): - """API answered correct but there is not content to parse.""" - - -class Meter: - """Class represents meter object.""" - - meter_prefix = "var new_barInfo = " - info_prefix = "AQ.Views.MeterPicker.meters = " - - def __init__( - self, - meter_uuid: str, - meter_info: dict[str, Any], - metric_measurement_system: bool, - ) -> None: - """Initialize the meter.""" - self.meter_uuid = meter_uuid - self.meter_id = meter_info["meter_id"] - - self.meter_info = None - - self.metric_measurement_system = metric_measurement_system - self.native_unit_of_measurement = ( - "m\u00b3" if self.metric_measurement_system else "gal" - ) - self.reading_data = None - - self.last_historical_data = [] - - async def read_meter(self, client: Client, days_to_load=3) -> dict[str, Any]: - """Triggers an on-demand meter read and returns it when complete.""" - _LOGGER.debug("Requesting meter reading") - - query = {"query": {"terms": {"meter.meter_uuid": [self.meter_uuid]}}} - data = await client.request(path=SEARCH_ENDPOINT, method="post", json=query) - data = json.loads(data) - meters = data["elastic_results"]["hits"]["hits"] - if len(meters) > 1: - msg = "More than one meter reading found" - raise Exception(msg) - - self.meter_info = meters[0]["_source"] - self.reading_data = self.meter_info["register_0"] - - try: - historical_data = await self.get_historical_datas( - days_to_load=days_to_load, client=client - ) - if not self.last_historical_data: - self.last_historical_data = historical_data - elif ( - historical_data - and historical_data[-1]["reading"] - > self.last_historical_data[-1]["reading"] - ): - # Take newer data - self.last_historical_data = historical_data - elif historical_data[-1]["reading"] == self.last_historical_data[-1][ - "reading" - ] and len(historical_data) > len(self.last_historical_data): - # If it the same date - take more data - self.last_historical_data = historical_data - - except EyeOnWaterResponseIsEmpty: - self.last_historical_data = [] - - @property - def attributes(self): - """Define attributes.""" - return self.meter_info - - def get_flags(self, flag) -> bool: - """Define flags.""" - flags = self.reading_data["flags"] - if flag not in flags: - msg = f"Cannot find {flag} field" - raise EyeOnWaterAPIError(msg) - return flags[flag] - - @property - def reading(self): - """Returns the latest meter reading in gal.""" - reading = self.reading_data["latest_read"] - if READ_UNITS_FIELD not in reading: - msg = "Cannot find read units in reading data" - raise EyeOnWaterAPIError(msg) - read_unit = reading[READ_UNITS_FIELD] - read_unit_upper = read_unit.upper() - amount = float(reading[READ_AMOUNT_FIELD]) - amount = self.convert(read_unit_upper, amount) - return amount - - def convert(self, read_unit_upper, amount): - if self.metric_measurement_system: - if read_unit_upper in MEASUREMENT_CUBICMETERS: - pass - else: - raise EyeOnWaterAPIError( - f"Unsupported measurement unit: {read_unit_upper}" - ) - else: - if read_unit_upper == MEASUREMENT_KILOGALLONS: - amount = amount * 1000 - elif read_unit_upper == MEASUREMENT_100_GALLONS: - amount = amount * 100 - elif read_unit_upper == MEASUREMENT_10_GALLONS: - amount = amount * 10 - elif read_unit_upper == MEASUREMENT_GALLONS: - pass - elif read_unit_upper == MEASUREMENT_CCF: - amount = amount * 748.052 - elif read_unit_upper in MEASUREMENT_CF: - amount = amount * 7.48052 - else: - raise EyeOnWaterAPIError( - f"Unsupported measurement unit: {read_unit_upper}" - ) - return amount - - async def get_historical_datas(self, days_to_load: int, client: Client): - """Retrieve historical data for today and past N days.""" - - today = datetime.datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 - ) - - date_list = [today - datetime.timedelta(days=x) for x in range(0, days_to_load)] - date_list.reverse() - - # TODO: identify missing days and request only missing dates. - - _LOGGER.info( - f"requesting historical statistics for {self.meter_uuid} on {date_list}" - ) - - statistics = [] - - for date in date_list: - _LOGGER.info( - f"requesting historical statistics for {self.meter_uuid} on {date}" - ) - try: - statistics += await self.get_historical_data(date=date, client=client) - except EyeOnWaterResponseIsEmpty: - continue - - return statistics - - async def get_historical_data(self, date: datetime, client: Client): - """Retrieve the historical hourly water readings for a requested day""" - - if self.metric_measurement_system: - units = "CM" - else: - units = self.native_unit_of_measurement.upper() - - query = { - "params": { - "source": "barnacle", - "aggregate": "hourly", - "units": units, - "combine": "true", - "perspective": "billing", - "display_minutes": True, - "display_hours": True, - "display_days": True, - "date": date.strftime("%m/%d/%Y"), - "furthest_zoom": "hr", - "display_weeks": True, - }, - "query": {"query": {"terms": {"meter.meter_uuid": [self.meter_uuid]}}}, - } - data = await client.request( - path=CONSUMPTION_ENDPOINT, method="post", json=query - ) - data = json.loads(data) - - key = f"{self.meter_uuid},0" - if key not in data["timeseries"]: - raise EyeOnWaterResponseIsEmpty("Response is empty") - - timezone = data["hit"]["meter.timezone"][0] - timezone = pytz.timezone(timezone) - - data = data["timeseries"][key]["series"] - statistics = [] - for d in data: - response_unit = d["display_unit"].upper() - statistics.append( - { - "dt": timezone.localize(parser.parse(d["date"])), - "reading": self.convert(response_unit, d["bill_read"]), - } - ) - - statistics.sort(key=lambda d: d["dt"]) - - return statistics - - -class Account: - """Class represents account object.""" - - def __init__( - self, - eow_hostname: str, - username: str, - password: str, - metric_measurement_system: bool, - ) -> None: - """Initialize the account.""" - self.eow_hostname = eow_hostname - self.username = username - self.password = password - self.metric_measurement_system = metric_measurement_system - - async def fetch_meters(self, client: Client): - """List the meters associated with the account.""" - path = DASHBOARD_ENDPOINT + urllib.parse.quote(self.username) - data = await client.request(path=path, method="get") - - meters = [] - lines = data.split("\n") - for line in lines: - if Meter.info_prefix in line: - meter_infos = client.extract_json(line, Meter.info_prefix) - for meter_info in meter_infos: - if METER_UUID_FIELD not in meter_info: - msg = f"Cannot find {METER_UUID_FIELD} field" - raise EyeOnWaterAPIError( - msg, - ) - - meter_uuid = meter_info[METER_UUID_FIELD] - - meter = Meter( - meter_uuid=meter_uuid, - meter_info=meter_info, - metric_measurement_system=self.metric_measurement_system, - ) - meters.append(meter) - - return meters - - -class Client: - """Class represents client object.""" - - def __init__( - self, - websession: ClientSession, - account: Account, - ) -> None: - """Initialize the client.""" - self.base_url = "https://" + account.eow_hostname + "/" - self.websession = websession - self.account = account - self.cookies = None - self.authenticated = False - self.token_expiration = datetime.datetime.now() - self.user_agent = None - - def _update_token_expiration(self): - self.token_expiration = datetime.datetime.now() + TOKEN_EXPIRATION - - @retry(retry=retry_if_exception_type(EyeOnWaterAuthExpired)) - async def request( - self, - path: str, - method: str, - **kwargs, - ): - """Make API calls against the eow API.""" - await self.authenticate() - resp = await self.websession.request( - method, - f"{self.base_url}{path}", - cookies=self.cookies, - **kwargs, - ) - if resp.status == 403: - _LOGGER.error("Reached ratelimit") - raise EyeOnWaterRateLimitError("Reached ratelimit") - elif resp.status == 401: - _LOGGER.debug("Authentication token expired; requesting new token") - self.authenticated = False - await self.authenticate() - raise EyeOnWaterAuthExpired - - # Since API call did not return a 400 code, update the token_expiration. - self._update_token_expiration() - - data = await resp.text() - - if resp.status != 200: - _LOGGER.error(f"Request failed: {resp.status} {data}") - raise EyeOnWaterException(f"Request failed: {resp.status} {data}") - - return data - - async def authenticate(self): - """Authenticate the client.""" - if not self.token_valid: - _LOGGER.debug("Requesting login token") - - resp = await self.websession.request( - "POST", - f"{self.base_url}{AUTH_ENDPOINT}", - data={ - "username": self.account.username, - "password": self.account.password, - }, - ) - - if "dashboard" not in str(resp.url): - _LOGGER.warning("METER NOT FOUND!") - msg = "No meter found" - raise EyeOnWaterAuthError(msg) - - if resp.status == 400: - msg = f"Username or password was not accepted by {self.base_url}" - raise EyeOnWaterAuthError(msg) - - if resp.status == 403: - msg = "Reached ratelimit" - raise EyeOnWaterRateLimitError(msg) - - self.cookies = resp.cookies - self._update_token_expiration() - self.authenticated = True - _LOGGER.debug("Successfully retrieved login token") - - def extract_json(self, line, prefix): - """Extract JSON response.""" - line = line[line.find(prefix) + len(prefix) :] - line = line[: line.find(";")] - return json.loads(line) - - @property - def token_valid(self): - """Validate the token.""" - if self.authenticated or (datetime.datetime.now() < self.token_expiration): - return True - - return False diff --git a/custom_components/eyeonwater/manifest.json b/custom_components/eyeonwater/manifest.json index b3b4080..835d2af 100644 --- a/custom_components/eyeonwater/manifest.json +++ b/custom_components/eyeonwater/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["recorder"], "documentation": "https://github.com/kdeyev/eyeonwater", "iot_class": "cloud_polling", - "requirements": [], - "version": "2.0.0" + "requirements": ["pyonwater==0.1.2"], + "version": "2.0.2" } diff --git a/custom_components/eyeonwater/sensor.py b/custom_components/eyeonwater/sensor.py index d2e3da1..5503fc8 100644 --- a/custom_components/eyeonwater/sensor.py +++ b/custom_components/eyeonwater/sensor.py @@ -2,6 +2,7 @@ import datetime import logging +from pyonwater import Meter import pytz from homeassistant.components.recorder import get_instance @@ -24,7 +25,6 @@ from homeassistant.util import dt as dtutil from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN, WATER_METER_NAME -from .eow import Meter _LOGGER = logging.getLogger(__name__) _LOGGER.addHandler(logging.StreamHandler()) diff --git a/custom_components/eyeonwater/test.py b/custom_components/eyeonwater/test.py index 0fe6c0c..68aeadc 100644 --- a/custom_components/eyeonwater/test.py +++ b/custom_components/eyeonwater/test.py @@ -1,8 +1,7 @@ import asyncio import aiohttp - -from .eow import Account, Client +from pyonwater import Account, Client async def main():