├── manifest.json ├── LICENSE ├── .gitignore ├── switch.py ├── README.md ├── __init__.py └── sensor.py /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "grohe_sense", 3 | "name": "Grohe Sense", 4 | "documentation": "https://github.com/gkreitz/homeassistant-grohe_sense", 5 | "dependencies": [], 6 | "requirements": [], 7 | "codeowners": ["@gkreitz"] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 gkreitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import (timedelta) 3 | from homeassistant.components.switch import SwitchEntity 4 | 5 | from homeassistant.helpers.entity import Entity 6 | from homeassistant.util import Throttle 7 | from homeassistant.const import (STATE_UNKNOWN) 8 | 9 | from . import (DOMAIN, BASE_URL, GROHE_SENSE_TYPE, GROHE_SENSE_GUARD_TYPE) 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | VALVE_UPDATE_DELAY = timedelta(minutes=1) 14 | 15 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 16 | _LOGGER.debug("Starting Grohe Sense valve switch") 17 | auth_session = hass.data[DOMAIN]['session'] 18 | entities = [] 19 | 20 | for device in filter(lambda d: d.type == GROHE_SENSE_GUARD_TYPE, hass.data[DOMAIN]['devices']): 21 | entities.append(GroheSenseGuardValve(auth_session, device.locationId, device.roomId, device.applianceId, device.name)) 22 | if entities: 23 | async_add_entities(entities) 24 | 25 | 26 | class GroheSenseGuardValve(SwitchEntity): 27 | def __init__(self, auth_session, locationId, roomId, applianceId, name): 28 | self._auth_session = auth_session 29 | self._locationId = locationId 30 | self._roomId = roomId 31 | self._applianceId = applianceId 32 | self._name = name 33 | self._is_on = STATE_UNKNOWN 34 | 35 | @property 36 | def name(self): 37 | return '{} valve'.format(self._name) 38 | 39 | @property 40 | def is_on(self): 41 | return self._is_on 42 | 43 | @property 44 | def icon(self): 45 | return 'mdi:water' 46 | 47 | @property 48 | def device_class(self): 49 | return 'switch' 50 | 51 | @Throttle(VALVE_UPDATE_DELAY) 52 | async def async_update(self): 53 | command_response = await self._auth_session.get(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/command') 54 | if 'command' in command_response and 'valve_open' in command_response['command']: 55 | self._is_on = command_response['command']['valve_open'] 56 | else: 57 | _LOGGER.error('Failed to parse out valve_open from commands response: %s', command_response) 58 | 59 | async def _set_state(self, state): 60 | data = { 'type': GROHE_SENSE_GUARD_TYPE, 'command': { 'valve_open': state } } 61 | command_response = await self._auth_session.post(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/command', data) 62 | if 'command' in command_response and 'valve_open' in command_response['command']: 63 | self._is_on = command_response['command']['valve_open'] 64 | else: 65 | _LOGGER.warning('Got unknown response back when setting valve state: %s', command_response) 66 | 67 | async def async_turn_on(self, **kwargs): 68 | _LOGGER.info('Turning on water for %s', self._name) 69 | await self._set_state(True) 70 | 71 | async def async_turn_off(self, **kwargs): 72 | _LOGGER.info('Turning off water for %s', self._name) 73 | await self._set_state(False) 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homeassistant-grohe_sense 2 | Grohe Sense integration for Home Assistant 3 | 4 | This is an integration to get Grohe Sense (small leak sensor) and Grohe Sense Guard (main water pipe sensor/breaker) sensors into Home Assistant. Far from production quality, not affiliated with Grohe. My understanding of the protocol is based on https://github.com/FlorianSW/grohe-ondus-api-java. 5 | 6 | When you install this, you get the following sensors for Sense: 7 | - **humidity** 8 | - **temperature** 9 | - **notifications** 10 | 11 | It's a small, battery-powered device, so don't expect frequent updates. It seems to measure every hour, but the app also said it only uploads every 24h. The sensors I implemented only give the latest measurement returned from the server. 12 | 13 | When you install this, you get the following sensors for each Sense Guard (subject to change, still haven't figured out what makes sense really): 14 | - **1_day** Liters of water withdrawn today (resets to 0 at midnight) 15 | - **7_day** Liters of water withdrawn during the last 144 hours. 16 | - **flowrate** 17 | - **pressure** 18 | - **temperature_guard** 19 | - **notifications** 20 | 21 | You will also get a switch device (so, be careful with `group.all_switches`, as that now includes your water) called 22 | - **valve** 23 | 24 | The Sense Guard uploads data to its server every 15 minutes (at least the one I have), so don't expect to use this for anything close to real-time. For water withdrawals, it seems to report the withdrawal only when it ends, so if you continuously withdraw water, I guess those sensors may stay at 0. Hopefully, that would show up in the flowrate sensor. 25 | 26 | The notifications sensor is a string of all your unread notifications (newline-separated). I recommend installing the Grohe Sense app, where there is a UI to read them (so they disappear from this sensor). On first start, you may find you have a lot of old unread notifications. The notifications I know how to parse are listed in `NOTIFICATION_TYPES` in `sensor.py`, if the API returns something unknown, it will be shown as `Unknown notification:` and then a json dump. If you see that, please consider submitting a bug report with the `category` and `type` fields from the Json + some description of what it means (can be found by finding the corresponding notification in the Grohe Sense app). 27 | 28 | ## Automation ideas 29 | - Turning water off when you're away (and dishwasher, washer, et.c. are not running) and turning it back on when home again. 30 | - Turning water off when non-Grohe sensors detect water. 31 | - Passing along notifications from Grohe sense to Slack (note that there is a polling delay, plus unknown delay between device and Grohe's cloud) 32 | - Send Slack notification when your alarm is armed away and flowrate is >0 (controlling for the high latency, plus dishwashers, ice makers, et.c.). 33 | 34 | Graphing water consumption is also nice. Note that the data returned by Grohe's servers is extremely detailed, so for nicer graphs, you may want to talk to the servers directly and access the json data, rather than go via this integration. 35 | 36 | ## Installation 37 | - Ensure everything is set up and working in Grohe's Ondus app 38 | - Copy this folder to `/custom_components/grohe_sense/` 39 | - Go to https://idp2-apigw.cloud.grohe.com/v3/iot/oidc/login 40 | - Bring up developer tools 41 | - 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 42 | - 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). 43 | - This gives you a json response. Save it and extract refresh_token from it (manually, or `jq .refresh_token < file.json`) 44 | 45 | Put the following in your home assistant config (N.B., format has changed, this component is no longer configured as a sensor platform) 46 | ``` 47 | grohe_sense: 48 | refresh_token: "YOUR_VERY_VERY_LONG_REFRESH_TOKEN" 49 | ``` 50 | 51 | ## Remarks on the "API" 52 | I have not seen any documentation from Grohe on the API this integration is using, so likely it was only intended for their app. Breaking changes have happened previously, and can easily happen again. I make no promises that I'll maintain this module when that happens. 53 | 54 | The API returns _much_ more detailed data than is exposed via these sensors. For withdrawals, it returns an exact start- and endtime for each withdrawal, as well as volume withdrawn. It seems to store data since the water meter was installed, so you can extract a lot of historic data (but then polling gets a bit slow). I'm not aware of any good way to expose time series data like this in home assistant (suddenly I learn that 2 liters was withdrawn 5 minutes ago, and 5 liters was withdrawn 2 minutes ago). If anyone has any good ideas/pointers, that'd be appreciated. 55 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import collections 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | import voluptuous as vol 7 | 8 | from homeassistant.helpers import aiohttp_client 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | DOMAIN = 'grohe_sense' 13 | 14 | CONF_REFRESH_TOKEN = 'refresh_token' 15 | 16 | CONFIG_SCHEMA = vol.Schema( 17 | { 18 | DOMAIN: vol.Schema({ 19 | vol.Required(CONF_REFRESH_TOKEN): cv.string, 20 | }), 21 | }, 22 | extra=vol.ALLOW_EXTRA, 23 | ) 24 | 25 | BASE_URL = 'https://idp2-apigw.cloud.grohe.com/v3/iot/' 26 | 27 | GROHE_SENSE_TYPE = 101 # Type identifier for the battery powered water detector 28 | GROHE_SENSE_GUARD_TYPE = 103 # Type identifier for sense guard, the water guard installed on your water pipe 29 | 30 | GroheDevice = collections.namedtuple('GroheDevice', ['locationId', 'roomId', 'applianceId', 'type', 'name']) 31 | 32 | async def async_setup(hass, config): 33 | _LOGGER.debug("Loading Grohe Sense") 34 | 35 | await initialize_shared_objects(hass, config.get(DOMAIN).get(CONF_REFRESH_TOKEN)) 36 | 37 | await hass.helpers.discovery.async_load_platform('sensor', DOMAIN, {}, config) 38 | await hass.helpers.discovery.async_load_platform('switch', DOMAIN, {}, config) 39 | return True 40 | 41 | async def initialize_shared_objects(hass, refresh_token): 42 | session = aiohttp_client.async_get_clientsession(hass) 43 | auth_session = OauthSession(session, refresh_token) 44 | devices = [] 45 | 46 | hass.data[DOMAIN] = { 'session': auth_session, 'devices': devices } 47 | 48 | locations = await auth_session.get(BASE_URL + f'locations') 49 | for location in locations: 50 | _LOGGER.debug('Found location %s', location) 51 | locationId = location['id'] 52 | rooms = await auth_session.get(BASE_URL + f'locations/{locationId}/rooms') 53 | for room in rooms: 54 | _LOGGER.debug('Found room %s', room) 55 | roomId = room['id'] 56 | appliances = await auth_session.get(BASE_URL + f'locations/{locationId}/rooms/{roomId}/appliances') 57 | for appliance in appliances: 58 | _LOGGER.debug('Found appliance %s', appliance) 59 | applianceId = appliance['appliance_id'] 60 | devices.append(GroheDevice(locationId, roomId, applianceId, appliance['type'], appliance['name'])) 61 | 62 | class OauthException(Exception): 63 | def __init__(self, error_code, reason): 64 | self.error_code = error_code 65 | self.reason = reason 66 | 67 | class OauthSession: 68 | def __init__(self, session, refresh_token): 69 | self._session = session 70 | self._refresh_token = refresh_token 71 | self._access_token = None 72 | self._fetching_new_token = None 73 | 74 | @property 75 | def session(self): 76 | return self._session 77 | 78 | async def token(self, old_token=None): 79 | """ Returns an authorization header. If one is supplied as old_token, invalidate that one """ 80 | if self._access_token not in (None, old_token): 81 | return self._access_token 82 | 83 | if self._fetching_new_token is not None: 84 | await self._fetching_new_token.wait() 85 | return self._access_token 86 | 87 | self._access_token = None 88 | self._fetching_new_token = asyncio.Event() 89 | data = { 'refresh_token': self._refresh_token } 90 | headers = { 'Content-Type': 'application/json' } 91 | 92 | refresh_response = await self._http_request(BASE_URL + 'oidc/refresh', 'post', headers=headers, json=data) 93 | if not 'access_token' in refresh_response: 94 | _LOGGER.error('OAuth token refresh did not yield access token! Got back %s', refresh_response) 95 | else: 96 | self._access_token = 'Bearer ' + refresh_response['access_token'] 97 | 98 | self._fetching_new_token.set() 99 | self._fetching_new_token = None 100 | return self._access_token 101 | 102 | async def get(self, url, **kwargs): 103 | return await self._http_request(url, auth_token=self, **kwargs) 104 | 105 | async def post(self, url, json, **kwargs): 106 | return await self._http_request(url, method='post', auth_token=self, json=json, **kwargs) 107 | 108 | async def _http_request(self, url, method='get', auth_token=None, headers={}, **kwargs): 109 | _LOGGER.debug('Making http %s request to %s, headers %s', method, url, headers) 110 | headers = headers.copy() 111 | tries = 0 112 | while True: 113 | if auth_token != None: 114 | # Cache token so we know which token was used for this request, 115 | # so we know if we need to invalidate. 116 | token = await auth_token.token() 117 | headers['Authorization'] = token 118 | try: 119 | async with self._session.request(method, url, headers=headers, **kwargs) as response: 120 | _LOGGER.debug('Http %s request to %s got response %d', method, url, response.status) 121 | if response.status in (200, 201): 122 | return await response.json() 123 | elif response.status == 401: 124 | if auth_token != None: 125 | _LOGGER.debug('Request to %s returned status %d, refreshing auth token', url, response.status) 126 | token = await auth_token.token(token) 127 | else: 128 | _LOGGER.error('Grohe sense refresh token is invalid (or expired), please update your configuration with a new refresh token') 129 | raise OauthException(response.status, await response.text()) 130 | else: 131 | _LOGGER.debug('Request to %s returned status %d, %s', url, response.status, await response.text()) 132 | except OauthException as oe: 133 | raise 134 | except Exception as e: 135 | _LOGGER.debug('Exception for http %s request to %s: %s', method, url, e) 136 | 137 | tries += 1 138 | await asyncio.sleep(min(600, 2**tries)) 139 | 140 | -------------------------------------------------------------------------------- /sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import collections 4 | from datetime import (datetime, timezone, timedelta) 5 | 6 | from homeassistant.helpers.entity import Entity 7 | from homeassistant.util import Throttle 8 | 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) 9 | 10 | from homeassistant.helpers import aiohttp_client 11 | 12 | from . import (DOMAIN, BASE_URL, GROHE_SENSE_TYPE, GROHE_SENSE_GUARD_TYPE) 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | SensorType = collections.namedtuple('SensorType', ['unit', 'device_class', 'function']) 18 | 19 | 20 | SENSOR_TYPES = { 21 | 'temperature': SensorType(TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, lambda x : x), 22 | 'humidity': SensorType(PERCENTAGE, DEVICE_CLASS_HUMIDITY, lambda x : x), 23 | 'flowrate': SensorType(VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, None, lambda x : x * 3.6), 24 | 'pressure': SensorType(PRESSURE_MBAR, DEVICE_CLASS_PRESSURE, lambda x : x * 1000), 25 | 'temperature_guard': SensorType(TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, lambda x : x), 26 | } 27 | 28 | SENSOR_TYPES_PER_UNIT = { 29 | GROHE_SENSE_TYPE: [ 'temperature', 'humidity'], 30 | GROHE_SENSE_GUARD_TYPE: [ 'flowrate', 'pressure', 'temperature_guard'] 31 | } 32 | 33 | NOTIFICATION_UPDATE_DELAY = timedelta(minutes=1) 34 | 35 | NOTIFICATION_TYPES = { # The protocol returns notification information as a (category, type) tuple, this maps to strings 36 | (10,60) : 'Firmware update available', 37 | (10,460) : 'Firmware update available', 38 | (20,11) : 'Battery low', 39 | (20,12) : 'Battery empty', 40 | (20,20) : 'Below temperature threshold', 41 | (20,21) : 'Above temperature threshold', 42 | (20,30) : 'Below humidity threshold', 43 | (20,31) : 'Above humidity threshold', 44 | (20,40) : 'Frost warning', 45 | (20,80) : 'Lost wifi', 46 | (20,320) : 'Unusual water consumption (water shut off)', 47 | (20,321) : 'Unusual water consumption (water not shut off)', 48 | (20,330) : 'Micro leakage', 49 | (20,340) : 'Frost warning', 50 | (20,380) : 'Lost wifi', 51 | (30,0) : 'Flooding', 52 | (30,310) : 'Pipe break', 53 | (30,400) : 'Maximum volume reached', 54 | (30,430) : 'Sense detected water (water shut off)', 55 | (30,431) : 'Sense detected water (water not shut off)', 56 | } 57 | 58 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 59 | _LOGGER.debug("Starting Grohe Sense sensor") 60 | 61 | if DOMAIN not in hass.data or 'devices' not in hass.data[DOMAIN]: 62 | _LOGGER.error("Did not find shared objects. You may need to update your configuration (this module should no longer be configured under sensor).") 63 | return 64 | 65 | auth_session = hass.data[DOMAIN]['session'] 66 | 67 | entities = [] 68 | for device in hass.data[DOMAIN]['devices']: 69 | reader = GroheSenseGuardReader(auth_session, device.locationId, device.roomId, device.applianceId, device.type) 70 | entities.append(GroheSenseNotificationEntity(auth_session, device.locationId, device.roomId, device.applianceId, device.name)) 71 | if device.type in SENSOR_TYPES_PER_UNIT: 72 | entities += [GroheSenseSensorEntity(reader, device.name, key) for key in SENSOR_TYPES_PER_UNIT[device.type]] 73 | if device.type == GROHE_SENSE_GUARD_TYPE: # The sense guard also gets sensor entities for water flow 74 | entities.append(GroheSenseGuardWithdrawalsEntity(reader, device.name, 1)) 75 | entities.append(GroheSenseGuardWithdrawalsEntity(reader, device.name, 7)) 76 | else: 77 | _LOGGER.warning('Unrecognized appliance %s, ignoring.', device) 78 | if entities: 79 | async_add_entities(entities) 80 | 81 | class GroheSenseGuardReader: 82 | def __init__(self, auth_session, locationId, roomId, applianceId, device_type): 83 | self._auth_session = auth_session 84 | self._locationId = locationId 85 | self._roomId = roomId 86 | self._applianceId = applianceId 87 | self._type = device_type 88 | 89 | self._withdrawals = [] 90 | self._measurements = {} 91 | self._poll_from = datetime.now(tz=timezone.utc) - timedelta(7) 92 | self._fetching_data = None 93 | self._data_fetch_completed = datetime.min 94 | 95 | @property 96 | def applianceId(self): 97 | """ returns the appliance Identifier, looks like a UUID, so hopefully unique """ 98 | return self._applianceId 99 | 100 | async def async_update(self): 101 | if self._fetching_data != None: 102 | await self._fetching_data.wait() 103 | return 104 | 105 | # XXX: Hardcoded 15 minute interval for now. Would be prettier to set this a bit more dynamically 106 | # based on the json response for the sense guard, and probably hardcode something longer for the sense. 107 | if datetime.now() - self._data_fetch_completed < timedelta(minutes=15): 108 | _LOGGER.debug('Skipping fetching new data, time since last fetch was only %s', datetime.now() - self._data_fetch_completed) 109 | return 110 | 111 | _LOGGER.debug("Fetching new data for appliance %s", self._applianceId) 112 | self._fetching_data = asyncio.Event() 113 | 114 | def parse_time(s): 115 | # XXX: Fix for python 3.6 - Grohe emits time zone as "+HH:MM", python 3.6's %z only accepts the format +HHMM 116 | # So, some ugly code to remove the colon for now... 117 | if s.rfind(':') > s.find('+'): 118 | s = s[:s.rfind(':')] + s[s.rfind(':')+1:] 119 | return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') 120 | 121 | poll_from=self._poll_from.strftime('%Y-%m-%d') 122 | measurements_response = await self._auth_session.get(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/data?from={poll_from}') 123 | if 'withdrawals' in measurements_response['data']: 124 | withdrawals = measurements_response['data']['withdrawals'] 125 | _LOGGER.debug('Received %d withdrawals in response', len(withdrawals)) 126 | for w in withdrawals: 127 | w['starttime'] = parse_time(w['starttime']) 128 | withdrawals = [ w for w in withdrawals if w['starttime'] > self._poll_from] 129 | withdrawals.sort(key = lambda x: x['starttime']) 130 | 131 | _LOGGER.debug('Got %d new withdrawals totaling %f volume', len(withdrawals), sum((w['waterconsumption'] for w in withdrawals))) 132 | self._withdrawals += withdrawals 133 | if len(self._withdrawals) > 0: 134 | self._poll_from = max(self._poll_from, self._withdrawals[-1]['starttime']) 135 | elif self._type != GROHE_SENSE_TYPE: 136 | _LOGGER.info('Data response for appliance %s did not contain any withdrawals data', self._applianceId) 137 | 138 | if 'measurement' in measurements_response['data']: 139 | measurements = measurements_response['data']['measurement'] 140 | measurements.sort(key = lambda x: x['timestamp']) 141 | if len(measurements): 142 | for key in SENSOR_TYPES_PER_UNIT[self._type]: 143 | if key in measurements[-1]: 144 | self._measurements[key] = measurements[-1][key] 145 | self._poll_from = max(self._poll_from, parse_time(measurements[-1]['timestamp'])) 146 | else: 147 | _LOGGER.info('Data response for appliance %s did not contain any measurements data', self._applianceId) 148 | 149 | 150 | self._data_fetch_completed = datetime.now() 151 | 152 | self._fetching_data.set() 153 | self._fetching_data = None 154 | 155 | def consumption(self, since): 156 | # XXX: As self._withdrawals is sorted, we could speed this up by a binary search, 157 | # but most likely data sets are small enough that a linear scan is fine. 158 | return sum((w['waterconsumption'] for w in self._withdrawals if w['starttime'] >= since)) 159 | 160 | def measurement(self, key): 161 | if key in self._measurements: 162 | return self._measurements[key] 163 | return STATE_UNKNOWN 164 | 165 | 166 | class GroheSenseNotificationEntity(Entity): 167 | def __init__(self, auth_session, locationId, roomId, applianceId, name): 168 | self._auth_session = auth_session 169 | self._locationId = locationId 170 | self._roomId = roomId 171 | self._applianceId = applianceId 172 | self._name = name 173 | self._notifications = [] 174 | 175 | @property 176 | def name(self): 177 | return f'{self._name} notifications' 178 | 179 | @property 180 | def state(self): 181 | def truncate_string(l, s): 182 | if len(s) > l: 183 | return s[:l-4] + ' ...' 184 | return s 185 | return truncate_string(255, '\n'.join([NOTIFICATION_TYPES.get((n['category'], n['type']), 'Unknown notification: {}'.format(n)) for n in self._notifications])) 186 | 187 | @Throttle(NOTIFICATION_UPDATE_DELAY) 188 | async def async_update(self): 189 | self._notifications = await self._auth_session.get(BASE_URL + f'locations/{self._locationId}/rooms/{self._roomId}/appliances/{self._applianceId}/notifications') 190 | 191 | 192 | class GroheSenseGuardWithdrawalsEntity(Entity): 193 | def __init__(self, reader, name, days): 194 | self._reader = reader 195 | self._name = name 196 | self._days = days 197 | 198 | #@property 199 | #def unique_id(self): 200 | # return '{}-{}'.format(self._reader.applianceId, self._days) 201 | 202 | @property 203 | def name(self): 204 | return '{} {} day'.format(self._name, self._days) 205 | 206 | @property 207 | def unit_of_measurement(self): 208 | return VOLUME_LITERS 209 | 210 | @property 211 | def state(self): 212 | if self._days == 1: # special case, if we're averaging over 1 day, just count since midnight local time 213 | since = datetime.now().astimezone().replace(hour=0,minute=0,second=0,microsecond=0) 214 | else: # otherwise, it's a rolling X day average 215 | since = datetime.now(tz=timezone.utc) - timedelta(self._days) 216 | return self._reader.consumption(since) 217 | 218 | async def async_update(self): 219 | await self._reader.async_update() 220 | 221 | class GroheSenseSensorEntity(Entity): 222 | def __init__(self, reader, name, key): 223 | self._reader = reader 224 | self._name = name 225 | self._key = key 226 | 227 | @property 228 | def name(self): 229 | return '{} {}'.format(self._name, self._key) 230 | 231 | @property 232 | def unit_of_measurement(self): 233 | return SENSOR_TYPES[self._key].unit 234 | 235 | @property 236 | def device_class(self): 237 | return SENSOR_TYPES[self._key].device_class 238 | 239 | @property 240 | def state(self): 241 | raw_state = self._reader.measurement(self._key) 242 | if raw_state in (STATE_UNKNOWN, STATE_UNAVAILABLE): 243 | return raw_state 244 | else: 245 | return SENSOR_TYPES[self._key].function(raw_state) 246 | 247 | async def async_update(self): 248 | await self._reader.async_update() 249 | --------------------------------------------------------------------------------