From 3130506860dd9c893d2d3896889dbe602ef7fa04 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sat, 23 Jul 2022 06:31:59 -0400 Subject: [PATCH 01/11] import some constants from home assistant constants --- custom_components/fpl/__init__.py | 3 +-- custom_components/fpl/config_flow.py | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/fpl/__init__.py b/custom_components/fpl/__init__.py index 5fe15f4..a741e9d 100644 --- a/custom_components/fpl/__init__.py +++ b/custom_components/fpl/__init__.py @@ -14,11 +14,10 @@ from .fplapi import FplApi from .const import ( DOMAIN, DOMAIN_DATA, - CONF_USERNAME, - CONF_PASSWORD, PLATFORMS, STARTUP_MESSAGE, ) +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from .fplDataUpdateCoordinator import FplDataUpdateCoordinator MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index 707ee6d..1424502 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -7,7 +7,8 @@ from homeassistant import config_entries from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.core import callback -from .const import DOMAIN, CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from .const import DOMAIN from .fplapi import ( LOGIN_RESULT_OK, @@ -38,7 +39,7 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._errors = {} async def async_step_user( - self, user_input={} + self, user_input=None ): # pylint: disable=dangerous-default-value """Handle a flow initialized by the user.""" self._errors = {} From a784fd314a450018fc7931cd4a3d6e7e5b3d0508 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sat, 23 Jul 2022 06:33:19 -0400 Subject: [PATCH 02/11] make use of _attr_ --- custom_components/fpl/fplEntity.py | 74 ++++--------------- .../fpl/sensor_DailyUsageSensor.py | 2 +- .../fpl/sensor_ProjectedBillSensor.py | 47 +++--------- 3 files changed, 29 insertions(+), 94 deletions(-) diff --git a/custom_components/fpl/fplEntity.py b/custom_components/fpl/fplEntity.py index 91b0553..ecb6728 100644 --- a/custom_components/fpl/fplEntity.py +++ b/custom_components/fpl/fplEntity.py @@ -17,6 +17,8 @@ from .const import DOMAIN, VERSION, ATTRIBUTION class FplEntity(CoordinatorEntity, SensorEntity): """FPL base entity""" + _attr_attribution = ATTRIBUTION + def __init__(self, coordinator, config_entry, account, sensorName): super().__init__(coordinator) self.config_entry = config_entry @@ -26,9 +28,8 @@ class FplEntity(CoordinatorEntity, SensorEntity): @property def unique_id(self): """Return the ID of this device.""" - return "{}{}{}".format( - DOMAIN, self.account, self.sensorName.lower().replace(" ", "") - ) + sensorName = self.sensorName.lower().replace(" ", "") + return f"{DOMAIN}{self.account}{sensorName}" @property def name(self): @@ -51,8 +52,8 @@ class FplEntity(CoordinatorEntity, SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" attributes = { - "attribution": ATTRIBUTION, - "integration": "FPL", + # "attribution": ATTRIBUTION, + # "integration": "FPL", } attributes.update(self.customAttributes()) return attributes @@ -65,11 +66,10 @@ class FplEntity(CoordinatorEntity, SensorEntity): class FplEnergyEntity(FplEntity): """Represents a energy sensor""" - @property - def state_class(self) -> str: - """Return the state class of this entity, from STATE_CLASSES, if any.""" - - return STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_icon = "mdi:flash" + _attr_state_class = STATE_CLASS_MEASUREMENT @property def last_reset(self) -> datetime: @@ -79,65 +79,23 @@ class FplEnergyEntity(FplEntity): yesterday = today - timedelta(days=1) return datetime.combine(yesterday, datetime.min.time()) - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_ENERGY - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return ENERGY_KILO_WATT_HOUR - - @property - def icon(self): - return "mdi:flash" - class FplMoneyEntity(FplEntity): """Represents a money sensor""" - @property - def icon(self): - return "mdi:currency-usd" - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_MONETARY - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return CURRENCY_DOLLAR + _attr_native_unit_of_measurement = CURRENCY_DOLLAR + _attr_device_class = DEVICE_CLASS_MONETARY + _attr_icon = "mdi:currency-usd" class FplDateEntity(FplEntity): """Represents a date or days""" - # @property - # def device_class(self) -> str: - # """Return the class of this device, from component DEVICE_CLASSES.""" - # return DEVICE_CLASS_DATE - - @property - def icon(self): - return "mdi:calendar" + _attr_icon = "mdi:calendar" class FplDayEntity(FplEntity): """Represents a date or days""" - # @property - # def device_class(self) -> str: - # """Return the class of this device, from component DEVICE_CLASSES.""" - # return DEVICE_CLASS_DATE - - @property - def icon(self): - return "mdi:calendar" - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return "days" + _attr_native_unit_of_measurement = "days" + _attr_icon = "mdi:calendar" diff --git a/custom_components/fpl/sensor_DailyUsageSensor.py b/custom_components/fpl/sensor_DailyUsageSensor.py index e3888f7..de4a486 100644 --- a/custom_components/fpl/sensor_DailyUsageSensor.py +++ b/custom_components/fpl/sensor_DailyUsageSensor.py @@ -1,6 +1,6 @@ """Daily Usage Sensors""" -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING from datetime import timedelta +from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING from .fplEntity import FplEnergyEntity, FplMoneyEntity diff --git a/custom_components/fpl/sensor_ProjectedBillSensor.py b/custom_components/fpl/sensor_ProjectedBillSensor.py index 264217a..e1d5762 100644 --- a/custom_components/fpl/sensor_ProjectedBillSensor.py +++ b/custom_components/fpl/sensor_ProjectedBillSensor.py @@ -7,7 +7,9 @@ from .fplEntity import FplMoneyEntity class FplProjectedBillSensor(FplMoneyEntity): - """projected bill sensor""" + """Projected bill sensor""" + + _attr_state_class = STATE_CLASS_TOTAL def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Bill") @@ -22,22 +24,9 @@ class FplProjectedBillSensor(FplMoneyEntity): return self.getData("projected_bill") - """ - @property - def state(self): - budget = self.getData("budget_bill") - budget_billing_projected_bill = self.getData("budget_billing_projected_bill") - - if budget and budget_billing_projected_bill is not None: - return self.getData("budget_billing_projected_bill") - - return self.getData("projected_bill") - """ - def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL attributes["budget_bill"] = self.getData("budget_bill") return attributes @@ -46,51 +35,39 @@ class FplProjectedBillSensor(FplMoneyEntity): class DeferedAmountSensor(FplMoneyEntity): """Defered amount sensor""" + _attr_state_class = STATE_CLASS_TOTAL + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Defered Amount") @property - def state(self): + def native_value(self): if self.getData("budget_bill"): return self.getData("defered_amount") return 0 - def customAttributes(self): - """Return the state attributes.""" - attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL - return attributes - class ProjectedBudgetBillSensor(FplMoneyEntity): """projected budget bill sensor""" + _attr_state_class = STATE_CLASS_TOTAL + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Budget Bill") @property - def state(self): + def native_value(self): return self.getData("budget_billing_projected_bill") - def customAttributes(self): - """Return the state attributes.""" - attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL - return attributes - class ProjectedActualBillSensor(FplMoneyEntity): """projeted actual bill sensor""" + _attr_state_class = STATE_CLASS_TOTAL + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Actual Bill") @property - def state(self): + def native_value(self): return self.getData("projected_bill") - - def customAttributes(self): - """Return the state attributes.""" - attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL - return attributes From 4caa8ce8ac37c1c3126919b1f692ddc0f546c94e Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sun, 24 Jul 2022 02:45:05 -0400 Subject: [PATCH 03/11] include const for username and password for use in dev --- custom_components/fpl/config_flow.py | 11 ++++++++--- custom_components/fpl/const.py | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index 1424502..90ae650 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -8,7 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.core import callback from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME -from .const import DOMAIN +from .const import DEFAULT_CONF_PASSWORD, DEFAULT_CONF_USERNAME, DOMAIN from .fplapi import ( LOGIN_RESULT_OK, @@ -18,6 +18,11 @@ from .fplapi import ( FplApi, ) +try: + from .secrets import DEFAULT_CONF_PASSWORD, DEFAULT_CONF_USERNAME +except: + pass + @callback def configured_instances(hass): @@ -86,8 +91,8 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _show_config_form(self, user_input): """Show the configuration form to edit location data.""" - username = "" - password = "" + username = DEFAULT_CONF_USERNAME + password = DEFAULT_CONF_PASSWORD if user_input is not None: if CONF_USERNAME in user_input: diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index cb9ee79..2023605 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -47,3 +47,6 @@ If you have any issues with this you need to open an issue here: {ISSUE_URL} ------------------------------------------------------------------- """ + +DEFAULT_CONF_USERNAME = "" +DEFAULT_CONF_PASSWORD = "" From 3dcf55d3847bd31f46bc5f6cc050ae92e79c41aa Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sun, 24 Jul 2022 03:02:19 -0400 Subject: [PATCH 04/11] ignore secrets file --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9fc1dff..d2535c1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ custom_components/fpl/__pycache__/ test.py custom_components/fpl1/__pycache__/ + +custom_components/fpl/secrets.py From 69ac6527f6cdea128e0830df9e76ba3ee027f645 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sun, 24 Jul 2022 03:04:30 -0400 Subject: [PATCH 05/11] minimal validation before return data from coordinator --- custom_components/fpl/fplEntity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/fpl/fplEntity.py b/custom_components/fpl/fplEntity.py index ecb6728..22f585f 100644 --- a/custom_components/fpl/fplEntity.py +++ b/custom_components/fpl/fplEntity.py @@ -60,7 +60,11 @@ class FplEntity(CoordinatorEntity, SensorEntity): def getData(self, field): """call this method to retrieve sensor data""" - return self.coordinator.data.get(self.account).get(field, None) + if self.coordinator.data is not None: + account = self.coordinator.data.get(self.account) + if account is not None: + return account.get(field, None) + return None class FplEnergyEntity(FplEntity): From a1dbe51f10352430cad027c0b5e5078ae6c5a767 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Sun, 24 Jul 2022 03:21:57 -0400 Subject: [PATCH 06/11] include a test sensor --- custom_components/fpl/const.py | 13 ++---- custom_components/fpl/sensor.py | 66 ++++++++++++++++------------ custom_components/fpl/sensor_test.py | 43 ++++++++++++++++++ 3 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 custom_components/fpl/sensor_test.py diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index 2023605..7aeab49 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -1,4 +1,8 @@ """Constants for fpl.""" +# +DEBUG = True + + # Base component constants NAME = "FPL Integration" DOMAIN = "fpl" @@ -25,15 +29,6 @@ PLATFORMS = [SENSOR] # Device classes BINARY_SENSOR_DEVICE_CLASS = "connectivity" -# Configuration -CONF_BINARY_SENSOR = "binary_sensor" -CONF_SENSOR = "sensor" -CONF_SWITCH = "switch" -CONF_ENABLED = "enabled" -CONF_NAME = "name" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" - # Defaults DEFAULT_NAME = DOMAIN diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 45bb018..5aab8be 100644 --- a/custom_components/fpl/sensor.py +++ b/custom_components/fpl/sensor.py @@ -31,52 +31,60 @@ from .sensor_DailyUsageSensor import ( FplDailyDeliveredKWHSensor, FplDailyReceivedKWHSensor, ) -from .const import DOMAIN + +from .sensor_test import TestSensor + +from .const import DOMAIN, DEBUG # from .TestSensor import TestSensor async def async_setup_entry(hass, entry, async_add_devices): """Setup sensor platform.""" + accounts = entry.data.get("accounts") coordinator = hass.data[DOMAIN][entry.entry_id] fpl_accounts = [] - for account in accounts: - # Test Sensor - # fpl_accounts.append(TestSensor(coordinator, entry, account)) + if DEBUG: + for account in accounts: + fpl_accounts.append(TestSensor(coordinator, entry, account)) + else: + for account in accounts: + # Test Sensor + # fpl_accounts.append(TestSensor(coordinator, entry, account)) - # bill sensors - fpl_accounts.append(FplProjectedBillSensor(coordinator, entry, account)) - fpl_accounts.append(ProjectedBudgetBillSensor(coordinator, entry, account)) - fpl_accounts.append(ProjectedActualBillSensor(coordinator, entry, account)) - fpl_accounts.append(DeferedAmountSensor(coordinator, entry, account)) + # bill sensors + fpl_accounts.append(FplProjectedBillSensor(coordinator, entry, account)) + fpl_accounts.append(ProjectedBudgetBillSensor(coordinator, entry, account)) + fpl_accounts.append(ProjectedActualBillSensor(coordinator, entry, account)) + fpl_accounts.append(DeferedAmountSensor(coordinator, entry, account)) - # usage sensors - fpl_accounts.append(DailyAverageSensor(coordinator, entry, account)) - fpl_accounts.append(BudgetDailyAverageSensor(coordinator, entry, account)) - fpl_accounts.append(ActualDailyAverageSensor(coordinator, entry, account)) + # usage sensors + fpl_accounts.append(DailyAverageSensor(coordinator, entry, account)) + fpl_accounts.append(BudgetDailyAverageSensor(coordinator, entry, account)) + fpl_accounts.append(ActualDailyAverageSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyUsageSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyUsageKWHSensor(coordinator, entry, account)) + fpl_accounts.append(FplDailyUsageSensor(coordinator, entry, account)) + fpl_accounts.append(FplDailyUsageKWHSensor(coordinator, entry, account)) - # date sensors - fpl_accounts.append(CurrentBillDateSensor(coordinator, entry, account)) - fpl_accounts.append(NextBillDateSensor(coordinator, entry, account)) - fpl_accounts.append(ServiceDaysSensor(coordinator, entry, account)) - fpl_accounts.append(AsOfDaysSensor(coordinator, entry, account)) - fpl_accounts.append(RemainingDaysSensor(coordinator, entry, account)) + # date sensors + fpl_accounts.append(CurrentBillDateSensor(coordinator, entry, account)) + fpl_accounts.append(NextBillDateSensor(coordinator, entry, account)) + fpl_accounts.append(ServiceDaysSensor(coordinator, entry, account)) + fpl_accounts.append(AsOfDaysSensor(coordinator, entry, account)) + fpl_accounts.append(RemainingDaysSensor(coordinator, entry, account)) - # KWH sensors - fpl_accounts.append(ProjectedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(DailyAverageKWHSensor(coordinator, entry, account)) - fpl_accounts.append(BillToDateKWHSensor(coordinator, entry, account)) + # KWH sensors + fpl_accounts.append(ProjectedKWHSensor(coordinator, entry, account)) + fpl_accounts.append(DailyAverageKWHSensor(coordinator, entry, account)) + fpl_accounts.append(BillToDateKWHSensor(coordinator, entry, account)) - fpl_accounts.append(NetReceivedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(NetDeliveredKWHSensor(coordinator, entry, account)) + fpl_accounts.append(NetReceivedKWHSensor(coordinator, entry, account)) + fpl_accounts.append(NetDeliveredKWHSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyReceivedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyDeliveredKWHSensor(coordinator, entry, account)) + fpl_accounts.append(FplDailyReceivedKWHSensor(coordinator, entry, account)) + fpl_accounts.append(FplDailyDeliveredKWHSensor(coordinator, entry, account)) async_add_devices(fpl_accounts) diff --git a/custom_components/fpl/sensor_test.py b/custom_components/fpl/sensor_test.py new file mode 100644 index 0000000..04bf0a2 --- /dev/null +++ b/custom_components/fpl/sensor_test.py @@ -0,0 +1,43 @@ +"""Test Sensors""" +from datetime import timedelta, datetime +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + DEVICE_CLASS_ENERGY, +) +from homeassistant.core import callback +from .fplEntity import FplEnergyEntity + + +class TestSensor(FplEnergyEntity): + """Daily Usage Kwh Sensor""" + + def __init__(self, coordinator, config, account): + super().__init__(coordinator, config, account, "Test Sensor") + + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = DEVICE_CLASS_ENERGY + + @property + def native_value(self): + data = self.getData("daily_usage") + + if data is not None and len(data) > 0 and "usage" in data[-1].keys(): + return data[-1]["usage"] + + return None + + @property + def last_reset(self) -> datetime | None: + data = self.getData("daily_usage") + date = data[-1]["readTime"] + last_reset = date - timedelta(days=1) + return last_reset + + def customAttributes(self): + """Return the state attributes.""" + data = self.getData("daily_usage") + date = data[-1]["readTime"] + + attributes = {} + attributes["date"] = date + return attributes From a9d343f1f5d7e214c87ae60c34885e51b35a1a75 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Tue, 2 Aug 2022 11:10:57 -0400 Subject: [PATCH 07/11] rework fpl api --- .../fpl/FplMainRegionApiClient.py | 388 ++++++++++++ .../fpl/FplNorthwestRegionApiClient.py | 167 ++++++ custom_components/fpl/__init__.py | 5 +- custom_components/fpl/aws_srp.py | 323 ++++++++++ custom_components/fpl/config_flow.py | 16 +- custom_components/fpl/const.py | 12 +- custom_components/fpl/exceptions.py | 13 + custom_components/fpl/fplEntity.py | 5 +- custom_components/fpl/fplapi.py | 563 ++++-------------- custom_components/fpl/sensor.py | 5 + .../fpl/sensor_AverageDailySensor.py | 12 +- .../fpl/sensor_DailyUsageSensor.py | 36 +- custom_components/fpl/sensor_DatesSensor.py | 20 +- custom_components/fpl/sensor_KWHSensor.py | 32 +- .../fpl/sensor_ProjectedBillSensor.py | 8 +- custom_components/fpl/sensor_test.py | 13 +- 16 files changed, 1129 insertions(+), 489 deletions(-) create mode 100644 custom_components/fpl/FplMainRegionApiClient.py create mode 100644 custom_components/fpl/FplNorthwestRegionApiClient.py create mode 100644 custom_components/fpl/aws_srp.py create mode 100644 custom_components/fpl/exceptions.py diff --git a/custom_components/fpl/FplMainRegionApiClient.py b/custom_components/fpl/FplMainRegionApiClient.py new file mode 100644 index 0000000..94d6de9 --- /dev/null +++ b/custom_components/fpl/FplMainRegionApiClient.py @@ -0,0 +1,388 @@ +"""FPL Main region data collection api client""" + +import json +import logging +from datetime import datetime, timedelta +import aiohttp +import async_timeout + + +from .const import ( + API_HOST, + LOGIN_RESULT_FAILURE, + LOGIN_RESULT_INVALIDPASSWORD, + LOGIN_RESULT_INVALIDUSER, + LOGIN_RESULT_OK, + TIMEOUT, +) + +STATUS_CATEGORY_OPEN = "OPEN" + +URL_LOGIN = API_HOST + "/api/resources/login" + +URL_BUDGET_BILLING_GRAPH = ( + API_HOST + "/api/resources/account/{account}/budgetBillingGraph" +) + +URL_RESOURCES_PROJECTED_BILL = ( + API_HOST + + "/api/resources/account/{account}/projectedBill" + + "?premiseNumber={premise}&lastBilledDate={lastBillDate}" +) + + +URL_APPLIANCE_USAGE = ( + API_HOST + "/dashboard-api/resources/account/{account}/applianceUsage/{account}" +) +URL_BUDGET_BILLING_PREMISE_DETAILS = ( + API_HOST + "/api/resources/account/{account}/budgetBillingGraph/premiseDetails" +) + + +ENROLLED = "ENROLLED" +NOTENROLLED = "NOTENROLLED" + +_LOGGER = logging.getLogger(__package__) + + +class FplMainRegionApiClient: + """Fpl Main Region Api Client""" + + def __init__(self, username, password, loop, session) -> None: + self.session = session + self.username = username + self.password = password + self.loop = loop + + async def login(self): + """login into fpl""" + + # login and get account information + + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get( + URL_LOGIN, + auth=aiohttp.BasicAuth(self.username, self.password), + ) + + if response.status == 200: + return LOGIN_RESULT_OK + + if response.status == 401: + json_data = json.loads(await response.text()) + + if json_data["messageCode"] == LOGIN_RESULT_INVALIDUSER: + return LOGIN_RESULT_INVALIDUSER + + if json_data["messageCode"] == LOGIN_RESULT_INVALIDPASSWORD: + return LOGIN_RESULT_INVALIDPASSWORD + + return LOGIN_RESULT_FAILURE + + async def get_open_accounts(self): + """ + Get open accounts + + Returns array with active account numbers + """ + result = [] + URL = API_HOST + "/api/resources/header" + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get(URL) + + json_data = await response.json() + accounts = json_data["data"]["accounts"]["data"]["data"] + + for account in accounts: + if account["statusCategory"] == STATUS_CATEGORY_OPEN: + result.append(account["accountNumber"]) + + return result + + async def logout(self): + """Logging out from fpl""" + _LOGGER.info("Logging out") + + URL_LOGOUT = API_HOST + "/api/resources/logout" + try: + async with async_timeout.timeout(TIMEOUT): + await self.session.get(URL_LOGOUT) + except Exception: + pass + + async def update(self, account) -> dict: + """Get data from resources endpoint""" + data = {} + + URL_RESOURCES_ACCOUNT = API_HOST + "/api/resources/account/{account}" + + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get( + URL_RESOURCES_ACCOUNT.format(account=account) + ) + account_data = (await response.json())["data"] + + premise = account_data.get("premiseNumber").zfill(9) + + data["meterSerialNo"] = account_data["meterSerialNo"] + + # currentBillDate + currentBillDate = datetime.strptime( + account_data["currentBillDate"].replace("-", "").split("T")[0], "%Y%m%d" + ).date() + + # nextBillDate + nextBillDate = datetime.strptime( + account_data["nextBillDate"].replace("-", "").split("T")[0], "%Y%m%d" + ).date() + + data["current_bill_date"] = str(currentBillDate) + data["next_bill_date"] = str(nextBillDate) + + today = datetime.now().date() + + data["service_days"] = (nextBillDate - currentBillDate).days + data["as_of_days"] = (today - currentBillDate).days + data["remaining_days"] = (nextBillDate - today).days + + # zip code + # zip_code = accountData["serviceAddress"]["zip"] + + # projected bill + pbData = await self.__getFromProjectedBill(account, premise, currentBillDate) + data.update(pbData) + + # programs + programsData = account_data["programs"]["data"] + + programs = dict() + _LOGGER.info("Getting Programs") + for program in programsData: + if "enrollmentStatus" in program.keys(): + key = program["name"] + programs[key] = program["enrollmentStatus"] == ENROLLED + + def hasProgram(programName) -> bool: + return programName in programs and programs[programName] + + # Budget Billing program + if hasProgram("BBL"): + data["budget_bill"] = True + bbl_data = await self.__getBBL_async(account, data) + data.update(bbl_data) + else: + data["budget_bill"] = False + + # Get data from energy service + data.update( + await self.__getDataFromEnergyService(account, premise, currentBillDate) + ) + + # Get data from energy service ( hourly ) + # data.update( + # await self.__getDataFromEnergyServiceHourly( + # account, premise, currentBillDate + # ) + # ) + + data.update(await self.__getDataFromApplianceUsage(account, currentBillDate)) + return data + + async def __getFromProjectedBill(self, account, premise, currentBillDate) -> dict: + """get data from projected bill endpoint""" + data = {} + + try: + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get( + URL_RESOURCES_PROJECTED_BILL.format( + account=account, + premise=premise, + lastBillDate=currentBillDate.strftime("%m%d%Y"), + ) + ) + + if response.status == 200: + + projectedBillData = (await response.json())["data"] + + billToDate = float(projectedBillData["billToDate"]) + projectedBill = float(projectedBillData["projectedBill"]) + dailyAvg = float(projectedBillData["dailyAvg"]) + avgHighTemp = int(projectedBillData["avgHighTemp"]) + + data["bill_to_date"] = billToDate + data["projected_bill"] = projectedBill + data["daily_avg"] = dailyAvg + data["avg_high_temp"] = avgHighTemp + + except Exception: + pass + + return data + + async def __getBBL_async(self, account, projectedBillData) -> dict: + """Get budget billing data""" + _LOGGER.info("Getting budget billing data") + data = {} + try: + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get( + URL_BUDGET_BILLING_PREMISE_DETAILS.format(account=account) + ) + if response.status == 200: + r = (await response.json())["data"] + dataList = r["graphData"] + + # startIndex = len(dataList) - 1 + + billingCharge = 0 + budgetBillDeferBalance = r["defAmt"] + + projectedBill = projectedBillData["projected_bill"] + asOfDays = projectedBillData["as_of_days"] + + for det in dataList: + billingCharge += det["actuallBillAmt"] + + calc1 = (projectedBill + billingCharge) / 12 + calc2 = (1 / 12) * (budgetBillDeferBalance) + + projectedBudgetBill = round(calc1 + calc2, 2) + bbDailyAvg = round(projectedBudgetBill / 30, 2) + bbAsOfDateAmt = round(projectedBudgetBill / 30 * asOfDays, 2) + + data["budget_billing_daily_avg"] = bbDailyAvg + data["budget_billing_bill_to_date"] = bbAsOfDateAmt + + data["budget_billing_projected_bill"] = float(projectedBudgetBill) + + except Exception as e: + _LOGGER.error("Error getting BBL: %s", e) + + try: + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get( + URL_BUDGET_BILLING_GRAPH.format(account=account) + ) + if response.status == 200: + r = (await response.json())["data"] + data["bill_to_date"] = float(r["eleAmt"]) + data["defered_amount"] = float(r["defAmt"]) + + except Exception as e: + _LOGGER.error("Error getting BBL: %s", e) + + return data + + async def __getDataFromEnergyService( + self, account, premise, lastBilledDate + ) -> dict: + _LOGGER.info("Getting data from energy service") + + date = str(lastBilledDate.strftime("%m%d%Y")) + JSON = { + "recordCount": 24, + "status": 2, + "channel": "WEB", + "amrFlag": "Y", + "accountType": "RESIDENTIAL", + "revCode": "1", + "premiseNumber": premise, + "projectedBillFlag": True, + "billComparisionFlag": True, + "monthlyFlag": True, + "frequencyType": "Daily", + "lastBilledDate": date, + "applicationPage": "resDashBoard", + } + URL_ENERGY_SERVICE = ( + API_HOST + + "/dashboard-api/resources/account/{account}/energyService/{account}" + ) + + data = {} + try: + async with async_timeout.timeout(TIMEOUT): + response = await self.session.post( + URL_ENERGY_SERVICE.format(account=account), json=JSON + ) + if response.status == 200: + r = (await response.json())["data"] + dailyUsage = [] + + # totalPowerUsage = 0 + if "data" in r["DailyUsage"]: + for daily in r["DailyUsage"]["data"]: + if ( + "kwhUsed" in daily.keys() + and "billingCharge" in daily.keys() + and "date" in daily.keys() + and "averageHighTemperature" in daily.keys() + ): + dailyUsage.append( + { + "usage": daily["kwhUsed"], + "cost": daily["billingCharge"], + # "date": daily["date"], + "max_temperature": daily[ + "averageHighTemperature" + ], + "netDeliveredKwh": daily["netDeliveredKwh"] + if "netDeliveredKwh" in daily.keys() + else 0, + "netReceivedKwh": daily["netReceivedKwh"] + if "netReceivedKwh" in daily.keys() + else 0, + "readTime": datetime.fromisoformat( + daily[ + "readTime" + ] # 2022-02-25T00:00:00.000-05:00 + ), + } + ) + # totalPowerUsage += int(daily["kwhUsed"]) + + # data["total_power_usage"] = totalPowerUsage + data["daily_usage"] = dailyUsage + + data["projectedKWH"] = r["CurrentUsage"]["projectedKWH"] + data["dailyAverageKWH"] = float( + r["CurrentUsage"]["dailyAverageKWH"] + ) + data["billToDateKWH"] = float(r["CurrentUsage"]["billToDateKWH"]) + data["recMtrReading"] = int(r["CurrentUsage"]["recMtrReading"]) + data["delMtrReading"] = int(r["CurrentUsage"]["delMtrReading"]) + data["billStartDate"] = r["CurrentUsage"]["billStartDate"] + except: + pass + + return data + + async def __getDataFromApplianceUsage(self, account, lastBilledDate) -> dict: + """get data from appliance usage""" + _LOGGER.info("Getting data from appliance usage") + + JSON = {"startDate": str(lastBilledDate.strftime("%m%d%Y"))} + data = {} + try: + async with async_timeout.timeout(TIMEOUT): + response = await self.session.post( + URL_APPLIANCE_USAGE.format(account=account), json=JSON + ) + if response.status == 200: + electric = (await response.json())["data"]["electric"] + + full = 100 + for e in electric: + rr = round(float(e["percentageDollar"])) + if rr < full: + full = full - rr + else: + rr = full + data[e["category"].replace(" ", "_")] = rr + + except Exception: + pass + + return {"energy_percent_by_applicance": data} diff --git a/custom_components/fpl/FplNorthwestRegionApiClient.py b/custom_components/fpl/FplNorthwestRegionApiClient.py new file mode 100644 index 0000000..6f449ca --- /dev/null +++ b/custom_components/fpl/FplNorthwestRegionApiClient.py @@ -0,0 +1,167 @@ +"""FPL Northwest data collection api client""" +from datetime import datetime +import logging +import async_timeout +import boto3 + +from .const import TIMEOUT, API_HOST +from .aws_srp import AWSSRP +from .const import LOGIN_RESULT_OK + +USER_POOL_ID = "us-east-1_w09KCowou" +CLIENT_ID = "4k78t7970hhdgtafurk158dr3a" + +ACCOUNT_STATUS_ACTIVE = "ACT" + +_LOGGER = logging.getLogger(__package__) + + +class FplNorthwestRegionApiClient: + """FPL Northwest Api client""" + + def __init__(self, username, password, loop, session) -> None: + self.session = session + self.username = username + self.password = password + self.loop = loop + self.id_token = None + self.access_token = None + self.refresh_token = None + + async def login(self): + """login using aws""" + client = await self.loop.run_in_executor( + None, boto3.client, "cognito-idp", "us-east-1" + ) + + aws = AWSSRP( + username=self.username, + password=self.password, + pool_id=USER_POOL_ID, + client_id=CLIENT_ID, + loop=self.loop, + client=client, + ) + tokens = await aws.authenticate_user() + + if "AccessToken" in tokens["AuthenticationResult"]: + self.access_token = tokens["AuthenticationResult"]["AccessToken"] + self.refresh_token = tokens["AuthenticationResult"]["RefreshToken"] + self.id_token = tokens["AuthenticationResult"]["IdToken"] + # Get User + headers = { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "AWSCognitoIdentityProviderService.GetUser", + } + + JSON = {"AccessToken": self.access_token} + + async with async_timeout.timeout(TIMEOUT): + response = await self.session.post( + "https://cognito-idp.us-east-1.amazonaws.com/", + headers=headers, + json=JSON, + ) + if response.status == 200: + data = await response.json(content_type="application/x-amz-json-1.1") + + # InitiateAuth + headers = { + "Content-Type": "application/x-amz-json-1.1", + "X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth", + } + + payload = { + "AuthFlow": "REFRESH_TOKEN_AUTH", + "AuthParameters": { + "DEVICE_KEY": None, + "REFRESH_TOKEN": self.refresh_token, + }, + "ClientId": "4k78t7970hhdgtafurk158dr3a", + } + + async with async_timeout.timeout(TIMEOUT): + response = await self.session.post( + "https://cognito-idp.us-east-1.amazonaws.com/", + headers=headers, + json=payload, + ) + if response.status == 200: + data = await response.json(content_type="application/x-amz-json-1.1") + + self.access_token = data["AuthenticationResult"]["AccessToken"] + self.id_token = tokens["AuthenticationResult"]["IdToken"] + + return LOGIN_RESULT_OK + + async def get_open_accounts(self): + """ + Returns the open accounts + """ + + result = [] + URL = API_HOST + "/cs/gulf/ssp/v1/profile/accounts/list" + + headers = {"Authorization": f"Bearer {self.id_token}"} + + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get(URL, headers=headers) + + if response.status == 200: + data = await response.json() + + for account in data["accounts"]: + if account["accountStatus"] == ACCOUNT_STATUS_ACTIVE: + result.append(account["accountNumber"]) + + return result + + async def logout(self): + """log out from fpl""" + + async def update(self, account): + """ + Returns the data collected from fpl + """ + + URL = ( + API_HOST + + f"/cs/gulf/ssp/v1/accountservices/account/{account}/accountSummary?balance=y" + ) + + headers = {"Authorization": f"Bearer {self.id_token}"} + async with async_timeout.timeout(TIMEOUT): + response = await self.session.get(URL, headers=headers) + + result = {} + + if response.status == 200: + data = await response.json() + + accountSumary = data["accountSummary"]["accountSummaryData"] + billAndMetterInfo = accountSumary["billAndMeterInfo"] + programInfo = accountSumary["programInfo"] + + result["budget_bill"] = False + result["bill_to_date"] = billAndMetterInfo["asOfDateAmount"] + + result["projected_bill"] = billAndMetterInfo["projBillAmount"] + result["projectedKWH"] = billAndMetterInfo["projBillKWH"] + + result["bill_to_date"] = billAndMetterInfo["asOfDateUsage"] + result["billToDateKWH"] = billAndMetterInfo["asOfDateUsage"] + + result["daily_avg"] = billAndMetterInfo["dailyAvgAmount"] + result["dailyAverageKWH"] = billAndMetterInfo["dailyAvgKwh"] + + result["billStartDate"] = programInfo["currentBillDate"] + result["next_bill_date"] = programInfo["nextBillDate"] + + start = datetime.fromisoformat(result["billStartDate"]) + end = datetime.fromisoformat(result["next_bill_date"]) + today = datetime.fromisoformat(data["today"]) + + result["service_days"] = (end - start).days + result["as_of_days"] = (today - start).days + + return result diff --git a/custom_components/fpl/__init__.py b/custom_components/fpl/__init__.py index a741e9d..1a7788e 100644 --- a/custom_components/fpl/__init__.py +++ b/custom_components/fpl/__init__.py @@ -9,6 +9,7 @@ from homeassistant.core import Config, HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from .fplapi import FplApi from .const import ( @@ -17,7 +18,7 @@ from .const import ( PLATFORMS, STARTUP_MESSAGE, ) -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + from .fplDataUpdateCoordinator import FplDataUpdateCoordinator MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) @@ -62,7 +63,7 @@ async def async_setup_entry(hass, entry): # Configure the client. _LOGGER.info("Configuring the client") session = async_get_clientsession(hass) - client = FplApi(username, password, session) + client = FplApi(username, password, session, hass.loop) coordinator = FplDataUpdateCoordinator(hass, client=client) await coordinator.async_refresh() diff --git a/custom_components/fpl/aws_srp.py b/custom_components/fpl/aws_srp.py new file mode 100644 index 0000000..51f5222 --- /dev/null +++ b/custom_components/fpl/aws_srp.py @@ -0,0 +1,323 @@ +"""AWS SRP""" +import os +import base64 +import binascii +import datetime +import hashlib +import hmac +import re +import functools +import boto3 + +import six + +from .exceptions import ForceChangePasswordException + +# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 +n_hex = ( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" + + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" + + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" + + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" + + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" + + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" + + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" + + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" + + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" + + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF" +) +# https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 +g_hex = "2" +info_bits = bytearray("Caldera Derived Key", "utf-8") + + +def hash_sha256(buf): + """AuthenticationHelper.hash""" + a = hashlib.sha256(buf).hexdigest() + return (64 - len(a)) * "0" + a + + +def hex_hash(hex_string): + return hash_sha256(bytearray.fromhex(hex_string)) + + +def hex_to_long(hex_string): + return int(hex_string, 16) + + +def long_to_hex(long_num): + return "%x" % long_num + + +def get_random(nbytes): + random_hex = binascii.hexlify(os.urandom(nbytes)) + return hex_to_long(random_hex) + + +def pad_hex(long_int): + """ + Converts a Long integer (or hex string) to hex format padded with zeroes for hashing + :param {Long integer|String} long_int Number or string to pad. + :return {String} Padded hex string. + """ + if not isinstance(long_int, six.string_types): + hash_str = long_to_hex(long_int) + else: + hash_str = long_int + if len(hash_str) % 2 == 1: + hash_str = "0%s" % hash_str + elif hash_str[0] in "89ABCDEFabcdef": + hash_str = "00%s" % hash_str + return hash_str + + +def compute_hkdf(ikm, salt): + """ + Standard hkdf algorithm + :param {Buffer} ikm Input key material. + :param {Buffer} salt Salt value. + :return {Buffer} Strong key material. + @private + """ + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + info_bits_update = info_bits + bytearray(chr(1), "utf-8") + hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() + return hmac_hash[:16] + + +def calculate_u(big_a, big_b): + """ + Calculate the client's value U which is the hash of A and B + :param {Long integer} big_a Large A value. + :param {Long integer} big_b Server B value. + :return {Long integer} Computed U value. + """ + u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b)) + return hex_to_long(u_hex_hash) + + +class AWSSRP(object): + + NEW_PASSWORD_REQUIRED_CHALLENGE = "NEW_PASSWORD_REQUIRED" + PASSWORD_VERIFIER_CHALLENGE = "PASSWORD_VERIFIER" + + def __init__( + self, + username, + password, + pool_id, + client_id, + pool_region=None, + client=None, + client_secret=None, + loop=None, + ): + if pool_region is not None and client is not None: + raise ValueError( + "pool_region and client should not both be specified " + "(region should be passed to the boto3 client instead)" + ) + + self.username = username + self.password = password + self.pool_id = pool_id + self.client_id = client_id + self.client_secret = client_secret + self.client = ( + client if client else boto3.client("cognito-idp", region_name=pool_region) + ) + self.big_n = hex_to_long(n_hex) + self.g = hex_to_long(g_hex) + self.k = hex_to_long(hex_hash("00" + n_hex + "0" + g_hex)) + self.small_a_value = self.generate_random_small_a() + self.large_a_value = self.calculate_a() + self.loop = loop + + def generate_random_small_a(self): + """ + helper function to generate a random big integer + :return {Long integer} a random value. + """ + random_long_int = get_random(128) + return random_long_int % self.big_n + + def calculate_a(self): + """ + Calculate the client's public value A = g^a%N + with the generated random number a + :param {Long integer} a Randomly generated small A. + :return {Long integer} Computed large A. + """ + big_a = pow(self.g, self.small_a_value, self.big_n) + # safety check + if (big_a % self.big_n) == 0: + raise ValueError("Safety check for A failed") + return big_a + + def get_password_authentication_key(self, username, password, server_b_value, salt): + """ + Calculates the final hkdf based on computed S value, and computed U value and the key + :param {String} username Username. + :param {String} password Password. + :param {Long integer} server_b_value Server B value. + :param {Long integer} salt Generated salt. + :return {Buffer} Computed HKDF value. + """ + u_value = calculate_u(self.large_a_value, server_b_value) + if u_value == 0: + raise ValueError("U cannot be zero.") + username_password = "%s%s:%s" % (self.pool_id.split("_")[1], username, password) + username_password_hash = hash_sha256(username_password.encode("utf-8")) + + x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash)) + g_mod_pow_xn = pow(self.g, x_value, self.big_n) + int_value2 = server_b_value - self.k * g_mod_pow_xn + s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n) + hkdf = compute_hkdf( + bytearray.fromhex(pad_hex(s_value)), + bytearray.fromhex(pad_hex(long_to_hex(u_value))), + ) + return hkdf + + def get_auth_params(self): + auth_params = { + "USERNAME": self.username, + "SRP_A": long_to_hex(self.large_a_value), + } + if self.client_secret is not None: + auth_params.update( + { + "SECRET_HASH": self.get_secret_hash( + self.username, self.client_id, self.client_secret + ) + } + ) + return auth_params + + @staticmethod + def get_secret_hash(username, client_id, client_secret): + message = bytearray(username + client_id, "utf-8") + hmac_obj = hmac.new(bytearray(client_secret, "utf-8"), message, hashlib.sha256) + return base64.standard_b64encode(hmac_obj.digest()).decode("utf-8") + + def process_challenge(self, challenge_parameters): + user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"] + salt_hex = challenge_parameters["SALT"] + srp_b_hex = challenge_parameters["SRP_B"] + secret_block_b64 = challenge_parameters["SECRET_BLOCK"] + # re strips leading zero from a day number (required by AWS Cognito) + timestamp = re.sub( + r" 0(\d) ", + r" \1 ", + datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y"), + ) + hkdf = self.get_password_authentication_key( + user_id_for_srp, self.password, hex_to_long(srp_b_hex), salt_hex + ) + secret_block_bytes = base64.standard_b64decode(secret_block_b64) + msg = ( + bytearray(self.pool_id.split("_")[1], "utf-8") + + bytearray(user_id_for_srp, "utf-8") + + bytearray(secret_block_bytes) + + bytearray(timestamp, "utf-8") + ) + hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) + signature_string = base64.standard_b64encode(hmac_obj.digest()) + response = { + "TIMESTAMP": timestamp, + "USERNAME": user_id_for_srp, + "PASSWORD_CLAIM_SECRET_BLOCK": secret_block_b64, + "PASSWORD_CLAIM_SIGNATURE": signature_string.decode("utf-8"), + } + if self.client_secret is not None: + response.update( + { + "SECRET_HASH": self.get_secret_hash( + self.username, self.client_id, self.client_secret + ) + } + ) + return response + + async def authenticate_user(self, client=None): + """authenticate user""" + boto_client = self.client or client + auth_params = self.get_auth_params() + + response = await self.loop.run_in_executor( + None, + functools.partial( + boto_client.initiate_auth, + AuthFlow="USER_SRP_AUTH", + AuthParameters=auth_params, + ClientId=self.client_id, + ), + ) + + if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE: + challenge_response = self.process_challenge(response["ChallengeParameters"]) + tokens = await self.loop.run_in_executor( + None, + functools.partial( + boto_client.respond_to_auth_challenge, + ClientId=self.client_id, + ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, + ChallengeResponses=challenge_response, + ), + ) + # tokens = boto_client.respond_to_auth_challenge( + # ClientId=self.client_id, + # ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, + # ChallengeResponses=challenge_response, + # ) + + if tokens.get("ChallengeName") == self.NEW_PASSWORD_REQUIRED_CHALLENGE: + raise ForceChangePasswordException( + "Change password before authenticating" + ) + + return tokens + else: + raise NotImplementedError( + "The %s challenge is not supported" % response["ChallengeName"] + ) + + def set_new_password_challenge(self, new_password, client=None): + boto_client = self.client or client + auth_params = self.get_auth_params() + response = boto_client.initiate_auth( + AuthFlow="USER_SRP_AUTH", + AuthParameters=auth_params, + ClientId=self.client_id, + ) + if response["ChallengeName"] == self.PASSWORD_VERIFIER_CHALLENGE: + challenge_response = self.process_challenge(response["ChallengeParameters"]) + tokens = boto_client.respond_to_auth_challenge( + ClientId=self.client_id, + ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, + ChallengeResponses=challenge_response, + ) + + if tokens["ChallengeName"] == self.NEW_PASSWORD_REQUIRED_CHALLENGE: + challenge_response = { + "USERNAME": auth_params["USERNAME"], + "NEW_PASSWORD": new_password, + } + new_password_response = boto_client.respond_to_auth_challenge( + ClientId=self.client_id, + ChallengeName=self.NEW_PASSWORD_REQUIRED_CHALLENGE, + Session=tokens["Session"], + ChallengeResponses=challenge_response, + ) + return new_password_response + return tokens + else: + raise NotImplementedError( + "The %s challenge is not supported" % response["ChallengeName"] + ) diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index 90ae650..fda232b 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -8,16 +8,19 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.core import callback from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME -from .const import DEFAULT_CONF_PASSWORD, DEFAULT_CONF_USERNAME, DOMAIN -from .fplapi import ( +from .const import ( + DEFAULT_CONF_PASSWORD, + DEFAULT_CONF_USERNAME, + DOMAIN, LOGIN_RESULT_OK, LOGIN_RESULT_FAILURE, LOGIN_RESULT_INVALIDUSER, LOGIN_RESULT_INVALIDPASSWORD, - FplApi, ) +from .fplapi import FplApi + try: from .secrets import DEFAULT_CONF_PASSWORD, DEFAULT_CONF_USERNAME except: @@ -61,12 +64,15 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if username not in configured_instances(self.hass): session = async_create_clientsession(self.hass) - api = FplApi(username, password, session) + api = FplApi(username, password, session, loop=self.hass.loop) result = await api.login() if result == LOGIN_RESULT_OK: + info = await api.get_basic_info() - accounts = await api.async_get_open_accounts() + accounts = info["accounts"] + + # accounts = await api.async_get_open_accounts() await api.logout() user_input["accounts"] = accounts diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index 7aeab49..ee076a6 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -1,7 +1,9 @@ """Constants for fpl.""" # -DEBUG = True +DEBUG = False +TIMEOUT = 5 +API_HOST = "https://www.fpl.com" # Base component constants NAME = "FPL Integration" @@ -45,3 +47,11 @@ If you have any issues with this you need to open an issue here: DEFAULT_CONF_USERNAME = "" DEFAULT_CONF_PASSWORD = "" + + +# Api login result +LOGIN_RESULT_OK = "OK" +LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER" +LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD" +LOGIN_RESULT_UNAUTHORIZED = "UNAUTHORIZED" +LOGIN_RESULT_FAILURE = "FAILURE" diff --git a/custom_components/fpl/exceptions.py b/custom_components/fpl/exceptions.py new file mode 100644 index 0000000..89c8bc5 --- /dev/null +++ b/custom_components/fpl/exceptions.py @@ -0,0 +1,13 @@ +"""exceptions file""" + + +class WarrantException(Exception): + """Base class for all Warrant exceptions""" + + +class ForceChangePasswordException(WarrantException): + """Raised when the user is forced to change their password""" + + +class TokenVerificationException(WarrantException): + """Raised when token verification fails.""" diff --git a/custom_components/fpl/fplEntity.py b/custom_components/fpl/fplEntity.py index 22f585f..411326a 100644 --- a/custom_components/fpl/fplEntity.py +++ b/custom_components/fpl/fplEntity.py @@ -71,12 +71,11 @@ class FplEnergyEntity(FplEntity): """Represents a energy sensor""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_ENERGY + # _attr_device_class = DEVICE_CLASS_ENERGY _attr_icon = "mdi:flash" - _attr_state_class = STATE_CLASS_MEASUREMENT @property - def last_reset(self) -> datetime: + def last_reset_not_use(self) -> datetime: """Return the time when the sensor was last reset, if any.""" today = datetime.today() diff --git a/custom_components/fpl/fplapi.py b/custom_components/fpl/fplapi.py index ef02114..d4c7711 100644 --- a/custom_components/fpl/fplapi.py +++ b/custom_components/fpl/fplapi.py @@ -1,478 +1,163 @@ """Custom FPl api client""" -import logging -from datetime import datetime, timedelta - import sys import json -import aiohttp +import logging + +from datetime import datetime, timedelta import async_timeout -STATUS_CATEGORY_OPEN = "OPEN" -# Api login result -LOGIN_RESULT_OK = "OK" -LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER" -LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD" -LOGIN_RESULT_UNAUTHORIZED = "UNAUTHORIZED" -LOGIN_RESULT_FAILURE = "FAILURE" + +from .const import ( + LOGIN_RESULT_FAILURE, + LOGIN_RESULT_OK, + TIMEOUT, + API_HOST, +) + +from .FplMainRegionApiClient import FplMainRegionApiClient +from .FplNorthwestRegionApiClient import FplNorthwestRegionApiClient + _LOGGER = logging.getLogger(__package__) -TIMEOUT = 5 - -API_HOST = "https://www.fpl.com" - -URL_LOGIN = API_HOST + "/api/resources/login" -URL_LOGOUT = API_HOST + "/api/resources/logout" -URL_RESOURCES_HEADER = API_HOST + "/api/resources/header" -URL_RESOURCES_ACCOUNT = API_HOST + "/api/resources/account/{account}" -URL_BUDGET_BILLING_GRAPH = ( - API_HOST + "/api/resources/account/{account}/budgetBillingGraph" -) - -URL_RESOURCES_PROJECTED_BILL = ( - API_HOST - + "/api/resources/account/{account}/projectedBill" - + "?premiseNumber={premise}&lastBilledDate={lastBillDate}" -) - -URL_ENERGY_SERVICE = ( - API_HOST + "/dashboard-api/resources/account/{account}/energyService/{account}" -) -URL_APPLIANCE_USAGE = ( - API_HOST + "/dashboard-api/resources/account/{account}/applianceUsage/{account}" -) -URL_BUDGET_BILLING_PREMISE_DETAILS = ( - API_HOST + "/api/resources/account/{account}/budgetBillingGraph/premiseDetails" -) -ENROLLED = "ENROLLED" -NOTENROLLED = "NOTENROLLED" +URL_TERRITORY = API_HOST + "/cs/customer/v1/territoryid/public/territory" + + +FPL_MAINREGION = "FL01" +FPL_NORTHWEST = "FL02" + + +class NoTerrytoryAvailableException(Exception): + """Thrown when not possible to determine user territory""" class FplApi: """A class for getting energy usage information from Florida Power & Light.""" - def __init__(self, username, password, session): + def __init__(self, username, password, session, loop): """Initialize the data retrieval. Session should have BasicAuth flag set.""" self._username = username self._password = password self._session = session + self._loop = loop + self._territory = None + self.access_token = None + self.id_token = None + self.apiClient = None + + async def getTerritory(self): + """get territory""" + if self._territory is not None: + return self._territory + + headers = {"userID": f"{self._username}", "channel": "WEB"} + async with async_timeout.timeout(TIMEOUT): + response = await self._session.get(URL_TERRITORY, headers=headers) + + if response.status == 200: + json_data = json.loads(await response.text()) + + territoryArray = json_data["data"]["territory"] + if len(territoryArray) == 0: + raise NoTerrytoryAvailableException() + + self._territory = territoryArray[0] + return territoryArray[0] + + def isMainRegion(self): + """Returns true if this account belongs to the main region, not northwest""" + return self._territory == FPL_MAINREGION + + async def initialize(self): + """initialize the api client""" + self._territory = await self.getTerritory() + + # set the api client based on user's territory + if self.apiClient is None: + if self.isMainRegion(): + self.apiClient = FplMainRegionApiClient( + self._username, self._password, self._loop, self._session + ) + else: + self.apiClient = FplNorthwestRegionApiClient( + self._username, self._password, self._loop, self._session + ) + + async def get_basic_info(self): + """returns basic info for sensor initialization""" + await self.initialize() + data = {} + data["territory"] = self._territory + data["accounts"] = await self.apiClient.get_open_accounts() + + return data async def async_get_data(self) -> dict: """Get data from fpl api""" - data = {} + await self.initialize() + data = { + "as_of_days": 5, + "avg_high_temp": 89, + "billStartDate": "07-27-2022", + "billToDateKWH": "196", + "bill_to_date": 160.1, + "budget_bill": True, + "budget_billing_bill_to_date": 18.61, + "budget_billing_daily_avg": 3.72, + "budget_billing_projected_bill": 111.69, + "current_bill_date": "2022-07-27", + "dailyAverageKWH": 39, + "daily_avg": 5.25, + "daily_usage": [], + "defered_amount": -6.84, + "delMtrReading": "15929", + "energy_percent_by_applicance": {}, + "meterSerialNo": "20948426", + "next_bill_date": "2022-08-26", + "projectedKWH": "1176", + "projected_bill": 163.77, + "recMtrReading": "", + "remaining_days": 25, + "service_days": 30, + } data["accounts"] = [] - if await self.login() == LOGIN_RESULT_OK: - accounts = await self.async_get_open_accounts() + + data["territory"] = self._territory + + print(self._territory) + + login_result = await self.apiClient.login() + + if login_result == LOGIN_RESULT_OK: + accounts = await self.apiClient.get_open_accounts() data["accounts"] = accounts for account in accounts: - account_data = await self.__async_get_data(account) - data[account] = account_data + data[account] = await self.apiClient.update(account) - await self.logout() + await self.apiClient.logout() return data async def login(self): - """login into fpl""" - _LOGGER.info("Logging in") - # login and get account information + """method to use in config flow""" try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_LOGIN, auth=aiohttp.BasicAuth(self._username, self._password) - ) + await self.initialize() - if response.status == 200: - _LOGGER.info("Logging Successful") - return LOGIN_RESULT_OK + _LOGGER.info("Logging in") + # login and get account information - if response.status == 401: - _LOGGER.error("Logging Unauthorized") - json_data = json.loads(await response.text()) - - if json_data["messageCode"] == LOGIN_RESULT_INVALIDUSER: - return LOGIN_RESULT_INVALIDUSER - - if json_data["messageCode"] == LOGIN_RESULT_INVALIDPASSWORD: - return LOGIN_RESULT_INVALIDPASSWORD + return await self.apiClient.login() except Exception as exception: _LOGGER.error("Error %s : %s", exception, sys.exc_info()[0]) return LOGIN_RESULT_FAILURE - return LOGIN_RESULT_FAILURE + async def async_get_open_accounts(self): + """return open accounts""" + self.initialize() + return await self.apiClient.get_open_accounts() async def logout(self): - """Logging out from fpl""" - _LOGGER.info("Logging out") - try: - async with async_timeout.timeout(TIMEOUT): - await self._session.get(URL_LOGOUT) - except Exception: - pass - - async def async_get_open_accounts(self): - """Getting open accounts""" - _LOGGER.info("Getting open accounts") - result = [] - - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get(URL_RESOURCES_HEADER) - - json_data = await response.json() - accounts = json_data["data"]["accounts"]["data"]["data"] - - for account in accounts: - if account["statusCategory"] == STATUS_CATEGORY_OPEN: - result.append(account["accountNumber"]) - - except Exception: - _LOGGER.error("Getting accounts %s", sys.exc_info()) - - return result - - async def __async_get_data(self, account) -> dict: - """Get data from resources endpoint""" - _LOGGER.info("Getting Data") - data = {} - - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_RESOURCES_ACCOUNT.format(account=account) - ) - account_data = (await response.json())["data"] - - premise = account_data.get("premiseNumber").zfill(9) - - data["meterSerialNo"] = account_data["meterSerialNo"] - - # currentBillDate - currentBillDate = datetime.strptime( - account_data["currentBillDate"].replace("-", "").split("T")[0], "%Y%m%d" - ).date() - - # nextBillDate - nextBillDate = datetime.strptime( - account_data["nextBillDate"].replace("-", "").split("T")[0], "%Y%m%d" - ).date() - - data["current_bill_date"] = str(currentBillDate) - data["next_bill_date"] = str(nextBillDate) - - today = datetime.now().date() - - data["service_days"] = (nextBillDate - currentBillDate).days - data["as_of_days"] = (today - currentBillDate).days - data["remaining_days"] = (nextBillDate - today).days - - # zip code - # zip_code = accountData["serviceAddress"]["zip"] - - # projected bill - pbData = await self.__getFromProjectedBill(account, premise, currentBillDate) - data.update(pbData) - - # programs - programsData = account_data["programs"]["data"] - - programs = dict() - _LOGGER.info("Getting Programs") - for program in programsData: - if "enrollmentStatus" in program.keys(): - key = program["name"] - programs[key] = program["enrollmentStatus"] == ENROLLED - - def hasProgram(programName) -> bool: - return programName in programs and programs[programName] - - # Budget Billing program - if hasProgram("BBL"): - data["budget_bill"] = True - bbl_data = await self.__getBBL_async(account, data) - data.update(bbl_data) - else: - data["budget_bill"] = False - - # Get data from energy service - data.update( - await self.__getDataFromEnergyService(account, premise, currentBillDate) - ) - - # Get data from energy service ( hourly ) - # data.update( - # await self.__getDataFromEnergyServiceHourly( - # account, premise, currentBillDate - # ) - # ) - - data.update(await self.__getDataFromApplianceUsage(account, currentBillDate)) - return data - - async def __getFromProjectedBill(self, account, premise, currentBillDate) -> dict: - """get data from projected bill endpoint""" - data = {} - - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_RESOURCES_PROJECTED_BILL.format( - account=account, - premise=premise, - lastBillDate=currentBillDate.strftime("%m%d%Y"), - ) - ) - - if response.status == 200: - - projectedBillData = (await response.json())["data"] - - billToDate = float(projectedBillData["billToDate"]) - projectedBill = float(projectedBillData["projectedBill"]) - dailyAvg = float(projectedBillData["dailyAvg"]) - avgHighTemp = int(projectedBillData["avgHighTemp"]) - - data["bill_to_date"] = billToDate - data["projected_bill"] = projectedBill - data["daily_avg"] = dailyAvg - data["avg_high_temp"] = avgHighTemp - - except Exception: - pass - - return data - - async def __getBBL_async(self, account, projectedBillData) -> dict: - """Get budget billing data""" - _LOGGER.info("Getting budget billing data") - data = {} - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_BUDGET_BILLING_PREMISE_DETAILS.format(account=account) - ) - if response.status == 200: - r = (await response.json())["data"] - dataList = r["graphData"] - - # startIndex = len(dataList) - 1 - - billingCharge = 0 - budgetBillDeferBalance = r["defAmt"] - - projectedBill = projectedBillData["projected_bill"] - asOfDays = projectedBillData["as_of_days"] - - for det in dataList: - billingCharge += det["actuallBillAmt"] - - calc1 = (projectedBill + billingCharge) / 12 - calc2 = (1 / 12) * (budgetBillDeferBalance) - - projectedBudgetBill = round(calc1 + calc2, 2) - bbDailyAvg = round(projectedBudgetBill / 30, 2) - bbAsOfDateAmt = round(projectedBudgetBill / 30 * asOfDays, 2) - - data["budget_billing_daily_avg"] = bbDailyAvg - data["budget_billing_bill_to_date"] = bbAsOfDateAmt - - data["budget_billing_projected_bill"] = float(projectedBudgetBill) - - except Exception as e: - _LOGGER.error("Error getting BBL: %s", e) - - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_BUDGET_BILLING_GRAPH.format(account=account) - ) - if response.status == 200: - r = (await response.json())["data"] - data["bill_to_date"] = float(r["eleAmt"]) - data["defered_amount"] = float(r["defAmt"]) - - except Exception as e: - _LOGGER.error("Error getting BBL: %s", e) - - return data - - async def __getDataFromEnergyService( - self, account, premise, lastBilledDate - ) -> dict: - _LOGGER.info("Getting data from energy service") - - date = str(lastBilledDate.strftime("%m%d%Y")) - JSON = { - "recordCount": 24, - "status": 2, - "channel": "WEB", - "amrFlag": "Y", - "accountType": "RESIDENTIAL", - "revCode": "1", - "premiseNumber": premise, - "projectedBillFlag": True, - "billComparisionFlag": True, - "monthlyFlag": True, - "frequencyType": "Daily", - "lastBilledDate": date, - "applicationPage": "resDashBoard", - } - - data = {} - - async with async_timeout.timeout(TIMEOUT): - response = await self._session.post( - URL_ENERGY_SERVICE.format(account=account), json=JSON - ) - if response.status == 200: - r = (await response.json())["data"] - dailyUsage = [] - - # totalPowerUsage = 0 - if "data" in r["DailyUsage"]: - for daily in r["DailyUsage"]["data"]: - if ( - "kwhUsed" in daily.keys() - and "billingCharge" in daily.keys() - and "date" in daily.keys() - and "averageHighTemperature" in daily.keys() - ): - dailyUsage.append( - { - "usage": daily["kwhUsed"], - "cost": daily["billingCharge"], - # "date": daily["date"], - "max_temperature": daily["averageHighTemperature"], - "netDeliveredKwh": daily["netDeliveredKwh"] - if "netDeliveredKwh" in daily.keys() - else 0, - "netReceivedKwh": daily["netReceivedKwh"] - if "netReceivedKwh" in daily.keys() - else 0, - "readTime": datetime.fromisoformat( - daily[ - "readTime" - ] # 2022-02-25T00:00:00.000-05:00 - ), - } - ) - # totalPowerUsage += int(daily["kwhUsed"]) - - # data["total_power_usage"] = totalPowerUsage - data["daily_usage"] = dailyUsage - - data["projectedKWH"] = r["CurrentUsage"]["projectedKWH"] - data["dailyAverageKWH"] = r["CurrentUsage"]["dailyAverageKWH"] - data["billToDateKWH"] = r["CurrentUsage"]["billToDateKWH"] - data["recMtrReading"] = r["CurrentUsage"]["recMtrReading"] - data["delMtrReading"] = r["CurrentUsage"]["delMtrReading"] - data["billStartDate"] = r["CurrentUsage"]["billStartDate"] - return data - - async def __getDataFromEnergyServiceHourly( - self, account, premise, lastBilledDate - ) -> dict: - _LOGGER.info("Getting data from energy service Hourly") - - # date = str(lastBilledDate.strftime("%m%d%Y")) - date = str((datetime.now() - timedelta(days=1)).strftime("%m%d%Y")) - - JSON = { - "status": 2, - "channel": "WEB", - "amrFlag": "Y", - "accountType": "RESIDENTIAL", - "revCode": "1", - "premiseNumber": premise, - "projectedBillFlag": False, - "billComparisionFlag": False, - "monthlyFlag": False, - "frequencyType": "Hourly", - "applicationPage": "resDashBoard", - "startDate": date, - } - - data = {} - - # now = homeassistant.util.dt.utcnow() - - # now = datetime.now().astimezone() - # hour = now.hour - - async with async_timeout.timeout(TIMEOUT): - response = await self._session.post( - URL_ENERGY_SERVICE.format(account=account), json=JSON - ) - if response.status == 200: - r = (await response.json())["data"] - dailyUsage = [] - - # totalPowerUsage = 0 - if "data" in r["HourlyUsage"]: - for daily in r["HourlyUsage"]["data"]: - if ( - "kwhUsed" in daily.keys() - and "billingCharge" in daily.keys() - and "date" in daily.keys() - and "averageHighTemperature" in daily.keys() - ): - dailyUsage.append( - { - "usage": daily["kwhUsed"], - "cost": daily["billingCharge"], - # "date": daily["date"], - "max_temperature": daily["averageHighTemperature"], - "netDeliveredKwh": daily["netDeliveredKwh"] - if "netDeliveredKwh" in daily.keys() - else 0, - "netReceivedKwh": daily["netReceivedKwh"] - if "netReceivedKwh" in daily.keys() - else 0, - "readTime": datetime.fromisoformat( - daily[ - "readTime" - ] # 2022-02-25T00:00:00.000-05:00 - ), - } - ) - # totalPowerUsage += int(daily["kwhUsed"]) - - # data["total_power_usage"] = totalPowerUsage - data["daily_usage"] = dailyUsage - - data["projectedKWH"] = r["HourlyUsage"]["projectedKWH"] - data["dailyAverageKWH"] = r["HourlyUsage"]["dailyAverageKWH"] - data["billToDateKWH"] = r["HourlyUsage"]["billToDateKWH"] - data["recMtrReading"] = r["HourlyUsage"]["recMtrReading"] - data["delMtrReading"] = r["HourlyUsage"]["delMtrReading"] - data["billStartDate"] = r["HourlyUsage"]["billStartDate"] - return data - - async def __getDataFromApplianceUsage(self, account, lastBilledDate) -> dict: - """get data from appliance usage""" - _LOGGER.info("Getting data from appliance usage") - - JSON = {"startDate": str(lastBilledDate.strftime("%m%d%Y"))} - data = {} - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.post( - URL_APPLIANCE_USAGE.format(account=account), json=JSON - ) - if response.status == 200: - electric = (await response.json())["data"]["electric"] - - full = 100 - for e in electric: - rr = round(float(e["percentageDollar"])) - if rr < full: - full = full - rr - else: - rr = full - data[e["category"].replace(" ", "_")] = rr - - except Exception: - pass - - return {"energy_percent_by_applicance": data} + """log out from fpl""" + return await self.apiClient.logout() diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 5aab8be..19ed290 100644 --- a/custom_components/fpl/sensor.py +++ b/custom_components/fpl/sensor.py @@ -43,6 +43,9 @@ async def async_setup_entry(hass, entry, async_add_devices): """Setup sensor platform.""" accounts = entry.data.get("accounts") + territory = entry.data.get("territory") + + print(f"setting sensor for {territory}") coordinator = hass.data[DOMAIN][entry.entry_id] fpl_accounts = [] @@ -50,6 +53,8 @@ async def async_setup_entry(hass, entry, async_add_devices): if DEBUG: for account in accounts: fpl_accounts.append(TestSensor(coordinator, entry, account)) + + fpl_accounts.append(FplProjectedBillSensor(coordinator, entry, account)) else: for account in accounts: # Test Sensor diff --git a/custom_components/fpl/sensor_AverageDailySensor.py b/custom_components/fpl/sensor_AverageDailySensor.py index f997c0c..ad1a14c 100644 --- a/custom_components/fpl/sensor_AverageDailySensor.py +++ b/custom_components/fpl/sensor_AverageDailySensor.py @@ -10,7 +10,7 @@ class DailyAverageSensor(FplMoneyEntity): super().__init__(coordinator, config, account, "Daily Average") @property - def state(self): + def native_value(self): budget = self.getData("budget_bill") budget_billing_projected_bill = self.getData("budget_billing_daily_avg") @@ -22,7 +22,7 @@ class DailyAverageSensor(FplMoneyEntity): def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL + # attributes["state_class"] = STATE_CLASS_TOTAL return attributes @@ -33,13 +33,13 @@ class BudgetDailyAverageSensor(FplMoneyEntity): super().__init__(coordinator, config, account, "Budget Daily Average") @property - def state(self): + def native_value(self): return self.getData("budget_billing_daily_avg") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL + # attributes["state_class"] = STATE_CLASS_TOTAL return attributes @@ -50,11 +50,11 @@ class ActualDailyAverageSensor(FplMoneyEntity): super().__init__(coordinator, config, account, "Actual Daily Average") @property - def state(self): + def native_value(self): return self.getData("daily_avg") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL + # attributes["state_class"] = STATE_CLASS_TOTAL return attributes diff --git a/custom_components/fpl/sensor_DailyUsageSensor.py b/custom_components/fpl/sensor_DailyUsageSensor.py index de4a486..1a46b5c 100644 --- a/custom_components/fpl/sensor_DailyUsageSensor.py +++ b/custom_components/fpl/sensor_DailyUsageSensor.py @@ -1,6 +1,10 @@ """Daily Usage Sensors""" -from datetime import timedelta -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING +from datetime import timedelta, datetime +from homeassistant.components.sensor import ( + STATE_CLASS_TOTAL_INCREASING, + STATE_CLASS_TOTAL, + DEVICE_CLASS_ENERGY, +) from .fplEntity import FplEnergyEntity, FplMoneyEntity @@ -11,7 +15,7 @@ class FplDailyUsageSensor(FplMoneyEntity): super().__init__(coordinator, config, account, "Daily Usage") @property - def state(self): + def native_value(self): data = self.getData("daily_usage") if data is not None and len(data) > 0 and "cost" in data[-1].keys(): @@ -36,8 +40,11 @@ class FplDailyUsageKWHSensor(FplEnergyEntity): def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Daily Usage KWH") + _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = DEVICE_CLASS_ENERGY + @property - def state(self): + def native_value(self): data = self.getData("daily_usage") if data is not None and len(data) > 0 and "usage" in data[-1].keys(): @@ -45,6 +52,13 @@ class FplDailyUsageKWHSensor(FplEnergyEntity): return None + @property + def last_reset(self) -> datetime | None: + data = self.getData("daily_usage") + date = data[-1]["readTime"] + last_reset = date - timedelta(days=1) + return last_reset + def customAttributes(self): """Return the state attributes.""" data = self.getData("daily_usage") @@ -64,8 +78,10 @@ class FplDailyReceivedKWHSensor(FplEnergyEntity): def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Daily Received KWH") + # _attr_state_class = STATE_CLASS_TOTAL_INCREASING + @property - def state(self): + def native_value(self): data = self.getData("daily_usage") if data is not None and len(data) > 0 and "netReceivedKwh" in data[-1].keys(): return data[-1]["netReceivedKwh"] @@ -78,20 +94,21 @@ class FplDailyReceivedKWHSensor(FplEnergyEntity): last_reset = date - timedelta(days=1) attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING attributes["date"] = date - attributes["last_reset"] = last_reset + # attributes["last_reset"] = last_reset return attributes class FplDailyDeliveredKWHSensor(FplEnergyEntity): """daily delivered Kwh sensor""" + # _attr_state_class = STATE_CLASS_TOTAL_INCREASING + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Daily Delivered KWH") @property - def state(self): + def native_value(self): data = self.getData("daily_usage") if data is not None and len(data) > 0 and "netDeliveredKwh" in data[-1].keys(): return data[-1]["netDeliveredKwh"] @@ -104,7 +121,6 @@ class FplDailyDeliveredKWHSensor(FplEnergyEntity): last_reset = date - timedelta(days=1) attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING attributes["date"] = date - attributes["last_reset"] = last_reset + # attributes["last_reset"] = last_reset return attributes diff --git a/custom_components/fpl/sensor_DatesSensor.py b/custom_components/fpl/sensor_DatesSensor.py index 0a54844..3a0fd31 100644 --- a/custom_components/fpl/sensor_DatesSensor.py +++ b/custom_components/fpl/sensor_DatesSensor.py @@ -4,45 +4,55 @@ from .fplEntity import FplDateEntity, FplDayEntity class CurrentBillDateSensor(FplDateEntity): + """Current bill date sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Current Bill Date") @property - def state(self): + def native_value(self): return datetime.date.fromisoformat(self.getData("current_bill_date")) class NextBillDateSensor(FplDateEntity): + """Next bill date sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Next Bill Date") @property - def state(self): + def native_value(self): return datetime.date.fromisoformat(self.getData("next_bill_date")) class ServiceDaysSensor(FplDayEntity): + """Service days sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Service Days") @property - def state(self): + def native_value(self): return self.getData("service_days") class AsOfDaysSensor(FplDayEntity): + """As of days sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "As Of Days") @property - def state(self): + def native_value(self): return self.getData("as_of_days") class RemainingDaysSensor(FplDayEntity): + """Remaining days sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Remaining Days") @property - def state(self): + def native_value(self): return self.getData("remaining_days") diff --git a/custom_components/fpl/sensor_KWHSensor.py b/custom_components/fpl/sensor_KWHSensor.py index c461cdd..585b799 100644 --- a/custom_components/fpl/sensor_KWHSensor.py +++ b/custom_components/fpl/sensor_KWHSensor.py @@ -8,41 +8,47 @@ from .fplEntity import FplEnergyEntity class ProjectedKWHSensor(FplEnergyEntity): + """Projected KWH sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected KWH") @property - def state(self): + def native_value(self): return self.getData("projectedKWH") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL + # attributes["state_class"] = STATE_CLASS_TOTAL return attributes class DailyAverageKWHSensor(FplEnergyEntity): + """Daily Average KWH sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Daily Average KWH") @property - def state(self): + def native_value(self): return self.getData("dailyAverageKWH") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL + # attributes["state_class"] = STATE_CLASS_TOTAL return attributes class BillToDateKWHSensor(FplEnergyEntity): + """Bill To Date KWH sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Bill To Date KWH") @property - def state(self): + def native_value(self): return self.getData("billToDateKWH") def customAttributes(self): @@ -54,36 +60,40 @@ class BillToDateKWHSensor(FplEnergyEntity): last_reset = date.today() - timedelta(days=asOfDays) attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING - attributes["last_reset"] = last_reset + # attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING + # attributes["last_reset"] = last_reset return attributes class NetReceivedKWHSensor(FplEnergyEntity): + """Received Meter Reading KWH sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Received Meter Reading KWH") @property - def state(self): + def native_value(self): return self.getData("recMtrReading") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING + # attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING return attributes class NetDeliveredKWHSensor(FplEnergyEntity): + """Delivered Meter Reading KWH sensor""" + def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Delivered Meter Reading KWH") @property - def state(self): + def native_value(self): return self.getData("delMtrReading") def customAttributes(self): """Return the state attributes.""" attributes = {} - attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING + # attributes["state_class"] = STATE_CLASS_TOTAL_INCREASING return attributes diff --git a/custom_components/fpl/sensor_ProjectedBillSensor.py b/custom_components/fpl/sensor_ProjectedBillSensor.py index e1d5762..00f84e0 100644 --- a/custom_components/fpl/sensor_ProjectedBillSensor.py +++ b/custom_components/fpl/sensor_ProjectedBillSensor.py @@ -9,7 +9,7 @@ from .fplEntity import FplMoneyEntity class FplProjectedBillSensor(FplMoneyEntity): """Projected bill sensor""" - _attr_state_class = STATE_CLASS_TOTAL + # _attr_state_class = STATE_CLASS_TOTAL def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Bill") @@ -35,7 +35,7 @@ class FplProjectedBillSensor(FplMoneyEntity): class DeferedAmountSensor(FplMoneyEntity): """Defered amount sensor""" - _attr_state_class = STATE_CLASS_TOTAL + # _attr_state_class = STATE_CLASS_TOTAL def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Defered Amount") @@ -50,7 +50,7 @@ class DeferedAmountSensor(FplMoneyEntity): class ProjectedBudgetBillSensor(FplMoneyEntity): """projected budget bill sensor""" - _attr_state_class = STATE_CLASS_TOTAL + # _attr_state_class = STATE_CLASS_TOTAL def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Budget Bill") @@ -63,7 +63,7 @@ class ProjectedBudgetBillSensor(FplMoneyEntity): class ProjectedActualBillSensor(FplMoneyEntity): """projeted actual bill sensor""" - _attr_state_class = STATE_CLASS_TOTAL + # _attr_state_class = STATE_CLASS_TOTAL def __init__(self, coordinator, config, account): super().__init__(coordinator, config, account, "Projected Actual Bill") diff --git a/custom_components/fpl/sensor_test.py b/custom_components/fpl/sensor_test.py index 04bf0a2..69a5892 100644 --- a/custom_components/fpl/sensor_test.py +++ b/custom_components/fpl/sensor_test.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_ENERGY, ) from homeassistant.core import callback +from homeassistant.const import STATE_UNKNOWN from .fplEntity import FplEnergyEntity @@ -24,20 +25,26 @@ class TestSensor(FplEnergyEntity): if data is not None and len(data) > 0 and "usage" in data[-1].keys(): return data[-1]["usage"] - return None + return STATE_UNKNOWN @property def last_reset(self) -> datetime | None: + last_reset = None data = self.getData("daily_usage") - date = data[-1]["readTime"] - last_reset = date - timedelta(days=1) + if len(data) > 0 and "readTime" in data[-1]: + date = data[-1]["readTime"] + last_reset = datetime.combine(date, datetime.min.time()) + print(f"setting last reset {last_reset}") return last_reset def customAttributes(self): """Return the state attributes.""" + print("setting custom attributes") data = self.getData("daily_usage") date = data[-1]["readTime"] attributes = {} attributes["date"] = date + last_reset = date - timedelta(days=1) + # attributes["last_reset"] = last_reset return attributes From 4069d5e4b4678b082b169f76daad19e3a9ec6463 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Tue, 2 Aug 2022 11:11:52 -0400 Subject: [PATCH 08/11] update image to latest and python to 3.10 --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 740ea84..a7e7945 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8 +FROM python:3.10 RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fec416a..1097eb5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "image": "ghcr.io/ludeeus/devcontainer/integration:latest", "name": "FPL integration development", "context": "..", "appPort": [ From ebb4d5acceb44127d97bde20336ddaac8e702603 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Wed, 17 Aug 2022 18:29:17 -0400 Subject: [PATCH 09/11] added some more constants --- custom_components/fpl/config_flow.py | 7 +++-- custom_components/fpl/const.py | 9 ++++-- custom_components/fpl/fplapi.py | 46 ++++++---------------------- 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index fda232b..9a66bfb 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -10,6 +10,8 @@ from homeassistant.core import callback from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME from .const import ( + CONF_ACCOUNTS, + CONF_TERRITORY, DEFAULT_CONF_PASSWORD, DEFAULT_CONF_USERNAME, DOMAIN, @@ -70,12 +72,13 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result == LOGIN_RESULT_OK: info = await api.get_basic_info() - accounts = info["accounts"] + accounts = info[CONF_ACCOUNTS] # accounts = await api.async_get_open_accounts() await api.logout() - user_input["accounts"] = accounts + user_input[CONF_ACCOUNTS] = accounts + user_input[CONF_TERRITORY] = info[CONF_TERRITORY] return self.async_create_entry(title=username, data=user_input) diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index ee076a6..d8f020f 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -1,7 +1,5 @@ """Constants for fpl.""" # -DEBUG = False - TIMEOUT = 5 API_HOST = "https://www.fpl.com" @@ -55,3 +53,10 @@ LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER" LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD" LOGIN_RESULT_UNAUTHORIZED = "UNAUTHORIZED" LOGIN_RESULT_FAILURE = "FAILURE" + + +CONF_TERRITORY = "territory" +CONF_ACCOUNTS = "account" + +FPL_MAINREGION = "FL01" +FPL_NORTHWEST = "FL02" diff --git a/custom_components/fpl/fplapi.py b/custom_components/fpl/fplapi.py index d4c7711..3a9cd8c 100644 --- a/custom_components/fpl/fplapi.py +++ b/custom_components/fpl/fplapi.py @@ -3,11 +3,13 @@ import sys import json import logging -from datetime import datetime, timedelta import async_timeout from .const import ( + CONF_ACCOUNTS, + CONF_TERRITORY, + FPL_MAINREGION, LOGIN_RESULT_FAILURE, LOGIN_RESULT_OK, TIMEOUT, @@ -24,10 +26,6 @@ _LOGGER = logging.getLogger(__package__) URL_TERRITORY = API_HOST + "/cs/customer/v1/territoryid/public/territory" -FPL_MAINREGION = "FL01" -FPL_NORTHWEST = "FL02" - - class NoTerrytoryAvailableException(Exception): """Thrown when not possible to determine user territory""" @@ -88,51 +86,25 @@ class FplApi: """returns basic info for sensor initialization""" await self.initialize() data = {} - data["territory"] = self._territory - data["accounts"] = await self.apiClient.get_open_accounts() + data[CONF_TERRITORY] = self._territory + data[CONF_ACCOUNTS] = await self.apiClient.get_open_accounts() return data async def async_get_data(self) -> dict: """Get data from fpl api""" await self.initialize() - data = { - "as_of_days": 5, - "avg_high_temp": 89, - "billStartDate": "07-27-2022", - "billToDateKWH": "196", - "bill_to_date": 160.1, - "budget_bill": True, - "budget_billing_bill_to_date": 18.61, - "budget_billing_daily_avg": 3.72, - "budget_billing_projected_bill": 111.69, - "current_bill_date": "2022-07-27", - "dailyAverageKWH": 39, - "daily_avg": 5.25, - "daily_usage": [], - "defered_amount": -6.84, - "delMtrReading": "15929", - "energy_percent_by_applicance": {}, - "meterSerialNo": "20948426", - "next_bill_date": "2022-08-26", - "projectedKWH": "1176", - "projected_bill": 163.77, - "recMtrReading": "", - "remaining_days": 25, - "service_days": 30, - } - data["accounts"] = [] + data = {} + data[CONF_ACCOUNTS] = [] - data["territory"] = self._territory - - print(self._territory) + data[CONF_TERRITORY] = self._territory login_result = await self.apiClient.login() if login_result == LOGIN_RESULT_OK: accounts = await self.apiClient.get_open_accounts() - data["accounts"] = accounts + data[CONF_ACCOUNTS] = accounts for account in accounts: data[account] = await self.apiClient.update(account) From 731ef9444ab588a243a95f2114941c68e034af6c Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Wed, 17 Aug 2022 18:29:43 -0400 Subject: [PATCH 10/11] added bill to date sensor and organize sensor initialization file --- custom_components/fpl/sensor.py | 102 +++++++++--------- .../fpl/sensor_ProjectedBillSensor.py | 16 +++ 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 19ed290..5681ede 100644 --- a/custom_components/fpl/sensor.py +++ b/custom_components/fpl/sensor.py @@ -15,6 +15,7 @@ from .sensor_DatesSensor import ( RemainingDaysSensor, ) from .sensor_ProjectedBillSensor import ( + BillToDateSensor, FplProjectedBillSensor, ProjectedBudgetBillSensor, ProjectedActualBillSensor, @@ -32,64 +33,69 @@ from .sensor_DailyUsageSensor import ( FplDailyReceivedKWHSensor, ) -from .sensor_test import TestSensor +from .const import CONF_ACCOUNTS, CONF_TERRITORY, DOMAIN, FPL_MAINREGION, FPL_NORTHWEST -from .const import DOMAIN, DEBUG +ALL_REGIONS = [FPL_MAINREGION, FPL_NORTHWEST] +ONLY_MAINREGION = [FPL_MAINREGION] -# from .TestSensor import TestSensor +sensors = {} + + +def registerSensor(sensor, regions): + """register all available sensors""" + for region in regions: + if region in sensors: + sensors[region].append(sensor) + else: + sensors[region] = [sensor] + + +# bill sensors +registerSensor(FplProjectedBillSensor, ALL_REGIONS) +registerSensor(BillToDateSensor, ALL_REGIONS) + +# budget billing +registerSensor(ProjectedBudgetBillSensor, ONLY_MAINREGION) +registerSensor(ProjectedActualBillSensor, ONLY_MAINREGION) +registerSensor(DeferedAmountSensor, ONLY_MAINREGION) + + +# usage sensors +registerSensor(DailyAverageSensor, ONLY_MAINREGION) +registerSensor(BudgetDailyAverageSensor, ONLY_MAINREGION) +registerSensor(ActualDailyAverageSensor, ONLY_MAINREGION) + +registerSensor(FplDailyUsageSensor, ONLY_MAINREGION) +registerSensor(FplDailyUsageKWHSensor, ONLY_MAINREGION) + +# date sensors +registerSensor(CurrentBillDateSensor, ALL_REGIONS) +registerSensor(NextBillDateSensor, ONLY_MAINREGION) +registerSensor(ServiceDaysSensor, ALL_REGIONS) +registerSensor(AsOfDaysSensor, ALL_REGIONS) +registerSensor(RemainingDaysSensor, ALL_REGIONS) + +# KWH sensors +registerSensor(ProjectedKWHSensor, ALL_REGIONS) +registerSensor(DailyAverageKWHSensor, ONLY_MAINREGION) +registerSensor(BillToDateKWHSensor, ALL_REGIONS) +registerSensor(NetReceivedKWHSensor, ONLY_MAINREGION) +registerSensor(NetDeliveredKWHSensor, ONLY_MAINREGION) +registerSensor(FplDailyReceivedKWHSensor, ONLY_MAINREGION) +registerSensor(FplDailyDeliveredKWHSensor, ONLY_MAINREGION) async def async_setup_entry(hass, entry, async_add_devices): """Setup sensor platform.""" - accounts = entry.data.get("accounts") - territory = entry.data.get("territory") - - print(f"setting sensor for {territory}") + accounts = entry.data.get(CONF_ACCOUNTS) + territory = entry.data.get(CONF_TERRITORY) coordinator = hass.data[DOMAIN][entry.entry_id] fpl_accounts = [] - if DEBUG: - for account in accounts: - fpl_accounts.append(TestSensor(coordinator, entry, account)) - - fpl_accounts.append(FplProjectedBillSensor(coordinator, entry, account)) - else: - for account in accounts: - # Test Sensor - # fpl_accounts.append(TestSensor(coordinator, entry, account)) - - # bill sensors - fpl_accounts.append(FplProjectedBillSensor(coordinator, entry, account)) - fpl_accounts.append(ProjectedBudgetBillSensor(coordinator, entry, account)) - fpl_accounts.append(ProjectedActualBillSensor(coordinator, entry, account)) - fpl_accounts.append(DeferedAmountSensor(coordinator, entry, account)) - - # usage sensors - fpl_accounts.append(DailyAverageSensor(coordinator, entry, account)) - fpl_accounts.append(BudgetDailyAverageSensor(coordinator, entry, account)) - fpl_accounts.append(ActualDailyAverageSensor(coordinator, entry, account)) - - fpl_accounts.append(FplDailyUsageSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyUsageKWHSensor(coordinator, entry, account)) - - # date sensors - fpl_accounts.append(CurrentBillDateSensor(coordinator, entry, account)) - fpl_accounts.append(NextBillDateSensor(coordinator, entry, account)) - fpl_accounts.append(ServiceDaysSensor(coordinator, entry, account)) - fpl_accounts.append(AsOfDaysSensor(coordinator, entry, account)) - fpl_accounts.append(RemainingDaysSensor(coordinator, entry, account)) - - # KWH sensors - fpl_accounts.append(ProjectedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(DailyAverageKWHSensor(coordinator, entry, account)) - fpl_accounts.append(BillToDateKWHSensor(coordinator, entry, account)) - - fpl_accounts.append(NetReceivedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(NetDeliveredKWHSensor(coordinator, entry, account)) - - fpl_accounts.append(FplDailyReceivedKWHSensor(coordinator, entry, account)) - fpl_accounts.append(FplDailyDeliveredKWHSensor(coordinator, entry, account)) + for account in accounts: + for sensor in sensors[territory]: + fpl_accounts.append(sensor(coordinator, entry, account)) async_add_devices(fpl_accounts) diff --git a/custom_components/fpl/sensor_ProjectedBillSensor.py b/custom_components/fpl/sensor_ProjectedBillSensor.py index 00f84e0..1897a86 100644 --- a/custom_components/fpl/sensor_ProjectedBillSensor.py +++ b/custom_components/fpl/sensor_ProjectedBillSensor.py @@ -71,3 +71,19 @@ class ProjectedActualBillSensor(FplMoneyEntity): @property def native_value(self): return self.getData("projected_bill") + + +class BillToDateSensor(FplMoneyEntity): + """projeted actual bill sensor""" + + # _attr_state_class = STATE_CLASS_TOTAL + + def __init__(self, coordinator, config, account): + super().__init__(coordinator, config, account, "Bill To Date") + + @property + def native_value(self): + if self.getData("budget_bill"): + return self.getData("budget_billing_bill_to_date") + + return self.getData("bill_to_date") From d9d27f7ae62f90d7dc8e75cb2b488d77e2020c20 Mon Sep 17 00:00:00 2001 From: Yordan Suarez Date: Wed, 17 Aug 2022 18:30:07 -0400 Subject: [PATCH 11/11] added exceptions and get more values from api --- .../fpl/FplMainRegionApiClient.py | 22 +++++------- .../fpl/FplNorthwestRegionApiClient.py | 34 +++++++++++-------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/custom_components/fpl/FplMainRegionApiClient.py b/custom_components/fpl/FplMainRegionApiClient.py index 94d6de9..a6d2250 100644 --- a/custom_components/fpl/FplMainRegionApiClient.py +++ b/custom_components/fpl/FplMainRegionApiClient.py @@ -107,7 +107,7 @@ class FplMainRegionApiClient: try: async with async_timeout.timeout(TIMEOUT): await self.session.get(URL_LOGOUT) - except Exception: + except: pass async def update(self, account) -> dict: @@ -216,7 +216,7 @@ class FplMainRegionApiClient: data["daily_avg"] = dailyAvg data["avg_high_temp"] = avgHighTemp - except Exception: + except: pass return data @@ -225,6 +225,7 @@ class FplMainRegionApiClient: """Get budget billing data""" _LOGGER.info("Getting budget billing data") data = {} + try: async with async_timeout.timeout(TIMEOUT): response = await self.session.get( @@ -257,10 +258,6 @@ class FplMainRegionApiClient: data["budget_billing_projected_bill"] = float(projectedBudgetBill) - except Exception as e: - _LOGGER.error("Error getting BBL: %s", e) - - try: async with async_timeout.timeout(TIMEOUT): response = await self.session.get( URL_BUDGET_BILLING_GRAPH.format(account=account) @@ -269,9 +266,8 @@ class FplMainRegionApiClient: r = (await response.json())["data"] data["bill_to_date"] = float(r["eleAmt"]) data["defered_amount"] = float(r["defAmt"]) - - except Exception as e: - _LOGGER.error("Error getting BBL: %s", e) + except: + pass return data @@ -351,8 +347,8 @@ class FplMainRegionApiClient: r["CurrentUsage"]["dailyAverageKWH"] ) data["billToDateKWH"] = float(r["CurrentUsage"]["billToDateKWH"]) - data["recMtrReading"] = int(r["CurrentUsage"]["recMtrReading"]) - data["delMtrReading"] = int(r["CurrentUsage"]["delMtrReading"]) + data["recMtrReading"] = int(r["CurrentUsage"]["recMtrReading"] or 0) + data["delMtrReading"] = int(r["CurrentUsage"]["delMtrReading"] or 0) data["billStartDate"] = r["CurrentUsage"]["billStartDate"] except: pass @@ -365,6 +361,7 @@ class FplMainRegionApiClient: JSON = {"startDate": str(lastBilledDate.strftime("%m%d%Y"))} data = {} + try: async with async_timeout.timeout(TIMEOUT): response = await self.session.post( @@ -381,8 +378,7 @@ class FplMainRegionApiClient: else: rr = full data[e["category"].replace(" ", "_")] = rr - - except Exception: + except: pass return {"energy_percent_by_applicance": data} diff --git a/custom_components/fpl/FplNorthwestRegionApiClient.py b/custom_components/fpl/FplNorthwestRegionApiClient.py index 6f449ca..9a3a4ec 100644 --- a/custom_components/fpl/FplNorthwestRegionApiClient.py +++ b/custom_components/fpl/FplNorthwestRegionApiClient.py @@ -1,5 +1,5 @@ """FPL Northwest data collection api client""" -from datetime import datetime +from datetime import datetime, timedelta import logging import async_timeout import boto3 @@ -143,25 +143,31 @@ class FplNorthwestRegionApiClient: programInfo = accountSumary["programInfo"] result["budget_bill"] = False - result["bill_to_date"] = billAndMetterInfo["asOfDateAmount"] - result["projected_bill"] = billAndMetterInfo["projBillAmount"] - result["projectedKWH"] = billAndMetterInfo["projBillKWH"] + result["projected_bill"] = float(billAndMetterInfo["projBillAmount"] or 0) + result["projectedKWH"] = int(billAndMetterInfo["projBillKWH"] or 0) - result["bill_to_date"] = billAndMetterInfo["asOfDateUsage"] - result["billToDateKWH"] = billAndMetterInfo["asOfDateUsage"] + result["bill_to_date"] = float(billAndMetterInfo["asOfDateAmount"] or 0) + result["billToDateKWH"] = int(billAndMetterInfo["asOfDateUsage"] or 0) - result["daily_avg"] = billAndMetterInfo["dailyAvgAmount"] - result["dailyAverageKWH"] = billAndMetterInfo["dailyAvgKwh"] + result["daily_avg"] = float(billAndMetterInfo["dailyAvgAmount"] or 0) + result["dailyAverageKWH"] = int(billAndMetterInfo["dailyAvgKwh"] or 0) - result["billStartDate"] = programInfo["currentBillDate"] - result["next_bill_date"] = programInfo["nextBillDate"] + start = datetime.fromisoformat(programInfo["currentBillDate"]) + # + timedelta(days=1) - start = datetime.fromisoformat(result["billStartDate"]) - end = datetime.fromisoformat(result["next_bill_date"]) + end = datetime.fromisoformat(programInfo["nextBillDate"]) today = datetime.fromisoformat(data["today"]) - result["service_days"] = (end - start).days - result["as_of_days"] = (today - start).days + # result["billStartDate"] = programInfo["currentBillDate"] + result["current_bill_date"] = start.strftime("%Y-%m-%d") + result["next_bill_date"] = programInfo["nextBillDate"] + + service_days = (end - start).days + as_of_days = (today - start).days + + result["service_days"] = service_days + result["as_of_days"] = as_of_days + result["remaining_days"] = service_days - as_of_days return result