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.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 @@ 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.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 @@ async def async_step_user( 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 = info["accounts"] - accounts = await api.async_get_open_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 @@ 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_TERRITORY = API_HOST + "/cs/customer/v1/territoryid/public/territory" -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" -) +FPL_MAINREGION = "FL01" +FPL_NORTHWEST = "FL02" -ENROLLED = "ENROLLED" -NOTENROLLED = "NOTENROLLED" +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) - async def async_get_data(self) -> dict: - """Get data from fpl api""" - data = {} - data["accounts"] = [] - if await self.login() == LOGIN_RESULT_OK: - accounts = await self.async_get_open_accounts() - - data["accounts"] = accounts - for account in accounts: - account_data = await self.__async_get_data(account) - data[account] = account_data - - await self.logout() - return data - - async def login(self): - """login into fpl""" - _LOGGER.info("Logging in") - # login and get account information - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get( - URL_LOGIN, auth=aiohttp.BasicAuth(self._username, self._password) - ) - - if response.status == 200: - _LOGGER.info("Logging Successful") - return LOGIN_RESULT_OK - - 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 - - except Exception as exception: - _LOGGER.error("Error %s : %s", exception, sys.exc_info()[0]) - return LOGIN_RESULT_FAILURE - - return LOGIN_RESULT_FAILURE - - 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 = [] + if response.status == 200: + json_data = json.loads(await response.text()) - try: - async with async_timeout.timeout(TIMEOUT): - response = await self._session.get(URL_RESOURCES_HEADER) + territoryArray = json_data["data"]["territory"] + if len(territoryArray) == 0: + raise NoTerrytoryAvailableException() - json_data = await response.json() - accounts = json_data["data"]["accounts"]["data"]["data"] + self._territory = territoryArray[0] + return territoryArray[0] - for account in accounts: - if account["statusCategory"] == STATUS_CATEGORY_OPEN: - result.append(account["accountNumber"]) + def isMainRegion(self): + """Returns true if this account belongs to the main region, not northwest""" + return self._territory == FPL_MAINREGION - except Exception: - _LOGGER.error("Getting accounts %s", sys.exc_info()) + async def initialize(self): + """initialize the api client""" + self._territory = await self.getTerritory() - return result + # 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 __async_get_data(self, account) -> dict: - """Get data from resources endpoint""" - _LOGGER.info("Getting Data") + 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() - 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"), - ) - ) + 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"] = [] - if response.status == 200: + data["territory"] = self._territory - projectedBillData = (await response.json())["data"] + print(self._territory) - billToDate = float(projectedBillData["billToDate"]) - projectedBill = float(projectedBillData["projectedBill"]) - dailyAvg = float(projectedBillData["dailyAvg"]) - avgHighTemp = int(projectedBillData["avgHighTemp"]) + login_result = await self.apiClient.login() - data["bill_to_date"] = billToDate - data["projected_bill"] = projectedBill - data["daily_avg"] = dailyAvg - data["avg_high_temp"] = avgHighTemp + if login_result == LOGIN_RESULT_OK: + accounts = await self.apiClient.get_open_accounts() - except Exception: - pass + data["accounts"] = accounts + for account in accounts: + data[account] = await self.apiClient.update(account) + await self.apiClient.logout() 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) - + async def login(self): + """method to use in config flow""" 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"]) + await self.initialize() - except Exception as e: - _LOGGER.error("Error getting BBL: %s", e) + _LOGGER.info("Logging in") + # login and get account information - 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 = {} + return await self.apiClient.login() - # 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 + except Exception as exception: + _LOGGER.error("Error %s : %s", exception, sys.exc_info()[0]) + return LOGIN_RESULT_FAILURE - async def __getDataFromApplianceUsage(self, account, lastBilledDate) -> dict: - """get data from appliance usage""" - _LOGGER.info("Getting data from appliance usage") + async def async_get_open_accounts(self): + """return open accounts""" + self.initialize() + return await self.apiClient.get_open_accounts() - 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} + async def logout(self): + """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 @@ def __init__(self, coordinator, config, account): 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 @@ def state(self): 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 @@ def __init__(self, coordinator, config, account): 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 @@ def __init__(self, coordinator, config, account): 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 @@ def __init__(self, coordinator, config, account): 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 @@ def state(self): 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 @@ def customAttributes(self): 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 @@ def customAttributes(self): 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 @@ 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 @@ 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 @@ def customAttributes(self): 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 @@ 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 @@ def customAttributes(self): 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 @@ def native_value(self): 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 @@ def native_value(self): 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 @@ DEVICE_CLASS_ENERGY, ) from homeassistant.core import callback +from homeassistant.const import STATE_UNKNOWN from .fplEntity import FplEnergyEntity @@ -24,20 +25,26 @@ def native_value(self): 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