From 18cf6f6f992158432ceb31c0fe50b1273c33e951 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Sun, 18 Dec 2016 06:23:10 -0500 Subject: [PATCH] Add HydroQuebec support (#4840) --- .coveragerc | 1 + .../components/sensor/hydroquebec.py | 320 ++++++++++++++++++ requirements_all.txt | 1 + 3 files changed, 322 insertions(+) create mode 100644 homeassistant/components/sensor/hydroquebec.py diff --git a/.coveragerc b/.coveragerc index 1a62d7e60f3..42ea738a3ef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -277,6 +277,7 @@ omit = homeassistant/components/sensor/haveibeenpwned.py homeassistant/components/sensor/hddtemp.py homeassistant/components/sensor/hp_ilo.py + homeassistant/components/sensor/hydroquebec.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py new file mode 100644 index 00000000000..c7fbac6b56a --- /dev/null +++ b/homeassistant/components/sensor/hydroquebec.py @@ -0,0 +1,320 @@ +""" +Support for HydroQuebec. + +Get data from 'My Consumption Profile' page: +https://www.hydroquebec.com/portail/en/group/clientele/portrait-de-consommation + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.hydroquebec/ +""" +import logging +from datetime import timedelta + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, + CONF_NAME, CONF_MONITORED_VARIABLES) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['beautifulsoup4==4.5.1'] + +_LOGGER = logging.getLogger(__name__) + +KILOWATT_HOUR = "kWh" # type: str +PRICE = "CAD" # type: str +DAYS = "days" # type: str + +DEFAULT_NAME = "HydroQuebec" + +REQUESTS_TIMEOUT = 15 +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) + +SENSOR_TYPES = { + 'period_total_bill': ['Current period bill', + PRICE, 'mdi:square-inc-cash'], + 'period_length': ['Current period length', + DAYS, 'mdi:calendar-today'], + 'period_total_days': ['Total number of days in this period', + DAYS, 'mdi:calendar-today'], + 'period_mean_daily_bill': ['Period daily average bill', + PRICE, 'mdi:square-inc-cash'], + 'period_mean_daily_consumption': ['Period daily average consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'period_total_consumption': ['Total Consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'period_lower_price_consumption': ['Period Lower price consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'period_higher_price_consumption': ['Period Higher price consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'yesterday_total_consumption': ['Yesterday total consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'yesterday_lower_price_consumption': ['Yesterday lower price consumption', + KILOWATT_HOUR, 'mdi:flash'], + 'yesterday_higher_price_consumption': + ['Yesterday higher price consumption', KILOWATT_HOUR, 'mdi:flash'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +HOST = "https://www.hydroquebec.com" +HOME_URL = "{}/portail/web/clientele/authentification".format(HOST) +PROFILE_URL = ("{}/portail/fr/group/clientele/" + "portrait-de-consommation".format(HOST)) +MONTHLY_MAP = (('period_total_bill', 'montantFacturePeriode'), + ('period_length', 'nbJourLecturePeriode'), + ('period_total_days', 'nbJourPrevuPeriode'), + ('period_mean_daily_bill', 'moyenneDollarsJourPeriode'), + ('period_mean_daily_consumption', 'moyenneKwhJourPeriode'), + ('period_total_consumption', 'consoTotalPeriode'), + ('period_lower_price_consumption', 'consoRegPeriode'), + ('period_higher_price_consumption', 'consoHautPeriode')) +DAILY_MAP = (('yesterday_total_consumption', 'consoTotalQuot'), + ('yesterday_lower_price_consumption', 'consoRegQuot'), + ('yesterday_higher_price_consumption', 'consoHautQuot')) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the HydroQuebec sensor.""" + # Create a data fetcher to support all of the configured sensors. Then make + # the first call to init the data. + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + try: + hydroquebec_data = HydroquebecData(username, password) + hydroquebec_data.update() + except requests.exceptions.HTTPError as error: + _LOGGER.error(error) + return False + + name = config.get(CONF_NAME) + + sensors = [] + for variable in config[CONF_MONITORED_VARIABLES]: + sensors.append(HydroQuebecSensor(hydroquebec_data, variable, name)) + + add_devices(sensors) + + +class HydroQuebecSensor(Entity): + """Implementation of a HydroQuebec sensor.""" + + def __init__(self, hydroquebec_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self.type = sensor_type + self.entity_id = "sensor.{}_{}".format(name, sensor_type) + self._name = SENSOR_TYPES[sensor_type][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._icon = SENSOR_TYPES[sensor_type][2] + self.hydroquebec_data = hydroquebec_data + self._state = None + + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + def update(self): + """Get the latest data from Hydroquebec and update the state.""" + self.hydroquebec_data.update() + self._state = round(self.hydroquebec_data.data[self.type], 2) + + +class HydroquebecData(object): + """Get data from HydroQuebec.""" + + def __init__(self, username, password): + """Initialize the data object.""" + self.username = username + self.password = password + self.data = None + self.cookies = None + + def _get_login_page(self): + """Go to the login page.""" + from bs4 import BeautifulSoup + try: + raw_res = requests.get(HOME_URL, timeout=REQUESTS_TIMEOUT) + except OSError: + _LOGGER.error("Can not connect to login page") + return False + # Get cookies + self.cookies = raw_res.cookies + # Get login url + soup = BeautifulSoup(raw_res.content, 'html.parser') + form_node = soup.find('form', {'name': 'fm'}) + if form_node is None: + _LOGGER.error("No login form find") + return False + login_url = form_node.attrs.get('action') + if login_url is None: + _LOGGER.error("Can not found login url") + return False + return login_url + + def _post_login_page(self, login_url): + """Login to HydroQuebec website.""" + data = {"login": self.username, + "_58_password": self.password} + + try: + raw_res = requests.post(login_url, + data=data, + cookies=self.cookies, + allow_redirects=False, + timeout=REQUESTS_TIMEOUT) + except OSError: + _LOGGER.error("Can not submit login form") + return False + if raw_res.status_code != 302: + _LOGGER.error("Bad HTTP status code") + return False + + # Update cookies + self.cookies.update(raw_res.cookies) + return True + + def _get_p_p_id(self): + """Get id of consumption profile.""" + from bs4 import BeautifulSoup + try: + raw_res = requests.get(PROFILE_URL, + cookies=self.cookies, + timeout=REQUESTS_TIMEOUT) + except OSError: + _LOGGER.error("Can not get profile page") + return False + # Update cookies + self.cookies.update(raw_res.cookies) + # Looking for p_p_id + soup = BeautifulSoup(raw_res.content, 'html.parser') + p_p_id = None + for node in soup.find_all('span'): + node_id = node.attrs.get('id', "") + print(node_id) + if node_id.startswith("p_portraitConsommation_WAR"): + p_p_id = node_id[2:] + break + + if p_p_id is None: + _LOGGER.error("Could not get p_p_id") + return False + + return p_p_id + + def _get_monthly_data(self, p_p_id): + """Get monthly data.""" + params = {"p_p_id": p_p_id, + "p_p_lifecycle": 2, + "p_p_resource_id": ("resourceObtenirDonnees" + "PeriodesConsommation")} + try: + raw_res = requests.get(PROFILE_URL, + params=params, + cookies=self.cookies, + timeout=REQUESTS_TIMEOUT) + except OSError: + _LOGGER.error("Can not get monthly data") + return False + try: + json_output = raw_res.json() + except OSError: + _LOGGER.error("Could not get monthly data") + return False + + if not json_output.get('success'): + _LOGGER.error("Could not get monthly data") + return False + + return json_output.get('results') + + def _get_daily_data(self, p_p_id, start_date, end_date): + """Get daily data.""" + params = {"p_p_id": p_p_id, + "p_p_lifecycle": 2, + "p_p_resource_id": + "resourceObtenirDonneesQuotidiennesConsommation", + "dateDebutPeriode": start_date, + "dateFinPeriode": end_date} + try: + raw_res = requests.get(PROFILE_URL, + params=params, + cookies=self.cookies, + timeout=REQUESTS_TIMEOUT) + except OSError: + _LOGGER.error("Can not get daily data") + return False + try: + json_output = raw_res.json() + except OSError: + _LOGGER.error("Could not get daily data") + return False + + if not json_output.get('success'): + _LOGGER.error("Could not get daily data") + return False + + return json_output.get('results') + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from HydroQuebec.""" + # Get login page + login_url = self._get_login_page() + if not login_url: + return + # Post login page + if not self._post_login_page(login_url): + return + # Get p_p_id + p_p_id = self._get_p_p_id() + if not p_p_id: + return + # Get Monthly data + monthly_data = self._get_monthly_data(p_p_id)[0] + if not monthly_data: + return + # Get daily data + start_date = monthly_data.get('dateDebutPeriode') + end_date = monthly_data.get('dateFinPeriode') + daily_data = self._get_daily_data(p_p_id, start_date, end_date) + if not daily_data: + return + daily_data = daily_data[0]['courant'] + + # format data + self.data = {} + for key1, key2 in MONTHLY_MAP: + self.data[key1] = monthly_data[key2] + for key1, key2 in DAILY_MAP: + self.data[key1] = daily_data[key2] diff --git a/requirements_all.txt b/requirements_all.txt index f39a560f2f8..291ef4813a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,6 +48,7 @@ astral==1.3.3 # homeassistant.components.sensor.linux_battery batinfo==0.4.2 +# homeassistant.components.sensor.hydroquebec # homeassistant.components.sensor.scrape beautifulsoup4==4.5.1