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
This commit is contained in:
Josh Nichols 2016-11-27 19:18:47 -05:00 committed by Paulus Schoutsen
parent 601193b1d2
commit 84b12ab007
5 changed files with 289 additions and 118 deletions

View File

@ -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

View File

@ -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

View File

@ -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.")

View File

@ -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()

View File

@ -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