From 8da166395dcaf40a2ff51affdba92e1c6dc6f498 Mon Sep 17 00:00:00 2001 From: Konstantin Deev Date: Wed, 16 Aug 2023 21:37:24 -0500 Subject: [PATCH 1/7] import statistics (#22) * the first attempt to import statistics * statistics integration * clean it up * clean it up * typo * typo * make it work * submit test * Update manifest.json * push coordinator * experiments * experiments * remove comments * clean code * clean code * Correct readme * fix conversion function * push test script * fix * Bugfix for failures on load * load 30 days of historical data * fix a typo * suppress ResponseIsEmpty error * make units in uppercase * optimize import * fix terrible bug * blind fix for metric units * raise exception in case of wrong HTTP status * raise exception in case of wrong HTTP status * Catch exceptions * add more error messages * meter_id or meter_uuid? * Use CM instead of CUBIC_METER --------- Co-authored-by: disforw Co-authored-by: Konstantin Deev --- README.md | 4 +- custom_components/eyeonwater/__init__.py | 30 ++++++- custom_components/eyeonwater/binary_sensor.py | 2 +- custom_components/eyeonwater/const.py | 5 +- custom_components/eyeonwater/coordinator.py | 86 ++++++++++++++++++- custom_components/eyeonwater/eow.py | 85 +++++++++++------- custom_components/eyeonwater/sensor.py | 4 +- custom_components/eyeonwater/test.py | 29 +++++-- 8 files changed, 199 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 530206c..ebba25c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,4 @@ You should be able to choose your water meter in the Water Consumption ![energy-dashboard](https://github.com/kdeyev/eyeonwater/blob/master/img/energy-dashboard.png?raw=true) -Pay attention that EyeOnWater publishes the meter reading once in several hours (even when they accumulate the meter reading once in several minutes). It does not correlate with the HA sensors architecture, which will make your consumption graphs look weird. In the image below, the consumption itself is correct, but the distribution in time is wrong - the graph shows 50 gallons of consumption at 8 AM, but actually, 50 gallons were consumed in the time period 4 AM-8 AM. - -![water-consumption-graph](https://github.com/kdeyev/eyeonwater/blob/master/img/water-consumption-graph.png?raw=true) +Pay attention that EyeOnWater publishes the meter reading once in several hours (even when they accumulate the meter reading once in several minutes). So data may come with a delay of several hours. diff --git a/custom_components/eyeonwater/__init__.py b/custom_components/eyeonwater/__init__.py index 6bc3c6b..f2c64f7 100644 --- a/custom_components/eyeonwater/__init__.py +++ b/custom_components/eyeonwater/__init__.py @@ -18,7 +18,7 @@ SCAN_INTERVAL, ) from .coordinator import EyeOnWaterData -from .eow import Account, Client, EyeOnWaterAPIError, EyeOnWaterAuthError +from .eow import EyeOnWaterAuthError _LOGGER = logging.getLogger(__name__) _LOGGER.addHandler(logging.StreamHandler()) @@ -38,11 +38,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except asyncio.TimeoutError as error: raise ConfigEntryNotReady from error - await eye_on_water_data.setup() + try: + await eye_on_water_data.setup() + except Exception as e: + _LOGGER.error(f"Fetching meters failed: {e}") + raise e + + # Fetch actual meter_info for all meters + try: + await eye_on_water_data.read_meters() + except Exception as e: + _LOGGER.error(f"Reading meters failed: {e}") + raise e + + # load old hostorical data + _LOGGER.info("Start loading historical data") + try: + await eye_on_water_data.import_historical_data(days_to_load=30) + except Exception as e: + _LOGGER.error(f"Loading historical data failed: {e}") + _LOGGER.info("Historical data loaded") + + for meter in eye_on_water_data.meters: + _LOGGER.debug(meter.meter_uuid, meter.meter_id, meter.meter_info) async def async_update_data(): _LOGGER.debug("Fetching latest data") await eye_on_water_data.read_meters() + await eye_on_water_data.import_historical_data() return eye_on_water_data coordinator = DataUpdateCoordinator( @@ -63,7 +86,10 @@ async def async_update_data(): } watch_task = asyncio.create_task(coordinator.async_refresh()) + + _LOGGER.debug("Start setup platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.debug("End setup platforms") return True diff --git a/custom_components/eyeonwater/binary_sensor.py b/custom_components/eyeonwater/binary_sensor.py index b2105a3..3b76694 100644 --- a/custom_components/eyeonwater/binary_sensor.py +++ b/custom_components/eyeonwater/binary_sensor.py @@ -85,7 +85,7 @@ def __init__( self._attr_is_on = self._state self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.meter.meter_uuid)}, - name=f"Water Meter {self.meter.meter_info['meter_id']}", + name=f"Water Meter {self.meter.meter_id}", ) @callback diff --git a/custom_components/eyeonwater/const.py b/custom_components/eyeonwater/const.py index 20073a3..87ba1c2 100644 --- a/custom_components/eyeonwater/const.py +++ b/custom_components/eyeonwater/const.py @@ -1,10 +1,11 @@ """Constants for the EyeOnWater integration.""" from datetime import timedelta -SCAN_INTERVAL = timedelta(minutes=5) -DEBOUNCE_COOLDOWN = 15 * 60 # Seconds +SCAN_INTERVAL = timedelta(minutes=15) +DEBOUNCE_COOLDOWN = 60 * 60 # Seconds DATA_COORDINATOR = "coordinator" DATA_SMART_METER = "smart_meter_data" DOMAIN = "eyeonwater" +WATER_METER_NAME = "Water Meter" diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index 8d9999a..b386f4b 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -1,13 +1,27 @@ """EyeOnWater coordinator.""" import logging +import datetime +from typing import List from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import async_import_statistics + +from .const import WATER_METER_NAME + from .config_flow import create_account_from_config -from .eow import Account, Client, EyeOnWaterAPIError, EyeOnWaterAuthError +from .eow import ( + Account, + Client, + Meter, + EyeOnWaterAPIError, + EyeOnWaterAuthError, + EyeOnWaterResponseIsEmpty, +) _LOGGER = logging.getLogger(__name__) @@ -27,11 +41,13 @@ def __init__( websession = aiohttp_client.async_get_clientsession(hass) self.client = Client(websession, account) self.meters: list = [] + self.hass = hass async def setup(self): """Fetch all of the user's meters.""" self.meters = await self.account.fetch_meters(self.client) _LOGGER.debug("Discovered %s meter(s)", len(self.meters)) + # await self.read_meters() async def read_meters(self): """Read each meter.""" @@ -42,3 +58,71 @@ async def read_meters(self): raise UpdateFailed(error) from error return self.meters + async def import_historical_data(self, days_to_load: int = 2): + """Import historical data for today and past N days.""" + + for meter in self.meters: + statistics = await self.get_historical_data(meter, days_to_load) + + if statistics: + name = f"{WATER_METER_NAME} {meter.meter_id}" + statistic_id = name = f"sensor.water_meter_{meter.meter_id}" + + metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=name, + source="recorder", + statistic_id=statistic_id, + unit_of_measurement=meter.native_unit_of_measurement, + ) + async_import_statistics(self.hass, metadata, statistics) + + async def get_historical_data( + self, meter: Meter, days_to_load: int = 2 + ) -> List[StatisticData]: + """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)] + + if meter.metric_measurement_system: + units = "CM" + else: + units = meter.native_unit_of_measurement.upper() + + _LOGGER.info( + f"adding historical statistics for {meter.meter_uuid} on {date_list} with units {units}" + ) + + statistics = [] + + for date in date_list: + _LOGGER.debug( + f"requesting historical statistics for {meter.meter_uuid} on {date} with units {units}" + ) + try: + data = await meter.get_historical_data( + date=date, units=units, client=self.client + ) + except EyeOnWaterResponseIsEmpty: + # Suppress this exception. It's valid situation when data was not reported by EOW for the requested day + continue + except (EyeOnWaterAPIError, EyeOnWaterAuthError) as error: + raise UpdateFailed(error) from error + + for row in data: + _LOGGER.debug(row) + statistics.append( + StatisticData( + start=row["start"], + sum=row["sum"], + min=row["sum"], + max=row["sum"], + ) + ) + + return statistics diff --git a/custom_components/eyeonwater/eow.py b/custom_components/eyeonwater/eow.py index fdbb0cb..e704698 100644 --- a/custom_components/eyeonwater/eow.py +++ b/custom_components/eyeonwater/eow.py @@ -60,6 +60,10 @@ 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.""" @@ -74,7 +78,10 @@ def __init__( ) -> None: """Initialize the meter.""" self.meter_uuid = meter_uuid - self.meter_info = meter_info + 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" @@ -116,11 +123,17 @@ def reading(self): 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}") + raise EyeOnWaterAPIError( + f"Unsupported measurement unit: {read_unit_upper}" + ) else: if read_unit_upper == MEASUREMENT_KILOGALLONS: amount = amount * 1000 @@ -135,47 +148,52 @@ def reading(self): elif read_unit_upper in MEASUREMENT_CF: amount = amount * 7.48052 else: - raise EyeOnWaterAPIError(f"Unsupported measurement unit: {read_unit}") + raise EyeOnWaterAPIError( + f"Unsupported measurement unit: {read_unit_upper}" + ) return amount - async def get_consumption(self, date, client: Client): + async def get_historical_data(self, date: datetime, units: str, client: Client): + """Retrieve the historical hourly water readings for a requested day""" query = { - "params":{ - "source":"barnacle", - "aggregate":"hourly", - "units":"GAL", - "combine":"true", - "perspective":"billing", - "display_minutes":True, - "display_hours":True, - "display_days":True, - "date": date, - "furthest_zoom":"hr", - "display_weeks":True + "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 - ] - } - } - } + "query": {"query": {"terms": {"meter.meter_uuid": [self.meter_uuid]}}}, } - data = await client.request(path=CONSUMPTION_ENDPOINT, method="post", json=query) + 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 Exception("Response is empty") + raise EyeOnWaterResponseIsEmpty("Response is empty") timezone = data["hit"]["meter.timezone"][0] timezone = pytz.timezone(timezone) # tzinfos = {data["timezone"] : timezone } data = data["timeseries"][key]["series"] - statistics = [{"start": timezone.localize(parser.parse(d["date"])), "sum": d["bill_read"]} for d in data] + statistics = [] + for d in data: + response_unit = d["display_unit"].upper() + statistics.append( + { + "start": timezone.localize(parser.parse(d["date"])), + "sum": self.convert(response_unit, d["bill_read"]), + } + ) for statistic in statistics: start = statistic["start"] @@ -184,6 +202,7 @@ async def get_consumption(self, date, client: Client): if start.minute != 0 or start.second != 0 or start.microsecond != 0: raise Exception("Invalid timestamp") + # statistics.sort(key=lambda d: d["start"]) return statistics @@ -267,7 +286,10 @@ async def request( **kwargs, # ssl=self.ssl_context, ) - if resp.status == 401: + 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() @@ -277,6 +299,11 @@ async def request( 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): diff --git a/custom_components/eyeonwater/sensor.py b/custom_components/eyeonwater/sensor.py index 8a6205b..b88ac05 100644 --- a/custom_components/eyeonwater/sensor.py +++ b/custom_components/eyeonwater/sensor.py @@ -11,7 +11,7 @@ DataUpdateCoordinator, ) -from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN +from .const import DATA_COORDINATOR, DATA_SMART_METER, DOMAIN, WATER_METER_NAME from .eow import Meter @@ -45,7 +45,7 @@ def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: self._attr_native_unit_of_measurement = meter.native_unit_of_measurement self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.meter.meter_uuid)}, - name=f"Water Meter {self.meter.meter_info['meter_id']}", + name=f"{WATER_METER_NAME} {self.meter.meter_id}", ) @property diff --git a/custom_components/eyeonwater/test.py b/custom_components/eyeonwater/test.py index 1e70224..f25d631 100644 --- a/custom_components/eyeonwater/test.py +++ b/custom_components/eyeonwater/test.py @@ -1,25 +1,42 @@ -from http import cookies import aiohttp import asyncio +import datetime from eow import Account, Client, Meter + async def main(): - account = Account(eow_hostname="eyeonwater.com", username="your EOW login", password="your EOW password", metric_measurement_system=False) + account = Account( + eow_hostname="eyeonwater.com", + username="your EOW login", + password="your EOW password", + metric_measurement_system=False, + ) websession = aiohttp.ClientSession() client = Client(websession=websession, account=account) await client.authenticate() - + meters = await account.fetch_meters(client=client) print(f"{len(meters)} meters found") for meter in meters: + today = datetime.datetime.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + # yesterday = today - datetime.timedelta(days=0) + + data = await meter.get_historical_data( + client=client, units="GAL", date=today + ) + for d in data: + print(str(d["start"]), d["sum"]) + await meter.read_meter(client=client) print(f"meter {meter.meter_uuid} shows {meter.reading}") - print(f"meter leaks: {meter.has_leak}") + print(f"meter {meter.meter_uuid} info {meter.meter_info}") await websession.close() - + loop = asyncio.get_event_loop() -loop.run_until_complete(main()) \ No newline at end of file +loop.run_until_complete(main()) From 242a4016f7351b510553a35039aaa2fb01306606 Mon Sep 17 00:00:00 2001 From: disforw Date: Wed, 16 Aug 2023 23:35:23 -0400 Subject: [PATCH 2/7] Update coordinator.py - force statistic_id to lower --- custom_components/eyeonwater/coordinator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index b386f4b..8bad963 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -47,7 +47,6 @@ async def setup(self): """Fetch all of the user's meters.""" self.meters = await self.account.fetch_meters(self.client) _LOGGER.debug("Discovered %s meter(s)", len(self.meters)) - # await self.read_meters() async def read_meters(self): """Read each meter.""" @@ -66,7 +65,7 @@ async def import_historical_data(self, days_to_load: int = 2): if statistics: name = f"{WATER_METER_NAME} {meter.meter_id}" - statistic_id = name = f"sensor.water_meter_{meter.meter_id}" + statistic_id = name = f"sensor.water_meter_{meter.meter_id.lower()}" metadata = StatisticMetaData( has_mean=False, @@ -95,14 +94,14 @@ async def get_historical_data( units = meter.native_unit_of_measurement.upper() _LOGGER.info( - f"adding historical statistics for {meter.meter_uuid} on {date_list} with units {units}" + f"adding historical statistics for {meter.meter_id} on {date_list} with units {units}" ) statistics = [] for date in date_list: _LOGGER.debug( - f"requesting historical statistics for {meter.meter_uuid} on {date} with units {units}" + f"requesting historical statistics for {meter.meter_id} on {date} with units {units}" ) try: data = await meter.get_historical_data( From 657d5c09e9eaf90d31ed9d868e1ca42235e13096 Mon Sep 17 00:00:00 2001 From: Konstantin Deev Date: Thu, 17 Aug 2023 00:05:46 -0500 Subject: [PATCH 3/7] remove redundant fields --- custom_components/eyeonwater/coordinator.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index 8bad963..71ceb07 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -115,13 +115,12 @@ async def get_historical_data( for row in data: _LOGGER.debug(row) - statistics.append( - StatisticData( - start=row["start"], - sum=row["sum"], - min=row["sum"], - max=row["sum"], + if row["sum"] > 0: + statistics.append( + StatisticData( + start=row["start"], + sum=row["sum"], + ) ) - ) return statistics From 1aa3544036a465f801ce6632043b9758b8a7dc4d Mon Sep 17 00:00:00 2001 From: disforw Date: Thu, 17 Aug 2023 20:18:28 -0400 Subject: [PATCH 4/7] Update __init__.py - code cleanup --- custom_components/eyeonwater/__init__.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/custom_components/eyeonwater/__init__.py b/custom_components/eyeonwater/__init__.py index f2c64f7..5cdb0a9 100644 --- a/custom_components/eyeonwater/__init__.py +++ b/custom_components/eyeonwater/__init__.py @@ -40,24 +40,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await eye_on_water_data.setup() - except Exception as e: - _LOGGER.error(f"Fetching meters failed: {e}") - raise e - - # Fetch actual meter_info for all meters - try: await eye_on_water_data.read_meters() except Exception as e: _LOGGER.error(f"Reading meters failed: {e}") raise e - # load old hostorical data - _LOGGER.info("Start loading historical data") try: await eye_on_water_data.import_historical_data(days_to_load=30) except Exception as e: _LOGGER.error(f"Loading historical data failed: {e}") - _LOGGER.info("Historical data loaded") + for meter in eye_on_water_data.meters: _LOGGER.debug(meter.meter_uuid, meter.meter_id, meter.meter_info) @@ -86,10 +78,8 @@ async def async_update_data(): } watch_task = asyncio.create_task(coordinator.async_refresh()) - - _LOGGER.debug("Start setup platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - _LOGGER.debug("End setup platforms") + return True From 2b7fddc8e6e597f53ebb43ae69e11ebd3e45a377 Mon Sep 17 00:00:00 2001 From: disforw Date: Thu, 17 Aug 2023 20:24:39 -0400 Subject: [PATCH 5/7] Update coordinator.py - code cleanup --- custom_components/eyeonwater/coordinator.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index 71ceb07..61da82a 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -12,7 +12,6 @@ from .const import WATER_METER_NAME - from .config_flow import create_account_from_config from .eow import ( Account, @@ -100,9 +99,6 @@ async def get_historical_data( statistics = [] for date in date_list: - _LOGGER.debug( - f"requesting historical statistics for {meter.meter_id} on {date} with units {units}" - ) try: data = await meter.get_historical_data( date=date, units=units, client=self.client From 38feefd54e2ba4636badd15fa78924f6003ef0bd Mon Sep 17 00:00:00 2001 From: Konstantin Deev Date: Sat, 19 Aug 2023 15:02:09 -0500 Subject: [PATCH 6/7] Fix domain selection for other but Canada and US (#32) Co-authored-by: Konstantin Deev --- custom_components/eyeonwater/config_flow.py | 9 +++------ custom_components/eyeonwater/eow.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/custom_components/eyeonwater/config_flow.py b/custom_components/eyeonwater/config_flow.py index 7d84bd9..f9e42c5 100644 --- a/custom_components/eyeonwater/config_flow.py +++ b/custom_components/eyeonwater/config_flow.py @@ -33,14 +33,11 @@ def create_account_from_config( ) -> Account: """Create account login from config.""" CountryCode = hass.config.country - if CountryCode == "US": - eow_hostname = CONF_EOW_HOSTNAME_COM - elif CountryCode == "CA": + if CountryCode == "CA": eow_hostname = CONF_EOW_HOSTNAME_CA else: - raise CannotConnect( - f"Unsupported country ({CountryCode}) setup in HomeAssistant." - ) + # There are some users from Europe that use .com domain + eow_hostname = CONF_EOW_HOSTNAME_COM metric_measurement_system = hass.config.units is METRIC_SYSTEM username = data[CONF_USERNAME] diff --git a/custom_components/eyeonwater/eow.py b/custom_components/eyeonwater/eow.py index e704698..a546370 100644 --- a/custom_components/eyeonwater/eow.py +++ b/custom_components/eyeonwater/eow.py @@ -325,7 +325,7 @@ async def authenticate(self): raise EyeOnWaterAuthError("No meter found") if resp.status == 400: - raise EyeOnWaterAuthError("Username or password was not accepted") + raise EyeOnWaterAuthError(f"Username or password was not accepted by {self.base_url}") if resp.status == 403: raise EyeOnWaterRateLimitError("Reached ratelimit") From 68f75e17784a0a84e09de990d9eb530f003e005e Mon Sep 17 00:00:00 2001 From: Konstantin Deev Date: Sat, 19 Aug 2023 16:24:29 -0500 Subject: [PATCH 7/7] Add pre-commit configuration (#33) * add hassfest * add recorder dependency * fix manifest * add pre-commit * add pre-commit * format the source code * format the source code --------- Co-authored-by: Konstantin Deev --- .github/workflows/hassfest.yaml | 14 ++++ .github/workflows/pre-commit.yml | 15 ++++ .pre-commit-config.yaml | 24 +++++++ README.md | 2 +- custom_components/eyeonwater/__init__.py | 19 ++++-- custom_components/eyeonwater/binary_sensor.py | 1 + custom_components/eyeonwater/config_flow.py | 14 ++-- custom_components/eyeonwater/coordinator.py | 38 ++++++----- custom_components/eyeonwater/eow.py | 68 +++++++++++-------- custom_components/eyeonwater/manifest.json | 7 +- custom_components/eyeonwater/sensor.py | 4 +- custom_components/eyeonwater/test.py | 14 ++-- pyproject.toml | 17 +++++ ruff.toml | 37 ++++++++++ 14 files changed, 205 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/hassfest.yaml create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml create mode 100644 ruff.toml diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..5f7a071 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..7c68dee --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,15 @@ +name: pre-commit + +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c3ad7d8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + args: ["--fix=lf"] + # - repo: https://github.com/astral-sh/ruff-pre-commit + # rev: v0.0.284 + # hooks: + # - id: ruff + # args: ["--fix"] + + - repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort diff --git a/README.md b/README.md index ebba25c..7609961 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Add `https://github.com/kdeyev/eyeonwater` as Repository and select the "Integra 2. Add EyeOnWater integration following [HACS instructions](https://github.com/hacs/integration) -Follow the configuration dialog: +Follow the configuration dialog: - Choose EyeOnWater hostname (choose eyeonwater.com unless you are in Canada). - Choose the measurement system you prefer to use. "Imperial" will create a water sensor counting gallons, "Metric" will create a water sensor counting cubic meters. - Use your username and password, which you use to log in on eyeonwater.com diff --git a/custom_components/eyeonwater/__init__.py b/custom_components/eyeonwater/__init__.py index 5cdb0a9..4d03f52 100644 --- a/custom_components/eyeonwater/__init__.py +++ b/custom_components/eyeonwater/__init__.py @@ -33,23 +33,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await eye_on_water_data.client.authenticate() except EyeOnWaterAuthError: - _LOGGER.error("Username or password was not accepted") + _LOGGER.exception("Username or password was not accepted") return False except asyncio.TimeoutError as error: raise ConfigEntryNotReady from error + # Fetch actual meter_info for all meters try: await eye_on_water_data.setup() await eye_on_water_data.read_meters() except Exception as e: - _LOGGER.error(f"Reading meters failed: {e}") - raise e + message = f"Reading meters failed: {e}" + _LOGGER.exception(message) + raise try: await eye_on_water_data.import_historical_data(days_to_load=30) except Exception as e: - _LOGGER.error(f"Loading historical data failed: {e}") - + message = f"Loading historical data failed: {e}" + _LOGGER.exception(message) for meter in eye_on_water_data.meters: _LOGGER.debug(meter.meter_uuid, meter.meter_id, meter.meter_info) @@ -67,7 +69,10 @@ async def async_update_data(): update_method=async_update_data, update_interval=SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( - hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True + hass, + _LOGGER, + cooldown=DEBOUNCE_COOLDOWN, + immediate=True, ), ) @@ -77,7 +82,7 @@ async def async_update_data(): DATA_SMART_METER: eye_on_water_data, } - watch_task = asyncio.create_task(coordinator.async_refresh()) + asyncio.create_task(coordinator.async_refresh()) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/eyeonwater/binary_sensor.py b/custom_components/eyeonwater/binary_sensor.py index 3b76694..7c67510 100644 --- a/custom_components/eyeonwater/binary_sensor.py +++ b/custom_components/eyeonwater/binary_sensor.py @@ -51,6 +51,7 @@ ), ] + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the EyeOnWater sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] diff --git a/custom_components/eyeonwater/config_flow.py b/custom_components/eyeonwater/config_flow.py index f9e42c5..84a668e 100644 --- a/custom_components/eyeonwater/config_flow.py +++ b/custom_components/eyeonwater/config_flow.py @@ -1,6 +1,5 @@ """Config flow for EyeOnWater integration.""" import asyncio -import contextlib import logging from typing import Any @@ -24,12 +23,13 @@ { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - } + }, ) def create_account_from_config( - hass: core.HomeAssistant, data: dict[str, Any] + hass: core.HomeAssistant, + data: dict[str, Any], ) -> Account: """Create account login from config.""" CountryCode = hass.config.country @@ -43,13 +43,12 @@ def create_account_from_config( username = data[CONF_USERNAME] password = data[CONF_PASSWORD] - account = Account( + return Account( eow_hostname=eow_hostname, username=username, password=password, metric_measurement_system=metric_measurement_system, ) - return account async def validate_input(hass: core.HomeAssistant, data): @@ -79,7 +78,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - errors = {} if user_input is not None: try: @@ -100,7 +98,9 @@ async def async_step_user(self, user_input=None): return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, ) diff --git a/custom_components/eyeonwater/coordinator.py b/custom_components/eyeonwater/coordinator.py index 61da82a..ea5a749 100644 --- a/custom_components/eyeonwater/coordinator.py +++ b/custom_components/eyeonwater/coordinator.py @@ -1,25 +1,22 @@ """EyeOnWater coordinator.""" -import logging import datetime -from typing import List +import logging +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import async_import_statistics from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import UpdateFailed -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData -from homeassistant.components.recorder.statistics import async_import_statistics from .const import WATER_METER_NAME - -from .config_flow import create_account_from_config from .eow import ( Account, Client, - Meter, EyeOnWaterAPIError, EyeOnWaterAuthError, EyeOnWaterResponseIsEmpty, + Meter, ) _LOGGER = logging.getLogger(__name__) @@ -58,7 +55,6 @@ async def read_meters(self): async def import_historical_data(self, days_to_load: int = 2): """Import historical data for today and past N days.""" - for meter in self.meters: statistics = await self.get_historical_data(meter, days_to_load) @@ -77,12 +73,16 @@ async def import_historical_data(self, days_to_load: int = 2): async_import_statistics(self.hass, metadata, statistics) async def get_historical_data( - self, meter: Meter, days_to_load: int = 2 - ) -> List[StatisticData]: + self, + meter: Meter, + days_to_load: int = 2, + ) -> list[StatisticData]: """Retrieve historical data for today and past N days.""" - today = datetime.datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 + hour=0, + minute=0, + second=0, + microsecond=0, ) date_list = [today - datetime.timedelta(days=x) for x in range(0, days_to_load)] @@ -92,16 +92,22 @@ async def get_historical_data( else: units = meter.native_unit_of_measurement.upper() - _LOGGER.info( - f"adding historical statistics for {meter.meter_id} on {date_list} with units {units}" + message = ( + f"adding historical statistics for {meter.meter_id} on {date_list} with units {units}", ) + _LOGGER.info(message) statistics = [] for date in date_list: + _LOGGER.debug( + f"requesting historical statistics for {meter.meter_id} on {date} with units {units}", + ) try: data = await meter.get_historical_data( - date=date, units=units, client=self.client + date=date, + units=units, + client=self.client, ) except EyeOnWaterResponseIsEmpty: # Suppress this exception. It's valid situation when data was not reported by EOW for the requested day @@ -116,7 +122,7 @@ async def get_historical_data( StatisticData( start=row["start"], sum=row["sum"], - ) + ), ) return statistics diff --git a/custom_components/eyeonwater/eow.py b/custom_components/eyeonwater/eow.py index a546370..e13ee7f 100644 --- a/custom_components/eyeonwater/eow.py +++ b/custom_components/eyeonwater/eow.py @@ -2,16 +2,18 @@ from __future__ import annotations import datetime -from dateutil import parser -import pytz import json import logging -from typing import Any +from typing import TYPE_CHECKING, Any import urllib.parse -from aiohttp import ClientSession +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" @@ -97,7 +99,8 @@ async def read_meter(self, client: Client) -> dict[str, Any]: data = json.loads(data) meters = data["elastic_results"]["hits"]["hits"] if len(meters) > 1: - raise Exception("More than one meter reading found") + msg = "More than one meter reading found" + raise Exception(msg) self.meter_info = meters[0]["_source"] self.reading_data = self.meter_info["register_0"] @@ -111,7 +114,8 @@ def get_flags(self, flag) -> bool: """Define flags.""" flags = self.reading_data["flags"] if flag not in flags: - raise EyeOnWaterAPIError(f"Cannot find {flag} field") + msg = f"Cannot find {flag} field" + raise EyeOnWaterAPIError(msg) return flags[flag] @property @@ -119,20 +123,21 @@ def reading(self): """Returns the latest meter reading in gal.""" reading = self.reading_data["latest_read"] if READ_UNITS_FIELD not in reading: - raise EyeOnWaterAPIError("Cannot find read units in reading data") + 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 + return self.convert(read_unit_upper, amount) def convert(self, read_unit_upper, amount): if self.metric_measurement_system: if read_unit_upper in MEASUREMENT_CUBICMETERS: pass else: + msg = f"Unsupported measurement unit: {read_unit_upper}" raise EyeOnWaterAPIError( - f"Unsupported measurement unit: {read_unit_upper}" + msg, ) else: if read_unit_upper == MEASUREMENT_KILOGALLONS: @@ -148,13 +153,14 @@ def convert(self, read_unit_upper, amount): elif read_unit_upper in MEASUREMENT_CF: amount = amount * 7.48052 else: + msg = f"Unsupported measurement unit: {read_unit_upper}" raise EyeOnWaterAPIError( - f"Unsupported measurement unit: {read_unit_upper}" + msg, ) return amount async def get_historical_data(self, date: datetime, units: str, client: Client): - """Retrieve the historical hourly water readings for a requested day""" + """Retrieve the historical hourly water readings for a requested day.""" query = { "params": { "source": "barnacle", @@ -172,17 +178,19 @@ async def get_historical_data(self, date: datetime, units: str, client: Client): "query": {"query": {"terms": {"meter.meter_uuid": [self.meter_uuid]}}}, } data = await client.request( - path=CONSUMPTION_ENDPOINT, method="post", json=query + 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") + msg = "Response is empty" + raise EyeOnWaterResponseIsEmpty(msg) timezone = data["hit"]["meter.timezone"][0] timezone = pytz.timezone(timezone) - # tzinfos = {data["timezone"] : timezone } data = data["timeseries"][key]["series"] statistics = [] @@ -192,17 +200,18 @@ async def get_historical_data(self, date: datetime, units: str, client: Client): { "start": timezone.localize(parser.parse(d["date"])), "sum": self.convert(response_unit, d["bill_read"]), - } + }, ) for statistic in statistics: start = statistic["start"] if start.tzinfo is None or start.tzinfo.utcoffset(start) is None: - raise Exception("Naive timestamp") + msg = "Naive timestamp" + raise Exception(msg) if start.minute != 0 or start.second != 0 or start.microsecond != 0: - raise Exception("Invalid timestamp") + msg = "Invalid timestamp" + raise Exception(msg) - # statistics.sort(key=lambda d: d["start"]) return statistics @@ -234,8 +243,9 @@ async def fetch_meters(self, client: Client): 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( - f"Cannot find {METER_UUID_FIELD} field" + msg, ) meter_uuid = meter_info[METER_UUID_FIELD] @@ -284,11 +294,11 @@ async def request( f"{self.base_url}{path}", cookies=self.cookies, **kwargs, - # ssl=self.ssl_context, ) if resp.status == 403: _LOGGER.error("Reached ratelimit") - raise EyeOnWaterRateLimitError("Reached ratelimit") + msg = "Reached ratelimit" + raise EyeOnWaterRateLimitError(msg) elif resp.status == 401: _LOGGER.debug("Authentication token expired; requesting new token") self.authenticated = False @@ -302,8 +312,9 @@ async def request( if resp.status != 200: _LOGGER.error(f"Request failed: {resp.status} {data}") - raise EyeOnWaterException(f"Request failed: {resp.status} {data}") - + msg = f"Request failed: {resp.status} {data}" + raise EyeOnWaterException(msg) + return data async def authenticate(self): @@ -322,13 +333,16 @@ async def authenticate(self): if "dashboard" not in str(resp.url): _LOGGER.warning("METER NOT FOUND!") - raise EyeOnWaterAuthError("No meter found") + msg = "No meter found" + raise EyeOnWaterAuthError(msg) if resp.status == 400: - raise EyeOnWaterAuthError(f"Username or password was not accepted by {self.base_url}") + msg = f"Username or password was not accepted by {self.base_url}" + raise EyeOnWaterAuthError(msg) if resp.status == 403: - raise EyeOnWaterRateLimitError("Reached ratelimit") + msg = "Reached ratelimit" + raise EyeOnWaterRateLimitError(msg) self.cookies = resp.cookies self._update_token_expiration() diff --git a/custom_components/eyeonwater/manifest.json b/custom_components/eyeonwater/manifest.json index 5f1cfff..b3b4080 100644 --- a/custom_components/eyeonwater/manifest.json +++ b/custom_components/eyeonwater/manifest.json @@ -1,10 +1,11 @@ { "domain": "eyeonwater", "name": "EyeOnWater", + "codeowners": [], "config_flow": true, + "dependencies": ["recorder"], "documentation": "https://github.com/kdeyev/eyeonwater", - "requirements": [], - "codeowners": [], "iot_class": "cloud_polling", - "version": "1.0" + "requirements": [], + "version": "2.0.0" } diff --git a/custom_components/eyeonwater/sensor.py b/custom_components/eyeonwater/sensor.py index b88ac05..5b4d9a8 100644 --- a/custom_components/eyeonwater/sensor.py +++ b/custom_components/eyeonwater/sensor.py @@ -20,9 +20,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] meters = hass.data[DOMAIN][config_entry.entry_id][DATA_SMART_METER].meters - sensors = [] - for meter in meters: - sensors.append(EyeOnWaterSensor(meter, coordinator)) + sensors = [EyeOnWaterSensor(meter, coordinator) for meter in meters] async_add_entities(sensors, False) diff --git a/custom_components/eyeonwater/test.py b/custom_components/eyeonwater/test.py index f25d631..5e9918c 100644 --- a/custom_components/eyeonwater/test.py +++ b/custom_components/eyeonwater/test.py @@ -1,8 +1,8 @@ -import aiohttp import asyncio import datetime -from eow import Account, Client, Meter +import aiohttp +from eow import Account, Client async def main(): @@ -21,12 +21,16 @@ async def main(): print(f"{len(meters)} meters found") for meter in meters: today = datetime.datetime.now().replace( - hour=0, minute=0, second=0, microsecond=0 + hour=0, + minute=0, + second=0, + microsecond=0, ) - # yesterday = today - datetime.timedelta(days=0) data = await meter.get_historical_data( - client=client, units="GAL", date=today + client=client, + units="GAL", + date=today, ) for d in data: print(str(d["start"]), d["sum"]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..852a5f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.black] +target-version = ["py39", "py310"] +exclude = 'generated' + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "homeassistant", + "tests", +] +forced_separate = [ + "tests", +] +combine_as_imports = true diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..c1b6b78 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,37 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = ["ALL"] + +# All the ones without a comment were the ones that are currently violated +# by the codebase. The plan is to fix them all (when sensible) and then enable them. +ignore = [ + "ANN", + "ANN101", # Missing type annotation for {name} in method + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} + "D401", # First line of docstring should be in imperative mood + "E501", # line too long + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "FIX004", # Line contains HACK, consider resolving the issue + "PD901", # df is a bad variable name. Be kinder to your future self. + "PERF203",# `try`-`except` within a loop incurs performance overhead + "PLR0913", # Too many arguments to function call (N > 5) + "PLR2004", # Magic value used in comparison, consider replacing X with a constant variable + "S101", # Use of assert detected + "SLF001", # Private member accessed +] + +[per-file-ignores] +"tests/*.py" = ["ALL"] +".github/*py" = ["INP001"] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25