diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..722ebf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,125 @@ + +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### Python Patch ### +.venv/ + +# End of https://www.gitignore.io/api/python diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8124999 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Antoine Colmard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..161e87c --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# heatzy-home-assistant + +Climate Home Assistant component for Heatzy Pilot + +## Installation + +To register the heatzy component to Home Assistant, copy the `climate` folder from this repository to your Home Assistant `custom_components` folder. + +```bash +# Create `custom_components` folder +mkdir -p ~/.homeassistant/custom_components +cd ~/.homeassistant/custom_components +git clone https://github.com/Devotics/heatzy-home-hassistant +``` + +## Usage + +Once installed, add the following lines to your `configuration.yaml`: +```yaml +climate: + - platform: heatzy + username: + password: +``` +This configuration will allow the component to query the Heatzy API to retrieve and contrĂ´l your devices status. + +## License + +[MIT](https://oss.ninja/mit/dramloc) \ No newline at end of file diff --git a/climate/heatzy/__init__.py b/climate/heatzy/__init__.py new file mode 100644 index 0000000..0d1f33e --- /dev/null +++ b/climate/heatzy/__init__.py @@ -0,0 +1,140 @@ +import logging + +import async_timeout +from homeassistant.components.climate import (STATE_COOL, STATE_ECO, + STATE_HEAT, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, + ClimateDevice) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS +from homeassistant.helpers import aiohttp_client, storage + +from .api import HeatzyAPI +from .authenticator import HeatzyAuthenticator +from .const import STORAGE_KEY, STORAGE_VERSION + +_LOGGER = logging.getLogger(__name__) + +HA_TO_HEATZY_STATE = { + STATE_HEAT: 'cft', + STATE_ECO: 'eco', + STATE_COOL: 'fro' +} +HEATZY_TO_HA_STATE = { + 'cft': STATE_HEAT, + 'eco': STATE_ECO, + 'fro': STATE_COOL, +} + + +async def async_setup_platform(hass, config, add_devices, discovery_info=None): + # retrieve platform config + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + session = aiohttp_client.async_get_clientsession(hass) + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + + authenticator = HeatzyAuthenticator(session, store, username, password) + api = HeatzyAPI(session, authenticator) + + # fetch configured Heatzy devices + devices = await api.async_get_devices() + # add all Heatzy devices to home assistant + add_devices(HeatzyPilotThermostat(api, device) for device in devices) + return True + + +class HeatzyPilotThermostat(ClimateDevice): + def __init__(self, api, device): + self._api = api + self._device = device + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return { + STATE_HEAT, + STATE_ECO, + STATE_COOL + } + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE | SUPPORT_ON_OFF + + @property + def unique_id(self): + """Return a unique ID.""" + return self._device.get('did') + + @property + def name(self): + return self._device.get('dev_alias') + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return HEATZY_TO_HA_STATE.get(self._device.get('attr').get('mode')) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._device.get('attr').get('derog_mode') == 1 + + @property + def is_on(self): + """Return true if on.""" + return self._device.get('attr').get('mode') != 'stop' + + async def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + _LOGGER.debug("Setting operation mode '%s' for device '%s'...", + operation_mode, self.unique_id) + await self._api.async_set_mode(self.unique_id, HA_TO_HEATZY_STATE.get(operation_mode)) + await self.async_update() + _LOGGER.info("Operation mode set to '%s' for device '%s'", + operation_mode, self.unique_id) + + async def async_turn_on(self): + """Turn device on.""" + _LOGGER.debug("Turning device '%s' on...", self.unique_id) + await self._api.async_turn_on(self.unique_id) + await self.async_update() + _LOGGER.info("Device '%s' turned on (%s)", self.unique_id) + + async def async_turn_off(self): + """Turn device off.""" + _LOGGER.debug("Turning device '%s' off...", self.unique_id) + await self._api.async_turn_off(self.unique_id) + await self.async_update() + _LOGGER.info("Device '%s' turned off (%s)", self.unique_id) + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + _LOGGER.debug("Turning device '%s' away mode on...", self.unique_id) + await self._api.async_turn_away_mode_on(self.unique_id) + await self.async_update() + _LOGGER.info("Device '%s' away mode turned on", self.unique_id) + + async def turn_away_mode_off(self): + """Turn away mode off.""" + _LOGGER.debug("Turning device '%s' away mode off...", self.unique_id) + await self._api.async_turn_away_mode_off(self.unique_id) + await self.async_update() + _LOGGER.info("Device '%s' away mode turned off", self.unique_id) + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("Updating device '%s'... Current state is: %s", + self.unique_id, {'is_on': self.is_on, 'is_away_mode_on': self.is_away_mode_on, 'current_operation': self.current_operation}) + self._device = await self._api.async_get_device(self.unique_id) + _LOGGER.debug("Device '%s' updated. New state is: %s", + self.unique_id, {'is_on': self.is_on, 'is_away_mode_on': self.is_away_mode_on, 'current_operation': self.current_operation}) diff --git a/climate/heatzy/api.py b/climate/heatzy/api.py new file mode 100644 index 0000000..a45ba9c --- /dev/null +++ b/climate/heatzy/api.py @@ -0,0 +1,98 @@ +import asyncio + +from .const import HEATZY_API_URL, HEATZY_APPLICATION_ID + + +class HeatzyAPI: + def __init__(self, session, authenticator): + self._session = session + self._authenticator = authenticator + + async def _async_get_token(self): + """Get authentication token""" + authentication = await self._authenticator.async_authenticate() + return authentication.get('token') + + async def async_get_devices(self): + """Fetch all configured devices""" + token = await self._async_get_token() + headers = { + 'X-Gizwits-Application-Id': HEATZY_APPLICATION_ID, + 'X-Gizwits-User-Token': token + } + response = await self._session.get(HEATZY_API_URL + '/bindings', headers=headers) + # API response has Content-Type=text/html, content_type=None silences parse error by forcing content type + body = await response.json(content_type=None) + devices = body.get('devices') + return await asyncio.gather( + *[self._merge_with_device_data(device) for device in devices] + ) + + async def async_get_device(self, device_id): + """Fetch device with given id""" + token = await self._async_get_token() + headers = { + 'X-Gizwits-Application-Id': HEATZY_APPLICATION_ID, + 'X-Gizwits-User-Token': token + } + response = await self._session.get(HEATZY_API_URL + '/devices/' + device_id, headers=headers) + # API response has Content-Type=text/html, content_type=None silences parse error by forcing content type + device = await response.json(content_type=None) + return await self._merge_with_device_data(device) + + async def _merge_with_device_data(self, device): + """Fetch detailled data for given device and merge it with the device information""" + device_data = await self._async_get_device_data(device.get('did')) + return {**device, **device_data} + + async def _async_get_device_data(self, device_id): + """Fetch detailled data for device with given id""" + token = await self._async_get_token() + headers = { + 'X-Gizwits-Application-Id': HEATZY_APPLICATION_ID, + 'X-Gizwits-User-Token': token + } + response = await self._session.get(HEATZY_API_URL + '/devdata/' + device_id + '/latest', headers=headers) + device_data = await response.json() + return device_data + + async def _async_control_device(self, device_id, payload): + """Control state of device with given id""" + token = await self._async_get_token() + headers = { + 'X-Gizwits-Application-Id': HEATZY_APPLICATION_ID, + 'X-Gizwits-User-Token': token + } + response = await self._session.post(HEATZY_API_URL + '/control/' + device_id, json=payload, headers=headers) + + async def async_set_mode(self, device_id, mode): + """Change device mode. Mode can be 'cft', 'eco', 'fro' or 'stop'.""" + return await self._async_control_device(device_id, { + 'attrs': { + 'mode': mode + } + }) + + async def async_turn_on(self, device_id): + """Turn device on""" + return await self.async_set_mode(device_id, 'cft') + + async def async_turn_off(self, device_id): + """Turn device off""" + return await self.async_set_mode(device_id, 'stop') + + async def _async_set_derog_mode(self, device_id, derog_mode): + """Set device 'derog_mode' (away_mode)""" + return await self._async_control_device(device_id, { + 'attrs': { + 'derog_mode': derog_mode + } + }) + + async def async_turn_away_mode_on(self, device_id): + """Turn device away mode on""" + return await self._async_set_derog_mode(device_id, 1) + + async def async_turn_away_mode_off(self, device_id): + """Turn device away mode off""" + return await self._async_set_derog_mode(device_id, 0) diff --git a/climate/heatzy/authenticator.py b/climate/heatzy/authenticator.py new file mode 100644 index 0000000..4d0f058 --- /dev/null +++ b/climate/heatzy/authenticator.py @@ -0,0 +1,48 @@ +import logging +import time + +from .const import HEATZY_API_URL, HEATZY_APPLICATION_ID + +_LOGGER = logging.getLogger(__name__) + + +class HeatzyAuthenticator: + def __init__(self, session, store, username, password): + self._session = session + self._store = store + self._username = username + self._password = password + + async def async_authenticate(self): + """Get Heatzy stored authentication if it exists or authenticate against Heatzy API""" + stored_auth = await self._async_load_authentication() + # check if there is a stored authentication and if it is not expired + if stored_auth is not None and stored_auth.get('expire_at') > time.time(): + # return stored authentication + return stored_auth + # refresh authentication + headers = { + 'X-Gizwits-Application-Id': HEATZY_APPLICATION_ID + } + payload = { + 'username': self._username, + 'password': self._password + } + response = await self._session.post(HEATZY_API_URL + '/login', json=payload, headers=headers) + + if response.status != 200: + _LOGGER.error("Heatzy API returned HTTP status %d, response %s", + response.status, result) + return None + + authentication = await response.json() + await self._async_save_authentication(authentication) + return authentication + + async def _async_load_authentication(self): + """Load stored authentication""" + return await self._store.async_load() + + async def _async_save_authentication(self, authentication): + """Save authentication to store""" + return await self._store.async_save(authentication) diff --git a/climate/heatzy/const.py b/climate/heatzy/const.py new file mode 100644 index 0000000..e10773d --- /dev/null +++ b/climate/heatzy/const.py @@ -0,0 +1,4 @@ +STORAGE_VERSION = 1 +STORAGE_KEY = 'heatzy_auth' +HEATZY_APPLICATION_ID = 'c70a66ff039d41b4a220e198b0fcc8b3' +HEATZY_API_URL = 'https://euapi.gizwits.com/app'