diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2afc79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +custom_components/fpl/test.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fba2e38 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + //"program": "/workspaces/hass-fpl/custom_components/fpl/test.py", + "program": "E:/projects/hass-fpl/custom_components/fpl/test.py", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a04b218 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/custom_components/fpl/.translations/en.json b/custom_components/fpl/.translations/en.json index 800ba50..aa2923d 100644 --- a/custom_components/fpl/.translations/en.json +++ b/custom_components/fpl/.translations/en.json @@ -14,7 +14,9 @@ }, "error": { "auth": "Username/Password is wrong.", - "name_exists": "Configuration already exists." + "name_exists": "Configuration already exists.", + "invalid_username": "Invalid Username", + "invalid_password": "Invalid Password" }, "abort": { "single_instance_allowed": "Only a single configuration of Fpl is allowed." diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index c7cecd8..e248778 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -4,7 +4,15 @@ import voluptuous as vol from .fplapi import FplApi from homeassistant import config_entries import aiohttp -from .const import DOMAIN, CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from .const import ( + DOMAIN, + CONF_USERNAME, + CONF_PASSWORD, + CONF_NAME, + LOGIN_RESULT_OK, + LOGIN_RESULT_INVALIDUSER, + LOGIN_RESULT_INVALIDPASSWORD, +) from homeassistant.core import callback @@ -32,24 +40,32 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" self._errors = {} - # if self._async_current_entries(): - # return self.async_abort(reason="single_instance_allowed") - # if self.hass.data.get(DOMAIN): - # return self.async_abort(reason="single_instance_allowed") + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if self.hass.data.get(DOMAIN): + return self.async_abort(reason="single_instance_allowed") if user_input is not None: username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] if username not in configured_instances(self.hass): - valid = await self._test_credentials(username, password) + result = await self._test_credentials(username, password) - if valid: + if result == LOGIN_RESULT_OK: return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) - else: + + if result == LOGIN_RESULT_INVALIDUSER: + self._errors[CONF_USERNAME] = "invalid_username" + + if result == LOGIN_RESULT_INVALIDPASSWORD: + self._errors[CONF_PASSWORD] = "invalid_password" + + if result == None: self._errors["base"] = "auth" + else: self._errors[CONF_NAME] = "name_exists" @@ -84,11 +100,12 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _test_credentials(self, username, password): """Return true if credentials is valid.""" + session = aiohttp.ClientSession() try: - session = aiohttp.ClientSession() api = FplApi(username, password, True, None, session) - await api.login() - return True + result = await api.login() except Exception: # pylint: disable=broad-except pass - return False + + await session.close() + return result diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index 1c00d96..dc1c6aa 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -33,3 +33,11 @@ CONF_PASSWORD = "password" # Defaults DEFAULT_NAME = DOMAIN + +# Api login result +LOGIN_RESULT_OK = "OK" +LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER" +LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD" + + +STATUS_CATEGORY_OPEN = "OPEN" diff --git a/custom_components/fpl/fplapi.py b/custom_components/fpl/fplapi.py index 08d649c..8a8e48e 100644 --- a/custom_components/fpl/fplapi.py +++ b/custom_components/fpl/fplapi.py @@ -1,11 +1,15 @@ import asyncio import logging import re -from datetime import timedelta, date +from datetime import timedelta, datetime, date as dt import aiohttp import async_timeout +import json + + from bs4 import BeautifulSoup +from const import STATUS_CATEGORY_OPEN, LOGIN_RESULT_OK _LOGGER = logging.getLogger(__name__) TIMEOUT = 5 @@ -14,40 +18,190 @@ TIMEOUT = 5 class FplApi(object): """A class for getting energy usage information from Florida Power & Light.""" - def __init__(self, username, password, is_tou, loop, session): + def __init__(self, username, password, loop, session): """Initialize the data retrieval. Session should have BasicAuth flag set.""" self._username = username self._password = password self._loop = loop self._session = session - self._is_tou = is_tou - self._account_number = None - self._premise_number = None - - self.yesterday_kwh = None - self.yesterday_dollars = None - self.mtd_kwh = None - self.mtd_dollars = None - self.projected_bill = None async def login(self): """login and get account information""" async with async_timeout.timeout(TIMEOUT, loop=self._loop): - response = await self._session.get("https://www.fpl.com/api/resources/login", - auth=aiohttp.BasicAuth(self._username, self._password)) + response = await self._session.get( + "https://www.fpl.com/api/resources/login", + auth=aiohttp.BasicAuth(self._username, self._password), + ) - if (await response.json())["messages"][0]["messageCode"] != "login.success": - raise Exception('login failure') + js = json.loads(await response.text()) + + if response.reason == "Unauthorized": + return js["messageCode"] + + if js["messages"][0]["messageCode"] != "login.success": + raise Exception("login failure") + + return LOGIN_RESULT_OK + + async def async_get_open_accounts(self): + async with async_timeout.timeout(TIMEOUT, loop=self._loop): + response = await self._session.get( + "https://www.fpl.com/api/resources/header" + ) + js = await response.json() + accounts = js["data"]["accounts"]["data"]["data"] + + result = [] + + for account in accounts: + if account["statusCategory"] == STATUS_CATEGORY_OPEN: + result.append(account["accountNumber"]) + # print(account["accountNumber"]) + # print(account["premiseNumber"]) + # print(account["statusCategory"]) + + # self._account_number = js["data"]["selectedAccount"]["data"]["accountNumber"] + # self._premise_number = js["data"]["selectedAccount"]["data"]["acctSecSettings"]["premiseNumber"] + return result + + async def async_get_data(self, account): + # await self.async_get_yesterday_usage() + # await self.async_get_mtd_usage() + async with async_timeout.timeout(TIMEOUT, loop=self._loop): + response = await self._session.get( + "https://www.fpl.com/api/resources/account/" + account + ) + data = (await response.json())["data"] + + premise = data["premiseNumber"].zfill(9) + print(premise) + + # print(data["nextBillDate"].replace("-", "").split("T")[0]) + # print(data["currentBillDate"].replace("-", "").split("T")[0]) + + start_date = datetime.strptime( + data["currentBillDate"].replace("-", "").split("T")[0], "%Y%m%d").date() + end_date = dt.today() + + last_day = datetime.strptime( + data["nextBillDate"].replace("-", "").split("T")[0], "%Y%m%d").date() + + lasting_days = (last_day - dt.today()).days + zip_code = data["serviceAddress"]["zip"] + + url = ( + "https://app.fpl.com/wps/PA_ESFPortalWeb/getDailyConsumption" + f"?premiseNumber={premise}" + f"&startDate={start_date.strftime('%Y%m%d')}" + f"&endDate={end_date.strftime('%Y%m%d')}" + f"&accountNumber={account}" + # "&accountType=ELE" + f"&zipCode={zip_code}" + "&consumption=0.0" + "&usage=0.0" + "&isMultiMeter=false" + f"&lastAvailableDate={end_date}" + # "&isAmiMeter=true" + "&userType=EXT" + # "¤tReading=64359" + "&isResidential=true" + # "&isTouUser=false" + "&showGroupData=false" + # "&isNetMeter=false" + "&certifiedDate=1900/01/01" + # "&acctNetMeter=false" + "&tempType=max" + "&viewType=dollar" + "&ecDayHumType=NoHum" + # "&ecHasMoveInRate=false" + # "&ecMoveInRateVal=" + # "&lastAvailableIeeDate=20191230" + ) async with async_timeout.timeout(TIMEOUT, loop=self._loop): - response = await self._session.get("https://www.fpl.com/api/resources/header") - json = await response.json() - self._account_number = json["data"]["selectedAccount"]["data"]["accountNumber"] - self._premise_number = json["data"]["selectedAccount"]["data"]["acctSecSettings"]["premiseNumber"] + response = await self._session.get(url) + + if response.status != 200: + self.data = None + return + + malformedXML = await response.read() + + cleanerXML = ( + str(malformedXML) + .replace('', "", 1) + .split("@@", 1)[0] + ) + + total_kw = 0 + total_cost = 0 + days = 0 + + soup = BeautifulSoup(cleanerXML, "html.parser") + items = soup.find("dataset", seriesname="$").find_all("set") + + details = [] + + for item in items: + # + match = re.search( + r"Date: (\w\w\w. \d\d, \d\d\d\d).*\{br\}kWh Usage: (.*?) kWh \{br\}.*Cost:\s\$([\w|.]+).*Temp:\s(\d+)", + str(item), + ) + if match: + date = datetime.strptime(match.group(1), "%b. %d, %Y").date() + usage = int(match.group(2)) + cost = float(match.group(3)) + max_temp = int(match.group(4)) + if usage == 0: + cost = 0 + + total_kw += usage + total_cost += cost + days += 1 + + day_detail = {} + day_detail["date"] = date + day_detail["usage"] = usage + day_detail["cost"] = cost + day_detail["max_temperature"] = max_temp + + details.append(day_detail) + + print(date) + print(usage) + print(cost) + print(max_temp) + + print("TOTALS") + print(total_kw) + print(total_cost) + + print("Average") + avg_cost = round(total_cost / days, 2) + print(avg_cost) + avg_kw = round(total_kw / days, 0) + print(avg_kw) + + print("Projected") + projected_cost = round(total_cost + avg_cost * lasting_days, 2) + print(projected_cost) + + data = {} + data["start_date"] = start_date + data["end_date"] = end_date + data["service_days"] = (end_date - start_date).days + data["current_days"] = days + data["remaining_days"] = lasting_days + data["details"] = details + + return data + pass async def async_get_yesterday_usage(self): async with async_timeout.timeout(TIMEOUT, loop=self._loop): - response = await self._session.get(self._build_daily_url()) + url = self._build_daily_url() + response = await self._session.get(url) _LOGGER.debug("Response from API: %s", response.status) @@ -57,60 +211,80 @@ class FplApi(object): malformedXML = await response.read() - cleanerXML = str(malformedXML).replace( - '', '', 1 - ).split("@@", 1)[0] + cleanerXML = ( + str(malformedXML) + .replace('', "", 1) + .split("@@", 1)[0] + ) - soup = BeautifulSoup(cleanerXML, 'html.parser') + soup = BeautifulSoup(cleanerXML, "html.parser") - tool_text = soup.find("dataset", seriesname="$") \ - .find("set")["tooltext"] + tool_text = soup.find("dataset", seriesname="$").find("set")[ + "tooltext"] match = re.search(r"\{br\}kWh Usage: (.*?) kWh \{br\}", tool_text) if match: - self.yesterday_kwh = match.group(1) + self.yesterday_kwh = match.group(1).replace("$", "") match2 = re.search(r"\{br\}Approx\. Cost: (\$.*?) \{br\}", tool_text) if match2: - self.yesterday_dollars = match2.group(1) + self.yesterday_dollars = match2.group(1).replace("$", "") async def async_get_mtd_usage(self): async with async_timeout.timeout(TIMEOUT, loop=self._loop): response = await self._session.get( - "https://app.fpl.com/wps/myportal/EsfPortal") + "https://app.fpl.com/wps/myportal/EsfPortal" + ) - soup = BeautifulSoup(await response.text(), 'html.parser') + soup = BeautifulSoup(await response.text(), "html.parser") - self.mtd_kwh = soup.find(id="bpbsubcontainer") \ - .find("table", class_="bpbtab_style_bill", width=430) \ - .find_all("div", class_="bpbtabletxt")[-1].string + self.mtd_kwh = ( + soup.find(id="bpbsubcontainer") + .find("table", class_="bpbtab_style_bill", width=430) + .find_all("div", class_="bpbtabletxt")[-1] + .string + ) - self.mtd_dollars = soup \ - .find_all("div", class_="bpbusagebgnd")[1] \ - .find("div", class_="bpbusagedollartxt").getText().strip() + self.mtd_dollars = ( + soup.find_all("div", class_="bpbusagebgnd")[1] + .find("div", class_="bpbusagedollartxt") + .getText() + .strip() + .replace("$", "") + ) - self.projected_bill = soup.find(id="bpssmlsubcontainer") \ - .find("div", class_="bpsmonthbillbgnd") \ - .find("div", class_="bpsmnthbilldollartxt") \ - .getText().strip().replace("$", "") + self.projected_bill = ( + soup.find(id="bpssmlsubcontainer") + .find("div", class_="bpsmonthbillbgnd") + .find("div", class_="bpsmnthbilldollartxt") + .getText() + .strip() + .replace("$", "") + ) + + test = soup.find( + class_="bpsusagesmlmnthtxt").getText().strip().split(" - ") + self.start_period = test[0] + self.end_period = test[1] def _build_daily_url(self): - end_date = date.today() + end_date = dt.today() start_date = end_date - timedelta(days=1) - return ("https://app.fpl.com/wps/PA_ESFPortalWeb/getDailyConsumption" - "?premiseNumber={premise_number}" - "&accountNumber={account_number}" - "&isTouUser={is_tou}" - "&startDate={start_date}" - "&endDate={end_date}" - "&userType=EXT" - "&isResidential=true" - "&certifiedDate=2000/01/01" - "&viewType=dollar" - "&tempType=max" - "&ecDayHumType=NoHum" - ).format( + return ( + "https://app.fpl.com/wps/PA_ESFPortalWeb/getDailyConsumption" + "?premiseNumber={premise_number}" + "&accountNumber={account_number}" + "&isTouUser={is_tou}" + "&startDate={start_date}" + "&endDate={end_date}" + "&userType=EXT" + "&isResidential=true" + "&certifiedDate=2000/01/01" + "&viewType=dollar" + "&tempType=max" + "&ecDayHumType=NoHum" + ).format( premise_number=self._premise_number, account_number=self._account_number, is_tou=str(self._is_tou), diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 1acf304..ffe4ebd 100644 --- a/custom_components/fpl/sensor.py +++ b/custom_components/fpl/sensor.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity import Entity from homeassistant import util from homeassistant.const import CONF_NAME, EVENT_CORE_CONFIG_UPDATE -from .const import DOMAIN, ICON +from .const import DOMAIN, ICON, LOGIN_RESULT_OK MIN_TIME_BETWEEN_SCANS = timedelta(minutes=1440) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1440) @@ -24,6 +24,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities([FplSensor(hass, config_entry.data)]) + print("setup entry") + + username = config_entry.data.get(const.CONF_USERNAME) + password = config_entry.data.get(const.CONF_PASSWORD) + + session = aiohttp.ClientSession() + try: + api = FplApi(username, password, True, hass.loop, session) + result = await api.login() + + if result == LOGIN_RESULT_OK: + await api.async_get_headers() + pass + + except Exception: # pylint: disable=broad-except + pass + + await session.close() class FplSensor(Entity): @@ -69,18 +87,22 @@ class FplSensor(Entity): # "yesterday_kwh": self.api.yesterday_kwh, # "yesterday_dollars": self.api.yesterday_dollars.replace("$", ""), "mtd_kwh": self.api.mtd_kwh, - "mtd_dollars": self.api.mtd_dollars.replace("$", ""), + "mtd_dollars": self.api.mtd_dollars, "projected_bill": self.api.projected_bill, } @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_UPDATES) async def async_update(self): session = aiohttp.ClientSession() - api = FplApi(self.username, self.password, True, self.loop, session) - await api.login() - # await api.async_get_yesterday_usage() - await api.async_get_mtd_usage() - await session.close() + try: + api = FplApi(self.username, self.password, True, self.loop, session) + await api.login() + # await api.async_get_yesterday_usage() + await api.async_get_mtd_usage() + self._state = api.projected_bill + self.api = api - self._state = api.projected_bill - self.api = api + except Exception: # pylint: disable=broad-except + pass + + await session.close()