From 262622e0a5f8e46dc1cb4a59791c4b0302d783fa Mon Sep 17 00:00:00 2001 From: Jacob McSwain Date: Wed, 25 Sep 2019 19:14:43 -0500 Subject: [PATCH] Inital commit --- .gitignore | 90 ++++++++ custom_components/shittynest/__init__.py | 39 ++++ custom_components/shittynest/api.py | 160 +++++++++++++ custom_components/shittynest/climate.py | 249 +++++++++++++++++++++ custom_components/shittynest/const.py | 1 + custom_components/shittynest/manifest.json | 11 + hacs.json | 4 + info.md | 23 ++ 8 files changed, 577 insertions(+) create mode 100644 .gitignore create mode 100644 custom_components/shittynest/__init__.py create mode 100644 custom_components/shittynest/api.py create mode 100644 custom_components/shittynest/climate.py create mode 100644 custom_components/shittynest/const.py create mode 100644 custom_components/shittynest/manifest.json create mode 100644 hacs.json create mode 100644 info.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fcc59d --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +.idea + +# 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/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/custom_components/shittynest/__init__.py b/custom_components/shittynest/__init__.py new file mode 100644 index 0000000..d4e665d --- /dev/null +++ b/custom_components/shittynest/__init__.py @@ -0,0 +1,39 @@ +"""The example integration.""" +import voluptuous as vol +from homeassistant.helpers import config_validation as cv +from .const import DOMAIN +from homeassistant.const import ( + CONF_EMAIL, + CONF_PASSWORD +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +def setup(hass, config): + """Set up the asuswrt component.""" + if config.get(DOMAIN) is not None: + email = config[DOMAIN].get(CONF_EMAIL) + password = config[DOMAIN].get(CONF_PASSWORD) + else: + email = None + password = None + + from .api import NestAPI + api = NestAPI( + email, + password + ) + + hass.data[DOMAIN] = api + + return True diff --git a/custom_components/shittynest/api.py b/custom_components/shittynest/api.py new file mode 100644 index 0000000..c44fb2f --- /dev/null +++ b/custom_components/shittynest/api.py @@ -0,0 +1,160 @@ +from datetime import time, timedelta, datetime +import json + +import requests + +API_URL = 'https://home.nest.com' + +class NestAPI(): + def __init__(self, email, password): + self._user_id = None + self._access_token = None + self._device_id = None + self._shared_id = None + self._czfe_url = None + self._compressor_lockout_enabled = None + self._compressor_lockout_time = None + self._hvac_ac_state = None + self._hvac_heater_state = None + self.mode = None + self._time_to_target = None + self._fan_timer_timeout = None + self.can_heat = None + self.can_cool = None + self.has_fan = None + self.fan = None + self.away = None + self.current_temperature = None + self.target_temperature = None + self.target_temperature_high = None + self.target_temperature_low = None + self.current_humidity = None + + self._login(email, password) + self.update() + + def _login(self, email = 'jacob.a.mcswain@gmail.com', password = 'ttlshiwwyaJ@'): + r = requests.post(f'{API_URL}/session', json={ + 'email': email, + 'password': password + }) + self._user_id = r.json()['userid'] + self._access_token = r.json()['access_token'] + + def get_action(self): + if self._hvac_ac_state: + return 'cooling' + elif self._hvac_heater_state: + return 'heating' + else: + return 'off' + + def update(self): + r = requests.post(f'{API_URL}/api/0.1/user/{self._user_id}/app_launch', json={ + 'known_bucket_types': ['shared', 'device'], + 'known_bucket_versions': [] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) + + self._czfe_url = r.json()['service_urls']['urls']['czfe_url'] + + for bucket in r.json()['updated_buckets']: + if bucket['object_key'].startswith('shared.'): + self._shared_id = bucket['object_key'] + thermostat_data = bucket['value'] + self.current_temperature = thermostat_data['current_temperature'] + self.target_temperature = thermostat_data['target_temperature'] + self._compressor_lockout_enabled = thermostat_data['compressor_lockout_enabled'] + self._compressor_lockout_time = thermostat_data['compressor_lockout_timeout'] + self._hvac_ac_state = thermostat_data['hvac_ac_state'] + self._hvac_heater_state = thermostat_data['hvac_heater_state'] + self.mode = thermostat_data['target_temperature_type'] + self.target_temperature_high = thermostat_data['target_temperature_high'] + self.target_temperature_low = thermostat_data['target_temperature_low'] + self.can_heat = thermostat_data['can_heat'] + self.can_cool = thermostat_data['can_cool'] + elif bucket['object_key'].startswith('device.'): + self._device_id = bucket['object_key'] + thermostat_data = bucket['value'] + self._time_to_target = thermostat_data['time_to_target'] + self._fan_timer_timeout = thermostat_data['fan_timer_timeout'] + self.has_fan = thermostat_data['has_fan'] + self.fan = thermostat_data['fan_timer_timeout'] > 0 + self.current_humidity = thermostat_data['current_humidity'] + self.away = thermostat_data['home_away_input'] + + def set_temp(self, temp, temp_high = None): + if temp_high is None: + requests.post(f'{self._czfe_url}/v5/put', json={ + 'objects': [{ + 'object_key': self._shared_id, + 'op': 'MERGE', + 'value':{ + 'target_temperature': temp + } + }] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) + else: + requests.post(f'{self._czfe_url}/v5/put', json={ + 'objects': [{ + 'object_key': self._shared_id, + 'op': 'MERGE', + 'value': { + 'target_temperature_low': temp, + 'target_temperature_high': temp_high + } + }] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) + + + def set_mode(self, mode): + requests.post(f'{self._czfe_url}/v5/put', json={ + 'objects': [{ + 'object_key': self._shared_id, + 'op': 'MERGE', + 'value':{ + 'target_temperature_type': mode + } + }] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) + + def set_fan(self, date): + requests.post(f'{self._czfe_url}/v5/put', json={ + 'objects': [{ + 'object_key': self._device_id, + 'op': 'MERGE', + 'value':{ + 'fan_timer_timeout': date + } + }] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) + + def set_eco_mode(self): + requests.post(f'{self._czfe_url}/v5/put', json={ + 'objects': [{ + 'object_key': self._device_id, + 'op': 'MERGE', + 'value':{ + 'eco': { + 'mode': 'manual-eco' + } + } + }] + }, + headers={ + 'Authorization': f'Basic {self._access_token}' + }) diff --git a/custom_components/shittynest/climate.py b/custom_components/shittynest/climate.py new file mode 100644 index 0000000..800bc34 --- /dev/null +++ b/custom_components/shittynest/climate.py @@ -0,0 +1,249 @@ +"""Demo platform that offers a fake climate device.""" +from datetime import datetime + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, + PRESET_AWAY, + PRESET_ECO, + PRESET_NONE, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_COOL, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from .api import NestAPI +from .const import DOMAIN + +NEST_MODE_HEAT_COOL = "range" +NEST_MODE_ECO = "eco" +NEST_MODE_HEAT = "heat" +NEST_MODE_COOL = "cool" +NEST_MODE_OFF = "off" + +MODE_HASS_TO_NEST = { + HVAC_MODE_AUTO: NEST_MODE_HEAT_COOL, + HVAC_MODE_HEAT: NEST_MODE_HEAT, + HVAC_MODE_COOL: NEST_MODE_COOL, + HVAC_MODE_OFF: NEST_MODE_OFF, +} + +ACTION_NEST_TO_HASS = { + "off": CURRENT_HVAC_IDLE, + "heating": CURRENT_HVAC_HEAT, + "cooling": CURRENT_HVAC_COOL, +} + +MODE_NEST_TO_HASS = {v: k for k, v in MODE_HASS_TO_NEST.items()} + +PRESET_AWAY_AND_ECO = "Away and Eco" + +PRESET_MODES = [PRESET_NONE, PRESET_AWAY, PRESET_ECO, PRESET_AWAY_AND_ECO] + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Nest climate device.""" + add_entities( + [ + ShittyNestClimate(hass.data[DOMAIN]), + ] + ) + + +class ShittyNestClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, api): + """Initialize the thermostat.""" + self._name = "Nest" + self._unit_of_measurement = TEMP_CELSIUS + self._fan_modes = [FAN_ON, FAN_AUTO] + + # Set the default supported features + self._support_flags = SUPPORT_TARGET_TEMPERATURE #| SUPPORT_PRESET_MODE + + # Not all nest devices support cooling and heating remove unused + self._operation_list = [] + + self.device = api + + if self.device.can_heat and self.device.can_cool: + self._operation_list.append(HVAC_MODE_AUTO) + self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE_RANGE + + # Add supported nest thermostat features + if self.device.can_heat: + self._operation_list.append(HVAC_MODE_HEAT) + + if self.device.can_cool: + self._operation_list.append(HVAC_MODE_COOL) + + self._operation_list.append(HVAC_MODE_OFF) + + # feature of device + if self.device.has_fan: + self._support_flags = self._support_flags | SUPPORT_FAN_MODE + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.device.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.device.mode not in (NEST_MODE_HEAT_COOL, NEST_MODE_ECO): + return self.device.target_temperature + return None + + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + if self.device.mode == NEST_MODE_ECO: + #TODO: Grab properly + return None + if self.device.mode == NEST_MODE_HEAT_COOL: + return self.device.target_temperature_high + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + if self.device.mode == NEST_MODE_ECO: + #TODO: Grab properly + return None + if self.device.mode == NEST_MODE_HEAT_COOL: + return self.device.target_temperature_low + return None + + @property + def hvac_action(self): + """Return current operation ie. heat, cool, idle.""" + return ACTION_NEST_TO_HASS[self.device.get_action()] + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + if self.device.mode == NEST_MODE_ECO: + # We assume the first operation in operation list is the main one + return self._operation_list[0] + + return MODE_NEST_TO_HASS[self.device.mode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def preset_mode(self): + """Return current preset mode.""" + if self.device.away and self.device.mode == NEST_MODE_ECO: + return PRESET_AWAY_AND_ECO + + if self.device.away: + return PRESET_AWAY + + if self.device.mode == NEST_MODE_ECO: + return PRESET_ECO + + return None + + @property + def preset_modes(self): + """Return preset modes.""" + return PRESET_MODES + + @property + def fan_mode(self): + """Return whether the fan is on.""" + if self.device.has_fan: + # Return whether the fan is on + return FAN_ON if self.device.fan else FAN_AUTO + # No Fan available so disable slider + return None + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + if self.device.has_fan: + return self._fan_modes + return None + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = None + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if self.device.mode == NEST_MODE_HEAT_COOL: + if target_temp_low is not None and target_temp_high is not None: + self.device.set_temp(target_temp_low, target_temp_high) + else: + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + self.device.set_temp(temp) + + def set_hvac_mode(self, hvac_mode): + """Set operation mode.""" + self.device.set_mode(MODE_HASS_TO_NEST[hvac_mode]) + + def set_fan_mode(self, fan_mode): + """Turn fan on/off.""" + if self.device.has_fan: + if fan_mode == 'on': + self.device.set_fan(int(datetime.now().timestamp() + 60 * 30)) + else: + self.device.set_fan(0) + + def set_preset_mode(self, preset_mode): + """Set preset mode.""" + need_away = preset_mode in (PRESET_AWAY, PRESET_AWAY_AND_ECO) + need_eco = preset_mode in (PRESET_ECO, PRESET_AWAY_AND_ECO) + is_away = self.device.away + is_eco = self.device.mode == NEST_MODE_ECO + + if is_away != need_away: + pass + #self.device.set_away() + + if is_eco != need_eco: + if need_eco: + self.device.set_eco_mode() + else: + self.device.mode = MODE_HASS_TO_NEST[self._operation_list[0]] + + def update(self): + """Updates data""" + self.device.update() + diff --git a/custom_components/shittynest/const.py b/custom_components/shittynest/const.py new file mode 100644 index 0000000..48a807f --- /dev/null +++ b/custom_components/shittynest/const.py @@ -0,0 +1 @@ +DOMAIN='shittynest' diff --git a/custom_components/shittynest/manifest.json b/custom_components/shittynest/manifest.json new file mode 100644 index 0000000..3fd7035 --- /dev/null +++ b/custom_components/shittynest/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "shittynest", + "name": "Shitty Nest (A hack around the Nest component to pull from their internal api)", + "documentation": "https://custom-components.github.io/shittynest", + "dependencies": [], + "codeowners": [ + "@USA-RedDragon" + ], + "homeassistant": "0.97.0", + "requirements": [] +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..13539be --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "Shitty Nest Thermostat", + "domains": ["climate"] +} \ No newline at end of file diff --git a/info.md b/info.md new file mode 100644 index 0000000..ac6eadf --- /dev/null +++ b/info.md @@ -0,0 +1,23 @@ +# shittynest + +A shitty Nest thermostats integration that uses the web api to work after Works with Nest was shut down (fuck Google) + +## Drawbacks + +- No proper error handling +- Won't work with 2FA enabled accounts +- Will only work the for thermostat, I have no other devices to test with +- Nest could change their webapp api at any time, making this defunct +- Won't work with Google-linked accounts + +## Example configuration.yaml + +```yaml +shittynest: + email: email@domain.com + password: !secret nest_password + +climate: + - platform: shittynest + scan_interval: 10 +```