diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e719a55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ + +custom_components/fpl/test.py + +custom_components/fpl/__pycache__/ + +.vscode/launch.json + +test.py 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..29eb42a 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 @@ -26,30 +34,36 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self._errors = {} - async def async_step_user( - self, user_input={} - ): # pylint: disable=dangerous-default-value + async def async_step_user(self, user_input={}): # pylint: disable=dangerous-default-value """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 +98,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 + api = FplApi(username, password, None, session) + 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..657fa59 100644 --- a/custom_components/fpl/fplapi.py +++ b/custom_components/fpl/fplapi.py @@ -1,55 +1,181 @@ 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 +URL_LOGIN = "https://www.fpl.com/api/resources/login" +URL_RESOURCES_HEADER = "https://www.fpl.com/api/resources/header" +URL_RESOURCES_ACCOUNT = "https://www.fpl.com/api/resources/account/{account}" +URL_RESOURCES_PROJECTED_BILL = "https://www.fpl.com/api/resources/account/{account}/projectedBill?premiseNumber={premise}&lastBilledDate={lastBillDate}" + 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 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" + ) + + 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_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("$", "") + ) + + test = soup.find( + class_="bpsusagesmlmnthtxt").getText().strip().split(" - ") + self.start_period = test[0] + self.end_period = test[1] + + 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(URL_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(URL_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): + + data = {} 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_RESOURCES_ACCOUNT.format(account=account)) + accountData = (await response.json())["data"] + + premise = accountData["premiseNumber"].zfill(9) + + # currentBillDate + currentBillDate = datetime.strptime( + accountData["currentBillDate"].replace( + "-", "").split("T")[0], "%Y%m%d" + ).date() + + # nextBillDate + nextBillDate = datetime.strptime( + accountData["nextBillDate"].replace( + "-", "").split("T")[0], "%Y%m%d" + ).date() + + # zip code + zip_code = accountData["serviceAddress"]["zip"] - 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()) + response = await self._session.get(URL_RESOURCES_PROJECTED_BILL.format( + account=account, + premise=premise, + lastBillDate=currentBillDate.strftime("%m%d%Y") + )) - _LOGGER.debug("Response from API: %s", response.status) + projectedBillData = (await response.json())["data"] + + serviceDays = int(projectedBillData["serviceDays"]) + billToDate = float(projectedBillData["billToDate"]) + projectedBill = float(projectedBillData["projectedBill"]) + asOfDays = int(projectedBillData["asOfDays"]) + dailyAvg = float(projectedBillData["dailyAvg"]) + avgHighTemp = int(projectedBillData["avgHighTemp"]) + + url = ( + "https://app.fpl.com/wps/PA_ESFPortalWeb/getDailyConsumption" + f"?premiseNumber={premise}" + f"&startDate={currentBillDate.strftime('%Y%m%d')}" + f"&endDate={dt.today().strftime('%Y%m%d')}" + f"&accountNumber={account}" + # "&accountType=ELE" + f"&zipCode={zip_code}" + "&consumption=0.0" + "&usage=0.0" + "&isMultiMeter=false" + f"&lastAvailableDate={dt.today()}" + # "&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(url) if response.status != 200: self.data = None @@ -57,63 +183,60 @@ class FplApi(object): malformedXML = await response.read() - cleanerXML = str(malformedXML).replace( - '', '', 1 - ).split("@@", 1)[0] - - soup = BeautifulSoup(cleanerXML, 'html.parser') - - 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) - - match2 = re.search(r"\{br\}Approx\. Cost: (\$.*?) \{br\}", tool_text) - if match2: - self.yesterday_dollars = match2.group(1) - - 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") - - 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_dollars = soup \ - .find_all("div", class_="bpbusagebgnd")[1] \ - .find("div", class_="bpbusagedollartxt").getText().strip() - - self.projected_bill = soup.find(id="bpssmlsubcontainer") \ - .find("div", class_="bpsmonthbillbgnd") \ - .find("div", class_="bpsmnthbilldollartxt") \ - .getText().strip().replace("$", "") - - def _build_daily_url(self): - end_date = date.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( - premise_number=self._premise_number, - account_number=self._account_number, - is_tou=str(self._is_tou), - start_date=start_date.strftime("%Y%m%d"), - end_date=end_date.strftime("%Y%m%d"), + 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"] = str(date) + day_detail["usage"] = usage + day_detail["cost"] = cost + day_detail["max_temperature"] = max_temp + + details.append(day_detail) + + remaining_days = serviceDays - asOfDays + avg_kw = round(total_kw / days, 0) + + data["current_bill_date"] = str(currentBillDate) + data["next_bill_date"] = str(nextBillDate) + data["service_days"] = serviceDays + data["bill_to_date"] = billToDate + data["projected_bill"] = projectedBill + data["as_of_days"] = asOfDays + data["daily_avg"] = dailyAvg + data["avg_high_temp"] = avgHighTemp + data["remaining_days"] = remaining_days + data["mtd_kwh"] = total_kw + data["average_kwh"] = avg_kw + + return data diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 1acf304..aed8584 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) @@ -17,47 +17,54 @@ def setup(hass, config): return True -def setup_platform(hass, config, add_devices, discovery_info=None): - setup(hass, config) - add_devices([FplSensor(hass, config)]) - - async def async_setup_entry(hass, config_entry, async_add_entities): - async_add_entities([FplSensor(hass, config_entry.data)]) + username = config_entry.data.get(const.CONF_USERNAME) + password = config_entry.data.get(const.CONF_PASSWORD) + + session = aiohttp.ClientSession() + try: + api = FplApi(username, password, hass.loop, session) + result = await api.login() + + if result == LOGIN_RESULT_OK: + accounts = await api.async_get_open_accounts() + for account in accounts: + async_add_entities( + [FplSensor(hass, config_entry.data, account)]) + pass + + except Exception: # pylint: disable=broad-except + pass + + await session.close() class FplSensor(Entity): - def __init__(self, hass, config): + def __init__(self, hass, config, account): self._config = config self.username = config.get(const.CONF_USERNAME) self.password = config.get(const.CONF_PASSWORD) self._state = 0 self.loop = hass.loop - self.api = None - async def _core_config_updated(self, _event): - """Handle core config updated.""" - print("Core config updated") - # self._init_data() - # if self._unsub_fetch_data: - # self._unsub_fetch_data() - # self._unsub_fetch_data = None - # await self._fetch_data() + self._account = account + self._data = None async def async_added_to_hass(self): await self.async_update() @property - def name(self): - name = self._config.get(CONF_NAME) - if name is not None: - return f"{DOMAIN.upper()} {name}" + def unique_id(self): + """Return the ID of this device.""" + return "{}{}".format(self._account, hash(self._account)) - return DOMAIN + @property + def name(self): + return f"{DOMAIN.upper()} {self._account}" @property def state(self): - return self._state + return self._data["bill_to_date"] @property def icon(self): @@ -65,22 +72,30 @@ class FplSensor(Entity): @property def state_attributes(self): + return self._data + """ return { - # "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("$", ""), - "projected_bill": self.api.projected_bill, + "mtd_kwh": self._data["mtd_kwh"], + "bill_to_date": self._data["bill_to_date"], + "projected_bill": self._data["projected_bill"], + # "details": self._data["details"], + "start_date": self._data["start_date"], + "end_date": self._data["end_date"], + "service_days": self._data["service_days"], + "current_days": self._data["current_days"], + "remaining_days": self._data["remaining_days"], } + """ @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, self.loop, session) + await api.login() + self._data = await api.async_get_data(self._account) - self._state = api.projected_bill - self.api = api + except Exception: # pylint: disable=broad-except + pass + + await session.close()