"""Support for Nest devices.""" from datetime import datetime, timedelta import logging import socket import threading from nest import Nest from nest.nest import APIError, AuthorizationError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, CONF_SENSORS, CONF_STRUCTURE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity from . import local_auth from .const import DOMAIN _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) SERVICE_CANCEL_ETA = "cancel_eta" SERVICE_SET_ETA = "set_eta" DATA_NEST = "nest" DATA_NEST_CONFIG = "nest_config" SIGNAL_NEST_UPDATE = "nest_update" NEST_CONFIG_FILE = "nest.conf" CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" ATTR_ETA = "eta" ATTR_ETA_WINDOW = "eta_window" ATTR_STRUCTURE = "structure" ATTR_TRIP_ID = "trip_id" AWAY_MODE_AWAY = "away" AWAY_MODE_HOME = "home" ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" SENSOR_SCHEMA = vol.Schema( {vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)} ) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { 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]), vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA, } ) }, extra=vol.ALLOW_EXTRA, ) SET_AWAY_MODE_SCHEMA = vol.Schema( { vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), } ) SET_ETA_SCHEMA = vol.Schema( { vol.Required(ATTR_ETA): cv.time_period, vol.Optional(ATTR_TRIP_ID): cv.string, vol.Optional(ATTR_ETA_WINDOW): cv.time_period, vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), } ) CANCEL_ETA_SCHEMA = vol.Schema( { vol.Required(ATTR_TRIP_ID): cv.string, vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), } ) def nest_update_event_broker(hass, nest): """ Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. Runs in its own thread. """ _LOGGER.debug("Listening for nest.update_event") while hass.is_running: nest.update_event.wait() if not hass.is_running: break nest.update_event.clear() _LOGGER.debug("Dispatching nest data update") dispatcher_send(hass, SIGNAL_NEST_UPDATE) _LOGGER.debug("Stop listening for nest.update_event") async def async_setup(hass, config): """Set up Nest components.""" if DOMAIN not in config: return True conf = config[DOMAIN] local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) access_token_cache_file = hass.config.path(filename) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={"nest_conf_path": access_token_cache_file}, ) ) # Store config to be used during entry setup hass.data[DATA_NEST_CONFIG] = conf return True async def async_setup_entry(hass, entry): """Set up Nest from a config entry.""" nest = Nest(access_token=entry.data["tokens"]["access_token"]) _LOGGER.debug("proceeding with setup") conf = hass.data.get(DATA_NEST_CONFIG, {}) hass.data[DATA_NEST] = NestDevice(hass, conf, nest) if not await hass.async_add_job(hass.data[DATA_NEST].initialize): return False for component in "climate", "camera", "sensor", "binary_sensor": hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) def validate_structures(target_structures): all_structures = [structure.name for structure in nest.structures] for target in target_structures: if target not in all_structures: _LOGGER.info("Invalid structure: %s", target) def set_away_mode(service): """Set the away mode for a Nest structure.""" if ATTR_STRUCTURE in service.data: target_structures = service.data[ATTR_STRUCTURE] validate_structures(target_structures) else: target_structures = hass.data[DATA_NEST].local_structure for structure in nest.structures: if structure.name in target_structures: _LOGGER.info( "Setting away mode for: %s to: %s", structure.name, service.data[ATTR_AWAY_MODE], ) structure.away = service.data[ATTR_AWAY_MODE] def set_eta(service): """Set away mode to away and include ETA for a Nest structure.""" if ATTR_STRUCTURE in service.data: target_structures = service.data[ATTR_STRUCTURE] validate_structures(target_structures) else: target_structures = hass.data[DATA_NEST].local_structure for structure in nest.structures: if structure.name in target_structures: if structure.thermostats: _LOGGER.info( "Setting away mode for: %s to: %s", structure.name, AWAY_MODE_AWAY, ) structure.away = AWAY_MODE_AWAY now = datetime.utcnow() trip_id = service.data.get( ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp())) ) eta_begin = now + service.data[ATTR_ETA] eta_window = service.data.get(ATTR_ETA_WINDOW, timedelta(minutes=1)) eta_end = eta_begin + eta_window _LOGGER.info( "Setting ETA for trip: %s, " "ETA window starts at: %s and ends at: %s", trip_id, eta_begin, eta_end, ) structure.set_eta(trip_id, eta_begin, eta_end) else: _LOGGER.info( "No thermostats found in structure: %s, unable to set ETA", structure.name, ) def cancel_eta(service): """Cancel ETA for a Nest structure.""" if ATTR_STRUCTURE in service.data: target_structures = service.data[ATTR_STRUCTURE] validate_structures(target_structures) else: target_structures = hass.data[DATA_NEST].local_structure for structure in nest.structures: if structure.name in target_structures: if structure.thermostats: trip_id = service.data[ATTR_TRIP_ID] _LOGGER.info("Cancelling ETA for trip: %s", trip_id) structure.cancel_eta(trip_id) else: _LOGGER.info( "No thermostats found in structure: %s, " "unable to cancel ETA", structure.name, ) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_SET_ETA, set_eta, schema=SET_ETA_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_CANCEL_ETA, cancel_eta, schema=CANCEL_ETA_SCHEMA ) @callback def start_up(event): """Start Nest update event listener.""" threading.Thread( name="Nest update listener", target=nest_update_event_broker, args=(hass, nest), ).start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) @callback def shut_down(event): """Stop Nest update event listener.""" nest.update_event.set() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) _LOGGER.debug("async_setup_nest is done") return True class NestDevice: """Structure Nest functions for hass.""" def __init__(self, hass, conf, nest): """Init Nest Devices.""" self.hass = hass self.nest = nest self.local_structure = conf.get(CONF_STRUCTURE) def initialize(self): """Initialize Nest.""" try: # Do not optimize next statement, it is here for initialize # persistence Nest API connection. structure_names = [s.name for s in self.nest.structures] if self.local_structure is None: self.local_structure = structure_names except (AuthorizationError, APIError, socket.error) as err: _LOGGER.error("Connection error while access Nest web service: %s", err) return False return True def structures(self): """Generate a list of structures.""" try: for structure in self.nest.structures: if structure.name not in self.local_structure: _LOGGER.debug( "Ignoring structure %s, not in %s", structure.name, self.local_structure, ) continue yield structure except (AuthorizationError, APIError, socket.error) as err: _LOGGER.error("Connection error while access Nest web service: %s", err) def thermostats(self): """Generate a list of thermostats.""" return self._devices("thermostats") def smoke_co_alarms(self): """Generate a list of smoke co alarms.""" return self._devices("smoke_co_alarms") def cameras(self): """Generate a list of cameras.""" return self._devices("cameras") def _devices(self, device_type): """Generate a list of Nest devices.""" try: for structure in self.nest.structures: if structure.name not in self.local_structure: _LOGGER.debug( "Ignoring structure %s, not in %s", structure.name, self.local_structure, ) continue for device in getattr(structure, device_type, []): try: # Do not optimize next statement, # it is here for verify Nest API permission. device.name_long except KeyError: _LOGGER.warning( "Cannot retrieve device name for [%s]" ", please check your Nest developer " "account permission settings.", device.serial, ) continue yield (structure, device) except (AuthorizationError, APIError, socket.error) as err: _LOGGER.error("Connection error while access Nest web service: %s", err) class NestSensorDevice(Entity): """Representation of a Nest sensor.""" def __init__(self, structure, device, variable): """Initialize the sensor.""" self.structure = structure self.variable = variable if device is not None: # device specific self.device = device self._name = "{} {}".format( self.device.name_long, self.variable.replace("_", " ") ) else: # structure only self.device = structure self._name = "{} {}".format( self.structure.name, self.variable.replace("_", " ") ) self._state = None self._unit = None @property def name(self): """Return the name of the nest, if any.""" return self._name @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit @property def should_poll(self): """Do not need poll thanks using Nest streaming API.""" return False @property def unique_id(self): """Return unique id based on device serial and variable.""" return f"{self.device.serial}-{self.variable}" @property def device_info(self): """Return information about the device.""" if not hasattr(self.device, "name_long"): name = self.structure.name model = "Structure" else: name = self.device.name_long if self.device.is_thermostat: model = "Thermostat" elif self.device.is_camera: model = "Camera" elif self.device.is_smoke_co_alarm: model = "Nest Protect" else: model = None return { "identifiers": {(DOMAIN, self.device.serial)}, "name": name, "manufacturer": "Nest Labs", "model": model, } def update(self): """Do not use NestSensorDevice directly.""" raise NotImplementedError async def async_added_to_hass(self): """Register update signal handler.""" async def async_update_state(): """Update sensor state.""" await self.async_update_ha_state(True) async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state)