diff --git a/.devcontainer/custom_component_helper b/.devcontainer/custom_component_helper index 189d759..5a18a9a 100644 --- a/.devcontainer/custom_component_helper +++ b/.devcontainer/custom_component_helper @@ -1,26 +1,8 @@ #!/usr/bin/env bash - -function StartHomeAssistant { echo "Copy configuration.yaml" cp -f .devcontainer/configuration.yaml /config || echo ".devcontainer/configuration.yaml are missing!" exit 1 - echo "Copy the custom component" rm -R /config/custom_components/ || echo "" cp -r custom_components /config/custom_components/ || echo "Could not copy the custom_component" exit 1 - echo "Start Home Assistant" - hass -c /config -} - -function UpdgradeHomeAssistantDev { - python -m pip install --upgrade git+https://github.com/home-assistant/home-assistant@dev -} - -function SetHomeAssistantVersion { - read -p 'Version: ' version - python -m pip install --upgrade homeassistant==$version -} - -function HomeAssistantConfigCheck { - hass -c /config --script check_config -} \ No newline at end of file + hass -c /config \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 31504d9..f0c4579 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Start Home Assistant on port 8124", "type": "shell", - "command": "source .devcontainer/custom_component_helper && StartHomeAssistant", + "command": "source .devcontainer/custom_component_helper", "group": { "kind": "test", "isDefault": true, diff --git a/custom_components/fpl/.translations/en.json b/custom_components/fpl/.translations/en.json new file mode 100644 index 0000000..914d6b6 --- /dev/null +++ b/custom_components/fpl/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Fpl", + "step": { + "user": { + "title": "Florida Power & Light", + "description": "If you need help with the configuration have a look here: https://github.com/custom-components/blueprint", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "auth": "Username/Password is wrong." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Fpl is allowed." + } + } +} \ No newline at end of file diff --git a/custom_components/fpl/.translations/nb.json b/custom_components/fpl/.translations/nb.json new file mode 100644 index 0000000..3b47f66 --- /dev/null +++ b/custom_components/fpl/.translations/nb.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Blueprint", + "step": { + "user": { + "title": "Blueprint", + "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/custom-components/blueprint", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + }, + "error": { + "auth": "Brukernavn/Passord er feil." + }, + "abort": { + "single_instance_allowed": "Du kan konfigurere Blueprint kun en gang." + } + } +} \ No newline at end of file diff --git a/custom_components/fpl/.translations/sensor.nb.json b/custom_components/fpl/.translations/sensor.nb.json new file mode 100644 index 0000000..ed34ceb --- /dev/null +++ b/custom_components/fpl/.translations/sensor.nb.json @@ -0,0 +1,5 @@ +{ + "state": { + "Some sample static text.": "Eksempel tekst." + } +} \ No newline at end of file diff --git a/custom_components/fpl/__init__.py b/custom_components/fpl/__init__.py new file mode 100644 index 0000000..9107f10 --- /dev/null +++ b/custom_components/fpl/__init__.py @@ -0,0 +1 @@ +""" FPL Component """ diff --git a/custom_components/fpl/config_flow.py b/custom_components/fpl/config_flow.py new file mode 100644 index 0000000..a5243d6 --- /dev/null +++ b/custom_components/fpl/config_flow.py @@ -0,0 +1,84 @@ +from collections import OrderedDict + +import voluptuous as vol +from .fplapi import FplApi +from homeassistant import config_entries +import aiohttp +from .const import DOMAIN + + +@config_entries.HANDLERS.register(DOMAIN) +class FplFlowHandler(config_entries.ConfigFlow): + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + 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 user_input is not None: + valid = await self._test_credentials( + user_input["username"], user_input["password"] + ) + if valid: + return self.async_create_entry(title="", data=user_input) + else: + self._errors["base"] = "auth" + + return await self._show_config_form(user_input) + + return await self._show_config_form(user_input) + + async def _show_config_form(self, user_input): + """Show the configuration form to edit location data.""" + + # Defaults + username = "" + password = "" + + if user_input is not None: + if "username" in user_input: + username = user_input["username"] + if "password" in user_input: + password = user_input["password"] + + data_schema = OrderedDict() + data_schema[vol.Required("username", default=username)] = str + data_schema[vol.Required("password", default=password)] = str + return self.async_show_form( + step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors + ) + + 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") + + return self.async_create_entry(title="configuration.yaml", data={}) + + async def _test_credentials(self, username, password): + """Return true if credentials is valid.""" + try: + # client = Client(username, password) + # client.get_data() + session = aiohttp.ClientSession() + api = FplApi(username, password, True, None, session) + await api.login() + return True + except Exception: # pylint: disable=broad-except + pass + return False diff --git a/custom_components/fpl/const.py b/custom_components/fpl/const.py new file mode 100644 index 0000000..5f35a52 --- /dev/null +++ b/custom_components/fpl/const.py @@ -0,0 +1,35 @@ +"""Constants for fpl.""" +# Base component constants +DOMAIN = "fpl" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.1" +PLATFORMS = ["binary_sensor", "sensor", "switch"] +REQUIRED_FILES = [ + ".translations/en.json", + "binary_sensor.py", + "const.py", + "config_flow.py", + "manifest.json", + "sensor.py", + "switch.py", +] +ISSUE_URL = "https://github.com/dotKrad/hass-fpl/issues" +ATTRIBUTION = "Data from this is provided by blueprint." + +# Icons +ICON = "mdi:format-quote-close" + +# Device classes +BINARY_SENSOR_DEVICE_CLASS = "connectivity" + +# Configuration +CONF_BINARY_SENSOR = "binary_sensor" +CONF_SENSOR = "sensor" +CONF_SWITCH = "switch" +CONF_ENABLED = "enabled" +CONF_NAME = "name" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" + +# Defaults +DEFAULT_NAME = DOMAIN diff --git a/custom_components/fpl/fplapi.py b/custom_components/fpl/fplapi.py new file mode 100644 index 0000000..08d649c --- /dev/null +++ b/custom_components/fpl/fplapi.py @@ -0,0 +1,119 @@ +import asyncio +import logging +import re +from datetime import timedelta, date + +import aiohttp +import async_timeout +from bs4 import BeautifulSoup + +_LOGGER = logging.getLogger(__name__) +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): + """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)) + + if (await response.json())["messages"][0]["messageCode"] != "login.success": + raise Exception('login failure') + + 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"] + + 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()) + + _LOGGER.debug("Response from API: %s", response.status) + + if response.status != 200: + self.data = None + return + + 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"), + ) diff --git a/custom_components/fpl/manifest.json b/custom_components/fpl/manifest.json new file mode 100644 index 0000000..a81ca8a --- /dev/null +++ b/custom_components/fpl/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "fpl", + "name": "FPL", + "documentation": "https://github.com/custom-components/blueprint", + "dependencies": [], + "config_flow": true, + "codeowners": [ + "@dotKrad" + ], + "requirements": [ + "bs4", + "integrationhelper" + ], + "homeassistant": "0.96.0" +} \ No newline at end of file diff --git a/custom_components/fpl/sensor.py b/custom_components/fpl/sensor.py new file mode 100644 index 0000000..8b9a0db --- /dev/null +++ b/custom_components/fpl/sensor.py @@ -0,0 +1,60 @@ +from homeassistant import const +from datetime import datetime, timedelta +from .fplapi import FplApi +import aiohttp +import asyncio +from homeassistant.helpers.entity import Entity +from homeassistant import util + +MIN_TIME_BETWEEN_SCANS = timedelta(minutes=1440) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1440) + + +def setup(hass, config): + return True + + +def setup_platform(hass, config, add_devices, discovery_info=None): + setup(hass, config) + add_devices([FplSensor(hass, config)]) + + +class FplSensor(Entity): + def __init__(self, hass, config): + print("init") + self.username = config.get(const.CONF_USERNAME) + self.password = config.get(const.CONF_PASSWORD) + self._state = 0 + self.loop = hass.loop + self.api = None + + @property + def name(self): + """Returns the name of the sensor.""" + return "fpl" + + @property + def state(self): + return self._state + + @property + def state_attributes(self): + 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, + } + + @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() + + self._state = api.projected_bill + self.api = api