From 84b12ab0078ed95a4bc3de67e088027ebd919d1f Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sun, 27 Nov 2016 19:18:47 -0500 Subject: [PATCH] Nest Cam support (#4292) * start nestcam support * start nestcam support * introduce a access_token_cache_file * Bare minimum to get nest thermostat loading * occaisonally the image works * switch to nest-aware interval for testing * Add Nest Aware awareness * remove duplicate error logging line * Fix nest protect support * address baloobot * fix copy pasta * fix more baloobot * last baloobot thing for now? * Use streaming status to determine online or not. online from nest means its on the network * Fix temperature scale for climate * Add support for eco mode * Fix auto mode for nest climate * update update current_operation and set_operation mode to use constant when possible. try to get setting something working * remove stale comment * unused-argument already disabled globally * Add eco to the end, instead of after off * Simplify conditional when the hass mode is the same as the nest one * away_temperature became eco_temperature, and works with eco mode * Update min/max temp based on locked temperature * Forgot to set locked stuff during construction * Cache image instead of throttling (which returns none), respect NestAware subscription * Fix _time_between_snapshots before the first update * WIP pin authorization * Add some more logging * Working configurator, woo. Fix some hound errors * Updated pin workflow * Deprecate more sensors * Don't update during access of name * Don't update during access of name * Add camera brand * Fix up some syntastic errors * Fix ups ome hound errors * Maybe fix some more? * Move snapshot simulator url checking down into python-nest * Rename _ready_to_update_camera_image to _ready_for_snapshot * More fixes * Set the next time a snapshot can be taken when one is taken to simplify logic * Add a FIXME about update not getting called * Call update during constructor, so values get set at least once * Fix up names * Remove todo about eco, since that's pretty nest * thanks hound * Fix temperature being off for farenheight. * Fix some lint errors, which includes using a git version of python-nest with updated code * generate requirements_all.py * fix pylint * Update nestcam before adding * Fix polling of NestCamera * Lint --- homeassistant/components/camera/nest.py | 109 ++++++++++++++++++++++ homeassistant/components/climate/nest.py | 113 ++++++++++------------- homeassistant/components/nest.py | 105 +++++++++++++++++++-- homeassistant/components/sensor/nest.py | 74 ++++++--------- requirements_all.txt | 6 +- 5 files changed, 289 insertions(+), 118 deletions(-) create mode 100644 homeassistant/components/camera/nest.py diff --git a/homeassistant/components/camera/nest.py b/homeassistant/components/camera/nest.py new file mode 100644 index 00000000000..20003d4b347 --- /dev/null +++ b/homeassistant/components/camera/nest.py @@ -0,0 +1,109 @@ +""" +Support for Nest Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.nest/ +""" + +import logging +from datetime import timedelta +import requests +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +import homeassistant.components.nest as nest +from homeassistant.util.dt import utcnow + + +DEPENDENCIES = ['nest'] +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) + +NEST_BRAND = "Nest" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup a Nest Cam.""" + if discovery_info is None: + return + camera_devices = hass.data[nest.DATA_NEST].camera_devices() + cameras = [NestCamera(structure, device) + for structure, device in camera_devices] + add_devices(cameras, True) + + +class NestCamera(Camera): + """Representation of a Nest Camera.""" + + def __init__(self, structure, device): + """Initialize a Nest Camera.""" + super(NestCamera, self).__init__() + self.structure = structure + self.device = device + + # data attributes + self._location = None + self._name = None + self._is_online = None + self._is_streaming = None + self._is_video_history_enabled = False + # default to non-NestAware subscribed, but will be fixed during update + self._time_between_snapshots = timedelta(seconds=30) + self._last_image = None + self._next_snapshot_at = None + + @property + def name(self): + """Return the name of the nest, if any.""" + return self._name + + @property + def should_poll(self): + """Nest camera should poll periodically.""" + return True + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._is_streaming + + @property + def brand(self): + """Camera Brand.""" + return NEST_BRAND + + # this doesn't seem to be getting called regularly, for some reason + def update(self): + """Cache value from Python-nest.""" + self._location = self.device.where + self._name = self.device.name + self._is_online = self.device.is_online + self._is_streaming = self.device.is_streaming + self._is_video_history_enabled = self.device.is_video_history_enabled + + if self._is_video_history_enabled: + # NestAware allowed 10/min + self._time_between_snapshots = timedelta(seconds=6) + else: + # otherwise, 2/min + self._time_between_snapshots = timedelta(seconds=30) + + def _ready_for_snapshot(self, now): + return (self._next_snapshot_at is None or + now > self._next_snapshot_at) + + def camera_image(self): + """Return a still image response from the camera.""" + now = utcnow() + if self._ready_for_snapshot(now): + url = self.device.snapshot_url + + try: + response = requests.get(url) + except requests.exceptions.RequestException as error: + _LOGGER.error('Error getting camera image: %s', error) + return None + + self._next_snapshot_at = now + self._time_between_snapshots + self._last_image = response.content + + return self._last_image diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 402cc2b2498..01c3b3782b1 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -14,7 +14,8 @@ from homeassistant.components.climate import ( PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE) from homeassistant.const import ( - TEMP_CELSIUS, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) + TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) DEPENDENCIES = ['nest'] _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,18 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=1)), }) +STATE_ECO = 'eco' +STATE_HEAT_COOL = 'heat-cool' + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Nest thermostat.""" + _LOGGER.debug("Setting up nest thermostat") + if discovery_info is None: + return + temp_unit = hass.config.units.temperature_unit + add_devices( [NestThermostat(structure, device, temp_unit) for structure, device in hass.data[DATA_NEST].devices()], @@ -58,9 +67,9 @@ class NestThermostat(ClimateDevice): if self.device.can_heat and self.device.can_cool: self._operation_list.append(STATE_AUTO) + self._operation_list.append(STATE_ECO) + # feature of device - self._has_humidifier = self.device.has_humidifier - self._has_dehumidifier = self.device.has_dehumidifier self._has_fan = self.device.has_fan # data attributes @@ -68,41 +77,24 @@ class NestThermostat(ClimateDevice): self._location = None self._name = None self._humidity = None - self._target_humidity = None self._target_temperature = None self._temperature = None + self._temperature_scale = None self._mode = None self._fan = None - self._away_temperature = None + self._eco_temperature = None + self._is_locked = None + self._locked_temperature = None @property def name(self): """Return the name of the nest, if any.""" - if self._location is None: - return self._name - else: - if self._name == '': - return self._location.capitalize() - else: - return self._location.capitalize() + '(' + self._name + ')' + return self._name @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def device_state_attributes(self): - """Return the device specific state attributes.""" - if self._has_humidifier or self._has_dehumidifier: - # Move these to Thermostat Device and make them global - return { - "humidity": self._humidity, - "target_humidity": self._target_humidity, - } - else: - # No way to control humidity not show setting - return {} + return self._temperature_scale @property def current_temperature(self): @@ -112,21 +104,17 @@ class NestThermostat(ClimateDevice): @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - if self._mode == 'cool': - return STATE_COOL - elif self._mode == 'heat': - return STATE_HEAT - elif self._mode == 'range': + if self._mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: + return self._mode + elif self._mode == STATE_HEAT_COOL: return STATE_AUTO - elif self._mode == 'off': - return STATE_OFF else: return STATE_UNKNOWN @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._mode != 'range' and not self.is_away_mode_on: + if self._mode != STATE_HEAT_COOL and not self.is_away_mode_on: return self._target_temperature else: return None @@ -134,10 +122,11 @@ class NestThermostat(ClimateDevice): @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" - if self.is_away_mode_on and self._away_temperature[0]: - # away_temperature is always a low, high tuple - return self._away_temperature[0] - if self._mode == 'range': + if (self.is_away_mode_on or self._mode == STATE_ECO) and \ + self._eco_temperature[0]: + # eco_temperature is always a low, high tuple + return self._eco_temperature[0] + if self._mode == STATE_HEAT_COOL: return self._target_temperature[0] else: return None @@ -145,10 +134,11 @@ class NestThermostat(ClimateDevice): @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" - if self.is_away_mode_on and self._away_temperature[1]: - # away_temperature is always a low, high tuple - return self._away_temperature[1] - if self._mode == 'range': + if (self.is_away_mode_on or self._mode == STATE_ECO) and \ + self._eco_temperature[1]: + # eco_temperature is always a low, high tuple + return self._eco_temperature[1] + if self._mode == STATE_HEAT_COOL: return self._target_temperature[1] else: return None @@ -163,8 +153,7 @@ class NestThermostat(ClimateDevice): target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if target_temp_low is not None and target_temp_high is not None: - - if self._mode == 'range': + if self._mode == STATE_HEAT_COOL: temp = (target_temp_low, target_temp_high) else: temp = kwargs.get(ATTR_TEMPERATURE) @@ -173,14 +162,11 @@ class NestThermostat(ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.device.mode = 'heat' - elif operation_mode == STATE_COOL: - self.device.mode = 'cool' + if operation_mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: + device_mode = operation_mode elif operation_mode == STATE_AUTO: - self.device.mode = 'range' - elif operation_mode == STATE_OFF: - self.device.mode = 'off' + device_mode = STATE_HEAT_COOL + self.device.mode = device_mode @property def operation_list(self): @@ -217,30 +203,33 @@ class NestThermostat(ClimateDevice): @property def min_temp(self): """Identify min_temp in Nest API or defaults if not available.""" - temp = self._away_temperature[0] - if temp is None: - return super().min_temp + if self._is_locked: + return self._locked_temperature[0] else: - return temp + return None @property def max_temp(self): """Identify max_temp in Nest API or defaults if not available.""" - temp = self._away_temperature[1] - if temp is None: - return super().max_temp + if self._is_locked: + return self._locked_temperature[1] else: - return temp + return None def update(self): """Cache value from Python-nest.""" self._location = self.device.where self._name = self.device.name self._humidity = self.device.humidity, - self._target_humidity = self.device.target_humidity, self._temperature = self.device.temperature self._mode = self.device.mode self._target_temperature = self.device.target self._fan = self.device.fan - self._away = self.structure.away - self._away_temperature = self.device.away_temperature + self._away = self.structure.away == 'away' + self._eco_temperature = self.device.eco_temperature + self._locked_temperature = self.device.locked_temperature + self._is_locked = self.device.is_locked + if self.device.temperature == 'C': + self._temperature_scale = TEMP_CELSIUS + else: + self._temperature_scale = TEMP_FAHRENHEIT diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 9f766efe693..10310390dfe 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -10,36 +10,109 @@ import socket import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, CONF_STRUCTURE) +from homeassistant.helpers import discovery +from homeassistant.const import (CONF_STRUCTURE, CONF_FILENAME) +from homeassistant.loader import get_component +_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-nest==2.11.0'] +REQUIREMENTS = [ + 'git+https://github.com/technicalpickles/python-nest.git' + '@nest-cam' + '#python-nest==3.0.0'] DOMAIN = 'nest' DATA_NEST = 'nest' +NEST_CONFIG_FILE = 'nest.conf' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string) }) }, extra=vol.ALLOW_EXTRA) +def request_configuration(nest, hass, config): + """Request configuration steps from the user.""" + configurator = get_component('configurator') + if 'nest' in _CONFIGURING: + _LOGGER.debug("configurator failed") + configurator.notify_errors( + _CONFIGURING['nest'], "Failed to configure, please try again.") + return + + def nest_configuration_callback(data): + """The actions to do when our configuration callback is called.""" + _LOGGER.debug("configurator callback") + pin = data.get('pin') + setup_nest(hass, nest, config, pin=pin) + + _CONFIGURING['nest'] = configurator.request_config( + hass, "Nest", nest_configuration_callback, + description=('To configure Nest, click Request Authorization below, ' + 'log into your Nest account, ' + 'and then enter the resulting PIN'), + link_name='Request Authorization', + link_url=nest.authorize_url, + submit_caption="Confirm", + fields=[{'id': 'pin', 'name': 'Enter the PIN', 'type': ''}] + ) + + +def setup_nest(hass, nest, config, pin=None): + """Setup Nest Devices.""" + if pin is not None: + _LOGGER.debug("pin acquired, requesting access token") + nest.request_token(pin) + + if nest.access_token is None: + _LOGGER.debug("no access_token, requesting configuration") + request_configuration(nest, hass, config) + return + + if 'nest' in _CONFIGURING: + _LOGGER.debug("configuration done") + configurator = get_component('configurator') + configurator.request_done(_CONFIGURING.pop('nest')) + + _LOGGER.debug("proceeding with setup") + conf = config[DOMAIN] + hass.data[DATA_NEST] = NestDevice(hass, conf, nest) + + _LOGGER.debug("proceeding with discovery") + discovery.load_platform(hass, 'climate', DOMAIN, {}, config) + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'camera', DOMAIN, {}, config) + _LOGGER.debug("setup done") + + return True + + def setup(hass, config): """Setup the Nest thermostat component.""" import nest - conf = config[DOMAIN] - username = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] + if 'nest' in _CONFIGURING: + return - nest = nest.Nest(username, password) - hass.data[DATA_NEST] = NestDevice(hass, conf, nest) + conf = config[DOMAIN] + client_id = conf[CONF_CLIENT_ID] + client_secret = conf[CONF_CLIENT_SECRET] + filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) + + access_token_cache_file = hass.config.path(filename) + + nest = nest.Nest( + access_token_cache_file=access_token_cache_file, + client_id=client_id, client_secret=client_secret) + setup_nest(hass, nest, config) return True @@ -85,3 +158,17 @@ class NestDevice(object): except socket.error: _LOGGER.error( "Connection error logging into the nest web service.") + + def camera_devices(self): + """Generator returning list of camera devices.""" + try: + for structure in self.nest.structures: + if structure.name in self._structure: + for device in structure.cameradevices: + yield(structure, device) + else: + _LOGGER.info("Ignoring structure %s, not in %s", + structure.name, self._structure) + except socket.error: + _LOGGER.error( + "Connection error logging into the nest web service.") diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index 9f8e7396f93..1173fcedd57 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -11,29 +11,35 @@ import voluptuous as vol from homeassistant.components.nest import DATA_NEST, DOMAIN from homeassistant.helpers.entity import Entity from homeassistant.const import ( - TEMP_CELSIUS, CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS + TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_PLATFORM, + CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS ) DEPENDENCIES = ['nest'] SENSOR_TYPES = ['humidity', 'operation_mode', - 'last_ip', - 'local_ip', - 'last_connection', - 'battery_level'] + 'last_connection'] -WEATHER_VARS = {'weather_humidity': 'humidity', - 'weather_temperature': 'temperature', - 'weather_condition': 'condition', - 'wind_speed': 'kph', - 'wind_direction': 'direction'} +SENSOR_TYPES_DEPRECATED = ['battery_health', + 'last_ip', + 'local_ip'] -SENSOR_UNITS = {'humidity': '%', 'battery_level': 'V', - 'kph': 'kph', 'temperature': '°C'} +WEATHER_VARS = {} + +DEPRECATED_WEATHER_VARS = {'weather_humidity': 'humidity', + 'weather_temperature': 'temperature', + 'weather_condition': 'condition', + 'wind_speed': 'kph', + 'wind_direction': 'direction'} + +SENSOR_UNITS = {'humidity': '%', + 'temperature': '°C'} PROTECT_VARS = ['co_status', 'smoke_status', - 'battery_level'] + 'battery_health'] + +PROTECT_VARS_DEPRECATED = ['battery_level'] SENSOR_TEMP_TYPES = ['temperature', 'target'] @@ -51,21 +57,22 @@ PLATFORM_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Nest Sensor.""" nest = hass.data[DATA_NEST] + conf = config.get(CONF_MONITORED_CONDITIONS, _VALID_SENSOR_TYPES) all_sensors = [] for structure, device in chain(nest.devices(), nest.protect_devices()): sensors = [NestBasicSensor(structure, device, variable) - for variable in config[CONF_MONITORED_CONDITIONS] + for variable in conf if variable in SENSOR_TYPES and is_thermostat(device)] sensors += [NestTempSensor(structure, device, variable) - for variable in config[CONF_MONITORED_CONDITIONS] + for variable in conf if variable in SENSOR_TEMP_TYPES and is_thermostat(device)] sensors += [NestWeatherSensor(structure, device, WEATHER_VARS[variable]) - for variable in config[CONF_MONITORED_CONDITIONS] + for variable in conf if variable in WEATHER_VARS and is_thermostat(device)] sensors += [NestProtectSensor(structure, device, variable) - for variable in config[CONF_MONITORED_CONDITIONS] + for variable in conf if variable in PROTECT_VARS and is_protect(device)] all_sensors.extend(sensors) @@ -99,16 +106,7 @@ class NestSensor(Entity): @property def name(self): """Return the name of the nest, if any.""" - if self._location is None: - return "{} {}".format(self._name, self.variable) - else: - if self._name == '': - return "{} {}".format(self._location.capitalize(), - self.variable) - else: - return "{}({}){}".format(self._location.capitalize(), - self._name, - self.variable) + return "{} {}".format(self._name, self.variable) class NestBasicSensor(NestSensor): @@ -138,7 +136,10 @@ class NestTempSensor(NestSensor): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return TEMP_CELSIUS + if self.device.temperature_scale == 'C': + return TEMP_CELSIUS + else: + return TEMP_FAHRENHEIT @property def state(self): @@ -191,19 +192,4 @@ class NestProtectSensor(NestSensor): def update(self): """Retrieve latest state.""" - state = getattr(self.device, self.variable) - if self.variable == 'battery_level': - self._state = getattr(self.device, self.variable) - else: - self._state = 'Unknown' - if state == 0: - self._state = 'Ok' - if state == 1 or state == 2: - self._state = 'Warning' - if state == 3: - self._state = 'Emergency' - - @property - def name(self): - """Return the name of the nest, if any.""" - return "{} {}".format(self._location.capitalize(), self.variable) + self._state = getattr(self.device, self.variable).capitalize() diff --git a/requirements_all.txt b/requirements_all.txt index 837e14cab77..dfb56d186a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -124,6 +124,9 @@ fuzzywuzzy==0.14.0 # homeassistant.components.device_tracker.bluetooth_le_tracker # gattlib==0.20150805 +# homeassistant.components.nest +git+https://github.com/technicalpickles/python-nest.git@nest-cam#python-nest==3.0.0 + # homeassistant.components.notify.gntp gntp==1.0.3 @@ -437,9 +440,6 @@ python-mpd2==0.5.5 # homeassistant.components.switch.mystrom python-mystrom==0.3.6 -# homeassistant.components.nest -python-nest==2.11.0 - # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1