diff --git a/custom_components/fpl/AverageDailySensor.py b/custom_components/fpl/AverageDailySensor.py new file mode 100644 index 0000000..dd2026e --- /dev/null +++ b/custom_components/fpl/AverageDailySensor.py @@ -0,0 +1,15 @@ +from .FplSensor import FplSensor + + +class FplAverageDailySensor(FplSensor): + def __init__(self, hass, config, account): + FplSensor.__init__(self, hass, config, account, "Average Daily") + + @property + def state(self): + return self.data["daily_avg"] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self.attr \ No newline at end of file diff --git a/custom_components/fpl/DailyUsageSensor.py b/custom_components/fpl/DailyUsageSensor.py new file mode 100644 index 0000000..8895fee --- /dev/null +++ b/custom_components/fpl/DailyUsageSensor.py @@ -0,0 +1,16 @@ +from .FplSensor import FplSensor + + +class FplDailyUsageSensor(FplSensor): + def __init__(self, hass, config, account): + FplSensor.__init__(self, hass, config, account, "Daily Usage") + + @property + def state(self): + return self.data["daily_usage"][-1]["cost"] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + self.attr["date"] = self.data["daily_usage"][-1]["date"] + return self.attr diff --git a/custom_components/fpl/FplSensor.py b/custom_components/fpl/FplSensor.py new file mode 100644 index 0000000..ab7b061 --- /dev/null +++ b/custom_components/fpl/FplSensor.py @@ -0,0 +1,68 @@ +from homeassistant.helpers.entity import Entity +from homeassistant import util +from .const import DOMAIN, DOMAIN_DATA, ATTRIBUTION +from datetime import timedelta + +MIN_TIME_BETWEEN_SCANS = timedelta(minutes=30) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=60) + + +class FplSensor(Entity): + def __init__(self, hass, config, account, sensorName): + self._config = config + self._state = None + self.loop = hass.loop + + self._account = account + self.attr = {} + self.data = None + self.sensorName = sensorName + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update the sensor.""" + # Send update "signal" to the component + await self.hass.data[DOMAIN_DATA]["client"].update_data() + + # Get new data (if any) + if "data" in self.hass.data[DOMAIN_DATA]: + self.data = self.hass.data[DOMAIN_DATA]["data"][self._account] + + # Set/update attributes + self.attr["attribution"] = ATTRIBUTION + + async def async_added_to_hass(self): + await self.async_update() + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._account)}, + "name": f"Account {self._account}", + "manufacturer": "Florida Power & Light", + } + + @property + def unique_id(self): + """Return the ID of this device.""" + id = "{}{}{}".format( + DOMAIN, self._account, self.sensorName.lower().replace(" ", "") + ) + return id + + @property + def name(self): + return f"{DOMAIN.upper()} {self._account} {self.sensorName}" + + @property + def state(self): + return self._state + + @property + def icon(self): + return "mdi:flash" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self.attr \ No newline at end of file diff --git a/custom_components/fpl/ProjectedBillSensor.py b/custom_components/fpl/ProjectedBillSensor.py new file mode 100644 index 0000000..f37653f --- /dev/null +++ b/custom_components/fpl/ProjectedBillSensor.py @@ -0,0 +1,31 @@ +from .FplSensor import FplSensor + + +class FplProjectedBillSensor(FplSensor): + def __init__(self, hass, config, account): + FplSensor.__init__(self, hass, config, account, "Projected Bill") + + @property + def state(self): + data = self.data + state = None + if "budget_bill" in data.keys(): + if data["budget_bill"]: + if "budget_billing_projected_bill" in data.keys(): + state = data["budget_billing_projected_bill"] + else: + if "projected_bill" in data.keys(): + state = data["projected_bill"] + + return state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if "budget_bill" in self.data.keys(): + self.attr["budget_bill"] = self.data["budget_bill"] + return self.attr + + @property + def icon(self): + return "mdi:currency-usd" \ No newline at end of file diff --git a/custom_components/fpl/__init__.py b/custom_components/fpl/__init__.py index d35fc03..dea2e9f 100644 --- a/custom_components/fpl/__init__.py +++ b/custom_components/fpl/__init__.py @@ -1,18 +1,62 @@ """ FPL Component """ - +import logging +from datetime import timedelta from homeassistant.core import Config, HomeAssistant +from homeassistant.util import Throttle +from .fplapi import FplApi +from .const import DOMAIN_DATA, CONF_USERNAME, CONF_PASSWORD + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) from .config_flow import FplFlowHandler from .const import DOMAIN +class FplData: + """This class handle communication and stores the data.""" + + def __init__(self, hass, client): + """Initialize the class.""" + self.hass = hass + self.client = client + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update_data(self): + """Update data.""" + # This is where the main logic to update platform data goes. + try: + data = await self.client.get_data() + self.hass.data[DOMAIN_DATA]["data"] = data + except Exception as error: # pylint: disable=broad-except + _LOGGER.error("Could not update data - %s", error) + + async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up configured Fpl.""" return True async def async_setup_entry(hass, config_entry): + + # Get "global" configuration. + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + + # Create DATA dict + hass.data[DOMAIN_DATA] = {} + + # Configure the client. + _LOGGER.info(f"Configuring the client") + client = FplApi(username, password, hass.loop) + fplData = FplData(hass, client) + + await fplData.update_data() + + hass.data[DOMAIN_DATA]["client"] = fplData + """Set up Fpl as config entry.""" hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, "sensor") diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py index 29eb42a..6102283 100644 --- a/custom_components/fpl/config_flow.py +++ b/custom_components/fpl/config_flow.py @@ -2,17 +2,17 @@ from collections import OrderedDict 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 + +from .fplapi import ( LOGIN_RESULT_OK, LOGIN_RESULT_INVALIDUSER, LOGIN_RESULT_INVALIDPASSWORD, ) + from homeassistant.core import callback @@ -34,7 +34,9 @@ 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 = {} @@ -48,12 +50,16 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = user_input[CONF_PASSWORD] if username not in configured_instances(self.hass): - result = await self._test_credentials(username, password) + api = FplApi(username, password, None) + result = await api.login() if result == LOGIN_RESULT_OK: - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) + fplData = await api.get_data() + accounts = fplData["accounts"] + + user_input["accounts"] = accounts + + return self.async_create_entry(title="", data=user_input) if result == LOGIN_RESULT_INVALIDUSER: self._errors[CONF_USERNAME] = "invalid_username" @@ -77,18 +83,14 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Defaults username = "" password = "" - name = "Home" if user_input is not None: if CONF_USERNAME in user_input: username = user_input[CONF_USERNAME] if CONF_PASSWORD in user_input: password = user_input[CONF_PASSWORD] - if CONF_NAME in user_input: - name = user_input[CONF_NAME] data_schema = OrderedDict() - data_schema[vol.Required(CONF_NAME, default=name)] = str data_schema[vol.Required(CONF_USERNAME, default=username)] = str data_schema[vol.Required(CONF_PASSWORD, default=password)] = str @@ -96,14 +98,12 @@ class FplFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors ) - async def _test_credentials(self, username, password): - """Return true if credentials is valid.""" - session = aiohttp.ClientSession() - try: - api = FplApi(username, password, None, session) - result = await api.login() - except Exception: # pylint: disable=broad-except - pass + async def async_step_import(self, user_input): # pylint: disable=unused-argument + """Import a config entry. + Special type of import, we're not actually going to store any data. + Instead, we're going to rely on the values that are in config file. + """ + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") - await session.close() - return result + return self.async_create_entry(title="configuration.yaml", data={}) diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py index dc1c6aa..419b370 100644 --- a/custom_components/fpl/const.py +++ b/custom_components/fpl/const.py @@ -16,9 +16,6 @@ REQUIRED_FILES = [ ISSUE_URL = "https://github.com/dotKrad/hass-fpl/issues" ATTRIBUTION = "Data from this is provided by FPL." -# Icons -ICON = "mdi:flash" - # Device classes BINARY_SENSOR_DEVICE_CLASS = "connectivity" @@ -32,12 +29,4 @@ CONF_USERNAME = "username" 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" +DEFAULT_NAME = DOMAIN \ No newline at end of file diff --git a/custom_components/fpl/fplapi.py b/custom_components/fpl/fplapi.py index 7801954..36e3e51 100644 --- a/custom_components/fpl/fplapi.py +++ b/custom_components/fpl/fplapi.py @@ -9,7 +9,12 @@ import json from bs4 import BeautifulSoup -from .const import STATUS_CATEGORY_OPEN, LOGIN_RESULT_OK + +STATUS_CATEGORY_OPEN = "OPEN" +# Api login result +LOGIN_RESULT_OK = "OK" +LOGIN_RESULT_INVALIDUSER = "NOTVALIDUSER" +LOGIN_RESULT_INVALIDPASSWORD = "FAILEDPASSWORD" _LOGGER = logging.getLogger(__name__) TIMEOUT = 5 @@ -26,31 +31,59 @@ NOTENROLLED = "NOTENROLLED" class FplApi(object): """A class for getting energy usage information from Florida Power & Light.""" - def __init__(self, username, password, loop, session): + def __init__(self, username, password, loop): """Initialize the data retrieval. Session should have BasicAuth flag set.""" self._username = username self._password = password self._loop = loop - self._session = session + self._session = None + + async def get_data(self): + self._session = aiohttp.ClientSession() + data = {} + await self.login() + accounts = await self.async_get_open_accounts() + + data["accounts"] = accounts + for account in accounts: + accountData = await self.__async_get_data(account) + data[account] = accountData + + await self._session.close() + + return data async def login(self): + if self._session is not None: + session = self._session + close = False + else: + session = aiohttp.ClientSession() + close = True + _LOGGER.info("Logging") """login and get account information""" async with async_timeout.timeout(TIMEOUT, loop=self._loop): - response = await self._session.get( + response = await session.get( URL_LOGIN, auth=aiohttp.BasicAuth(self._username, self._password) ) js = json.loads(await response.text()) if response.reason == "Unauthorized": + await session.close() raise Exception(js["messageCode"]) if js["messages"][0]["messageCode"] != "login.success": _LOGGER.error(f"Logging Failure") + await session.close() raise Exception("login failure") _LOGGER.info(f"Logging Successful") + + if close: + await session.close() + return LOGIN_RESULT_OK async def async_get_open_accounts(self): @@ -71,7 +104,7 @@ class FplApi(object): # self._premise_number = js["data"]["selectedAccount"]["data"]["acctSecSettings"]["premiseNumber"] return result - async def async_get_data(self, account): + async def __async_get_data(self, account): _LOGGER.info(f"Getting Data") data = {} @@ -108,7 +141,7 @@ class FplApi(object): zip_code = accountData["serviceAddress"]["zip"] # projected bill - pbData = await self.getFromProjectedBill(account, premise, currentBillDate) + pbData = await self.__getFromProjectedBill(account, premise, currentBillDate) data.update(pbData) # programs @@ -124,17 +157,17 @@ class FplApi(object): if programs["BBL"]: # budget billing data["budget_bill"] = True - bblData = await self.getBBL_async(account, data) + bblData = await self.__getBBL_async(account, data) data.update(bblData) data.update( - await self.getDataFromEnergyService(account, premise, currentBillDate) + await self.__getDataFromEnergyService(account, premise, currentBillDate) ) - data.update(await self.getDataFromApplianceUsage(account, currentBillDate)) + data.update(await self.__getDataFromApplianceUsage(account, currentBillDate)) return data - async def getFromProjectedBill(self, account, premise, currentBillDate): + async def __getFromProjectedBill(self, account, premise, currentBillDate): async with async_timeout.timeout(TIMEOUT, loop=self._loop): response = await self._session.get( URL_RESOURCES_PROJECTED_BILL.format( @@ -161,7 +194,7 @@ class FplApi(object): return [] - async def getBBL_async(self, account, projectedBillData): + async def __getBBL_async(self, account, projectedBillData): _LOGGER.info(f"Getting budget billing data") data = {} @@ -179,7 +212,7 @@ class FplApi(object): projectedBill = projectedBillData["projected_bill"] asOfDays = projectedBillData["as_of_days"] - + for det in dataList: billingCharge += det["actuallBillAmt"] @@ -206,7 +239,7 @@ class FplApi(object): return data - async def getDataFromEnergyService(self, account, premise, lastBilledDate): + async def __getDataFromEnergyService(self, account, premise, lastBilledDate): _LOGGER.info(f"Getting data from energy service") URL = "https://www.fpl.com/dashboard-api/resources/account/{account}/energyService/{account}" @@ -252,7 +285,7 @@ class FplApi(object): return [] - async def getDataFromApplianceUsage(self, account, lastBilledDate): + async def __getDataFromApplianceUsage(self, account, lastBilledDate): _LOGGER.info(f"Getting data from applicance usage") URL = "https://www.fpl.com/dashboard-api/resources/account/{account}/applianceUsage/{account}" JSON = {"startDate": str(lastBilledDate.strftime("%m%d%Y"))} diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py index 63e35f4..9a1b66f 100644 --- a/custom_components/fpl/sensor.py +++ b/custom_components/fpl/sensor.py @@ -15,8 +15,12 @@ from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, + ATTR_FRIENDLY_NAME, ) -from .const import DOMAIN, ICON, LOGIN_RESULT_OK +from .const import DOMAIN, DOMAIN_DATA, ATTRIBUTION +from .DailyUsageSensor import FplDailyUsageSensor +from .AverageDailySensor import FplAverageDailySensor +from .ProjectedBillSensor import FplProjectedBillSensor _LOGGER = logging.getLogger(__name__) @@ -29,38 +33,25 @@ def setup(hass, config): async def async_setup_entry(hass, config_entry, async_add_entities): - username = config_entry.data.get(CONF_USERNAME) - password = config_entry.data.get(CONF_PASSWORD) - session = aiohttp.ClientSession() - try: - api = FplApi(username, password, hass.loop, session) - result = await api.login() + accounts = config_entry.data.get("accounts") - fpl_accounts = [] + fpl_accounts = [] - if result == LOGIN_RESULT_OK: - accounts = await api.async_get_open_accounts() - for account in accounts: - _LOGGER.info(f"Adding fpl account: {account}") - fpl_accounts.append(FplSensor(hass, config_entry.data, account)) + for account in accounts: + _LOGGER.info(f"Adding fpl account: {account}") + fpl_accounts.append(FplSensor(hass, config_entry.data, account)) + fpl_accounts.append(FplDailyUsageSensor(hass, config_entry.data, account)) + fpl_accounts.append(FplAverageDailySensor(hass, config_entry.data, account)) + fpl_accounts.append(FplProjectedBillSensor(hass, config_entry.data, account)) - async_add_entities(fpl_accounts) - except Exception as e: # pylint: disable=broad-except - _LOGGER.error(f"Adding fpl accounts: {str(e)}") - async_call_later( - hass, 15, async_setup_entry(hass, config_entry, async_add_entities) - ) - - await session.close() + async_add_entities(fpl_accounts) class FplSensor(Entity): def __init__(self, hass, config, account): self._config = config - self.username = config.get(CONF_USERNAME) - self.password = config.get(CONF_PASSWORD) - self._state = STATE_UNKNOWN + self._state = None self.loop = hass.loop self._account = account @@ -69,6 +60,14 @@ class FplSensor(Entity): async def async_added_to_hass(self): await self.async_update() + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._account)}, + "name": f"Account {self._account}", + "manufacturer": "Florida Power & Light", + } + @property def unique_id(self): """Return the ID of this device.""" @@ -101,7 +100,7 @@ class FplSensor(Entity): @property def icon(self): - return ICON + return "mdi:flash" @property def state_attributes(self): @@ -109,16 +108,13 @@ class FplSensor(Entity): @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_UPDATES) async def async_update(self): - try: - session = aiohttp.ClientSession() - api = FplApi(self.username, self.password, self.loop, session) - await api.login() - data = await api.async_get_data(self._account) + # Send update "signal" to the component + await self.hass.data[DOMAIN_DATA]["client"].update_data() + + # Get new data (if any) + if "data" in self.hass.data[DOMAIN_DATA]: + data = self.hass.data[DOMAIN_DATA]["data"][self._account] + if data != {}: self._data = data - - except Exception as e: # pylint: disable=broad-except - _LOGGER.warning(f"Error ocurred during update: { str(e)}") - - finally: - await session.close() + self._data["attribution"] = ATTRIBUTION \ No newline at end of file