diff --git a/README.md b/README.md index d36fc31..b27855e 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,12 @@ Graphing water consumption is also nice. Note that the data returned by Grohe's ## Installation - Ensure everything is set up and working in Grohe's Ondus app - Copy this folder to `/custom_components/grohe_sense/` -- Go to https://idp2-apigw.cloud.grohe.com/v3/iot/oidc/login -- Bring up developer tools -- Log in, that'll try redirecting your browser with a 302 to an url starting with `ondus://idp2-apigw.cloud.grohe.com/v3/iot/oidc/token`, which an off-the-shelf Chrome will ignore -- You should see this failed redirect in your developer tools. Copy out the full URL and replace `ondus` with `https` and visit that URL (will likely only work once, and will expire, so don't be too slow). -- This gives you a json response. Save it and extract refresh_token from it (manually, or `jq .refresh_token < file.json`) Put the following in your home assistant config (N.B., format has changed, this component is no longer configured as a sensor platform) ``` grohe_sense: - refresh_token: "YOUR_VERY_VERY_LONG_REFRESH_TOKEN" + username: "YOUR_GROHE_EMAIL" + password: "YOUR_GROHE_PASSWORD" ``` ## Remarks on the "API" diff --git a/__init__.py b/__init__.py index e4d3351..6d8a2d8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,9 @@ import logging import asyncio import collections +import requests +from lxml import html +import json import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -11,12 +14,14 @@ DOMAIN = 'grohe_sense' -CONF_REFRESH_TOKEN = 'refresh_token' +CONF_USERNAME = 'username' +CONF_PASSWORD = 'password' CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema({ - vol.Required(CONF_REFRESH_TOKEN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, }), }, extra=vol.ALLOW_EXTRA, @@ -29,18 +34,53 @@ GroheDevice = collections.namedtuple('GroheDevice', ['locationId', 'roomId', 'applianceId', 'type', 'name']) +async def get_token(session, username, password): + try: + response = await session.get(BASE_URL + 'oidc/login') + except Exception as e: + _LOGGER.error('Get Refresh Token Exception %s', str(e)) + else: + cookie = response.cookies + tree = html.fromstring(await response.text()) + + name = tree.xpath("//html/body/div/div/div/div/div/div/div/form") + action = name[0].action + + payload = { + 'username': username, + 'password': password, + 'Content-Type': 'application/x-www-form-urlencoded', + 'origin': BASE_URL, + 'referer': BASE_URL + 'oidc/login', + 'X-Requested-With': 'XMLHttpRequest', + } + try: + response = await session.post(url = action, data = payload, cookies = cookie, allow_redirects=False) + except Exception as e: + _LOGGER.error('Get Refresh Token Action Exception %s', str(e)) + else: + ondus_url = response.headers['Location'].replace('ondus', 'https') + try: + response = await session.get(url = ondus_url, cookies = cookie) + except Exception as e: + _LOGGER.error('Get Refresh Token Response Exception %s', str(e)) + else: + response_json = json.loads(await response.text()) + + return response_json['refresh_token'] + async def async_setup(hass, config): _LOGGER.debug("Loading Grohe Sense") - await initialize_shared_objects(hass, config.get(DOMAIN).get(CONF_REFRESH_TOKEN)) + await initialize_shared_objects(hass, config.get(DOMAIN).get(CONF_USERNAME), config.get(DOMAIN).get(CONF_PASSWORD)) await hass.helpers.discovery.async_load_platform('sensor', DOMAIN, {}, config) await hass.helpers.discovery.async_load_platform('switch', DOMAIN, {}, config) return True -async def initialize_shared_objects(hass, refresh_token): +async def initialize_shared_objects(hass, username, password): session = aiohttp_client.async_get_clientsession(hass) - auth_session = OauthSession(session, refresh_token) + auth_session = OauthSession(session, username, password, await get_token(session, username, password)) devices = [] hass.data[DOMAIN] = { 'session': auth_session, 'devices': devices } @@ -65,8 +105,10 @@ def __init__(self, error_code, reason): self.reason = reason class OauthSession: - def __init__(self, session, refresh_token): + def __init__(self, session, username, password, refresh_token): self._session = session + self._username = username + self._password = password self._refresh_token = refresh_token self._access_token = None self._fetching_new_token = None @@ -126,7 +168,8 @@ async def _http_request(self, url, method='get', auth_token=None, headers={}, ** token = await auth_token.token(token) else: _LOGGER.error('Grohe sense refresh token is invalid (or expired), please update your configuration with a new refresh token') - raise OauthException(response.status, await response.text()) + self._refresh_token = get_token(self._session, self._username, self._password) + token = await self.token(token) else: _LOGGER.debug('Request to %s returned status %d, %s', url, response.status, await response.text()) except OauthException as oe: diff --git a/manifest.json b/manifest.json index f24cf14..feae5b0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,6 @@ { "domain": "grohe_sense", + "version": "0.0.1", "name": "Grohe Sense", "documentation": "https://github.com/gkreitz/homeassistant-grohe_sense", "dependencies": [], diff --git a/sensor.py b/sensor.py index 5537518..ef30a90 100644 --- a/sensor.py +++ b/sensor.py @@ -5,7 +5,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.const import (STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, DEVICE_CLASS_HUMIDITY, VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, PRESSURE_MBAR, DEVICE_CLASS_PRESSURE, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, VOLUME_LITERS) +from homeassistant.const import (STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, DEVICE_CLASS_HUMIDITY, VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, PRESSURE_BAR, DEVICE_CLASS_PRESSURE, TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, VOLUME_LITERS) from homeassistant.helpers import aiohttp_client @@ -21,7 +21,7 @@ 'temperature': SensorType(TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, lambda x : x), 'humidity': SensorType(PERCENTAGE, DEVICE_CLASS_HUMIDITY, lambda x : x), 'flowrate': SensorType(VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, None, lambda x : x * 3.6), - 'pressure': SensorType(PRESSURE_MBAR, DEVICE_CLASS_PRESSURE, lambda x : x * 1000), + 'pressure': SensorType(PRESSURE_BAR, DEVICE_CLASS_PRESSURE, lambda x : x * 1000), 'temperature_guard': SensorType(TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, lambda x : x), } @@ -119,7 +119,8 @@ def parse_time(s): return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') poll_from=self._poll_from.strftime('%Y-%m-%d') - measurements_response = await self._auth_session.get(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/data?from={poll_from}') + measurements_response = await self._auth_session.get(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/data/aggregated?from={poll_from}') + _LOGGER.debug('Data read: %s', measurements_response['data']) if 'withdrawals' in measurements_response['data']: withdrawals = measurements_response['data']['withdrawals'] _LOGGER.debug('Received %d withdrawals in response', len(withdrawals)) @@ -137,12 +138,13 @@ def parse_time(s): if 'measurement' in measurements_response['data']: measurements = measurements_response['data']['measurement'] - measurements.sort(key = lambda x: x['timestamp']) + measurements.sort(key = lambda x: x['date']) if len(measurements): for key in SENSOR_TYPES_PER_UNIT[self._type]: + _LOGGER.debug('key: %s', key) if key in measurements[-1]: self._measurements[key] = measurements[-1][key] - self._poll_from = max(self._poll_from, parse_time(measurements[-1]['timestamp'])) + self._poll_from = datetime.strptime(measurements[-1]['date'], '%Y-%m-%d') else: _LOGGER.info('Data response for appliance %s did not contain any measurements data', self._applianceId)