Merge branch 'release/MulitipleAccountSupport'

This commit is contained in:
Yordan Suarez
2019-12-31 12:30:02 -05:00
7 changed files with 308 additions and 132 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
custom_components/fpl/test.py
custom_components/fpl/__pycache__/
.vscode/launch.json
test.py

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"files.associations": {
"*.yaml": "home-assistant"
}
}

View File

@@ -14,7 +14,9 @@
}, },
"error": { "error": {
"auth": "Username/Password is wrong.", "auth": "Username/Password is wrong.",
"name_exists": "Configuration already exists." "name_exists": "Configuration already exists.",
"invalid_username": "Invalid Username",
"invalid_password": "Invalid Password"
}, },
"abort": { "abort": {
"single_instance_allowed": "Only a single configuration of Fpl is allowed." "single_instance_allowed": "Only a single configuration of Fpl is allowed."

View File

@@ -4,7 +4,15 @@ import voluptuous as vol
from .fplapi import FplApi from .fplapi import FplApi
from homeassistant import config_entries from homeassistant import config_entries
import aiohttp 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 from homeassistant.core import callback
@@ -26,30 +34,36 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Initialize.""" """Initialize."""
self._errors = {} self._errors = {}
async def async_step_user( async def async_step_user(self, user_input={}): # pylint: disable=dangerous-default-value
self, user_input={}
): # pylint: disable=dangerous-default-value
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
self._errors = {} self._errors = {}
# if self._async_current_entries(): if self._async_current_entries():
# return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
# if self.hass.data.get(DOMAIN): if self.hass.data.get(DOMAIN):
# return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
if user_input is not None: if user_input is not None:
username = user_input[CONF_USERNAME] username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD] password = user_input[CONF_PASSWORD]
if username not in configured_instances(self.hass): 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( return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input 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" self._errors["base"] = "auth"
else: else:
self._errors[CONF_NAME] = "name_exists" self._errors[CONF_NAME] = "name_exists"
@@ -84,11 +98,12 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def _test_credentials(self, username, password): async def _test_credentials(self, username, password):
"""Return true if credentials is valid.""" """Return true if credentials is valid."""
session = aiohttp.ClientSession()
try: try:
session = aiohttp.ClientSession() api = FplApi(username, password, None, session)
api = FplApi(username, password, True, None, session) result = await api.login()
await api.login()
return True
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
pass pass
return False
await session.close()
return result

View File

@@ -33,3 +33,11 @@ CONF_PASSWORD = "password"
# Defaults # Defaults
DEFAULT_NAME = DOMAIN DEFAULT_NAME = DOMAIN
# Api login result
LOGIN_RESULT_OK = "OK"
LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER"
LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD"
STATUS_CATEGORY_OPEN = "OPEN"

View File

@@ -1,55 +1,181 @@
import asyncio import asyncio
import logging import logging
import re import re
from datetime import timedelta, date from datetime import timedelta, datetime, date as dt
import aiohttp import aiohttp
import async_timeout import async_timeout
import json
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from .const import STATUS_CATEGORY_OPEN, LOGIN_RESULT_OK
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
TIMEOUT = 5 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): class FplApi(object):
"""A class for getting energy usage information from Florida Power & Light.""" """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.""" """Initialize the data retrieval. Session should have BasicAuth flag set."""
self._username = username self._username = username
self._password = password self._password = password
self._loop = loop self._loop = loop
self._session = session self._session = session
self._is_tou = is_tou
self._account_number = None
self._premise_number = None
self.yesterday_kwh = None async def async_get_mtd_usage(self):
self.yesterday_dollars = None async with async_timeout.timeout(TIMEOUT, loop=self._loop):
self.mtd_kwh = None response = await self._session.get(
self.mtd_dollars = None "https://app.fpl.com/wps/myportal/EsfPortal"
self.projected_bill = None )
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): async def login(self):
"""login and get account information""" """login and get account information"""
async with async_timeout.timeout(TIMEOUT, loop=self._loop): async with async_timeout.timeout(TIMEOUT, loop=self._loop):
response = await self._session.get("https://www.fpl.com/api/resources/login", response = await self._session.get(URL_LOGIN, auth=aiohttp.BasicAuth(self._username, self._password))
auth=aiohttp.BasicAuth(self._username, self._password))
if (await response.json())["messages"][0]["messageCode"] != "login.success": js = json.loads(await response.text())
raise Exception('login failure')
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): async with async_timeout.timeout(TIMEOUT, loop=self._loop):
response = await self._session.get("https://www.fpl.com/api/resources/header") response = await self._session.get(URL_RESOURCES_ACCOUNT.format(account=account))
json = await response.json() accountData = (await response.json())["data"]
self._account_number = json["data"]["selectedAccount"]["data"]["accountNumber"]
self._premise_number = json["data"]["selectedAccount"]["data"]["acctSecSettings"]["premiseNumber"] 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): 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"
# "&currentReading=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: if response.status != 200:
self.data = None self.data = None
@@ -57,63 +183,60 @@ class FplApi(object):
malformedXML = await response.read() malformedXML = await response.read()
cleanerXML = str(malformedXML).replace( cleanerXML = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>', '', 1 str(malformedXML)
).split("<ARG>@@", 1)[0] .replace('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>', "", 1)
.split("<ARG>@@", 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"),
) )
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:
# <set color="E99356" link="j-hourlyJs-001955543,2019/12/29T00:00:00,2019/12/29T00:00:00,8142998577,residential,null,20191206,20200108" tooltext="Day/Date: Dec. 29, 2019 {br}kWh Usage: 42 kWh {br}Approx. Cost: $4.42 {br}Daily High Temp: \xc2\xb0F " value="4.42"></set>
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

View File

@@ -7,7 +7,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant import util from homeassistant import util
from homeassistant.const import CONF_NAME, EVENT_CORE_CONFIG_UPDATE 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_SCANS = timedelta(minutes=1440)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1440) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1440)
@@ -17,47 +17,54 @@ def setup(hass, config):
return True 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 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): class FplSensor(Entity):
def __init__(self, hass, config): def __init__(self, hass, config, account):
self._config = config self._config = config
self.username = config.get(const.CONF_USERNAME) self.username = config.get(const.CONF_USERNAME)
self.password = config.get(const.CONF_PASSWORD) self.password = config.get(const.CONF_PASSWORD)
self._state = 0 self._state = 0
self.loop = hass.loop self.loop = hass.loop
self.api = None
async def _core_config_updated(self, _event): self._account = account
"""Handle core config updated.""" self._data = None
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()
async def async_added_to_hass(self): async def async_added_to_hass(self):
await self.async_update() await self.async_update()
@property @property
def name(self): def unique_id(self):
name = self._config.get(CONF_NAME) """Return the ID of this device."""
if name is not None: return "{}{}".format(self._account, hash(self._account))
return f"{DOMAIN.upper()} {name}"
return DOMAIN @property
def name(self):
return f"{DOMAIN.upper()} {self._account}"
@property @property
def state(self): def state(self):
return self._state return self._data["bill_to_date"]
@property @property
def icon(self): def icon(self):
@@ -65,22 +72,30 @@ class FplSensor(Entity):
@property @property
def state_attributes(self): def state_attributes(self):
return self._data
"""
return { return {
# "yesterday_kwh": self.api.yesterday_kwh, "mtd_kwh": self._data["mtd_kwh"],
# "yesterday_dollars": self.api.yesterday_dollars.replace("$", ""), "bill_to_date": self._data["bill_to_date"],
"mtd_kwh": self.api.mtd_kwh, "projected_bill": self._data["projected_bill"],
"mtd_dollars": self.api.mtd_dollars.replace("$", ""), # "details": self._data["details"],
"projected_bill": self.api.projected_bill, "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) @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_UPDATES)
async def async_update(self): async def async_update(self):
session = aiohttp.ClientSession() session = aiohttp.ClientSession()
api = FplApi(self.username, self.password, True, self.loop, session) try:
await api.login() api = FplApi(self.username, self.password, self.loop, session)
# await api.async_get_yesterday_usage() await api.login()
await api.async_get_mtd_usage() self._data = await api.async_get_data(self._account)
await session.close()
self._state = api.projected_bill except Exception: # pylint: disable=broad-except
self.api = api pass
await session.close()