diff --git a/.coveragerc b/.coveragerc index a8459a2cd74762b3b8b95c7f30c8cea1f8468670..f637b64149000c9b959f56985fe4c25d7f3abe78 100644 --- a/.coveragerc +++ b/.coveragerc @@ -579,12 +579,9 @@ omit = homeassistant/components/nest/api.py homeassistant/components/nest/binary_sensor.py homeassistant/components/nest/camera.py - homeassistant/components/nest/camera_legacy.py homeassistant/components/nest/climate.py - homeassistant/components/nest/climate_legacy.py - homeassistant/components/nest/local_auth.py + homeassistant/components/nest/legacy/* homeassistant/components/nest/sensor.py - homeassistant/components/nest/sensor_legacy.py homeassistant/components/netatmo/__init__.py homeassistant/components/netatmo/api.py homeassistant/components/netatmo/camera.py diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 151b1dac0009dcba94c73c9aa012362d4912614c..e9bffa2706c61378c1ad3b84fd9927519b7204de 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -1,45 +1,32 @@ """Support for Nest devices.""" import asyncio -from datetime import datetime, timedelta import logging -import threading from google_nest_sdm.event import AsyncEventCallback, EventMessage from google_nest_sdm.exceptions import AuthException, GoogleNestException from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber -from nest import Nest -from nest.nest import APIError, AuthorizationError import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_FILENAME, CONF_MONITORED_CONDITIONS, CONF_SENSORS, CONF_STRUCTURE, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, ) -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, - dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import api, config_flow, local_auth +from . import api, config_flow from .const import ( API_URL, DATA_SDM, @@ -50,34 +37,15 @@ from .const import ( SIGNAL_NEST_UPDATE, ) from .events import EVENT_NAME_MAP, NEST_EVENT +from .legacy import async_setup_legacy, async_setup_legacy_entry _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) CONF_PROJECT_ID = "project_id" CONF_SUBSCRIBER_ID = "subscriber_id" - - -# Configuration for the legacy nest API -SERVICE_CANCEL_ETA = "cancel_eta" -SERVICE_SET_ETA = "set_eta" - -DATA_NEST = "nest" DATA_NEST_CONFIG = "nest_config" -NEST_CONFIG_FILE = "nest.conf" - -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)} ) @@ -104,31 +72,6 @@ CONFIG_SCHEMA = vol.Schema( # Platforms for SDM API PLATFORMS = ["sensor", "camera", "climate"] -# Services for the legacy API - -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]), - } -) - async def async_setup(hass: HomeAssistant, config: dict): """Set up Nest components with dispatch between old/new flows.""" @@ -283,348 +226,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].pop(DATA_SUBSCRIBER) return unload_ok - - -def nest_update_event_broker(hass, nest): - """ - Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. - - Used for the legacy nest API. - - 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_legacy(hass, config): - """Set up Nest components using the legacy nest API.""" - 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_legacy_entry(hass, entry): - """Set up Nest from legacy 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] = NestLegacyDevice(hass, conf, nest) - if not await hass.async_add_executor_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, f"trip_{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 NestLegacyDevice: - """Structure Nest functions for hass for legacy API.""" - - 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, OSError) 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, OSError) 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, OSError) 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 = f"{self.device.name_long} {self.variable.replace('_', ' ')}" - else: - # structure only - self.device = structure - self._name = f"{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) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) - ) diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py index 56d4ac31b787c63dc94adcd4bb0e373759277bf6..dc58dd2856fc8e4b4c84a4e10c2dd2346eceb5de 100644 --- a/homeassistant/components/nest/binary_sensor.py +++ b/homeassistant/components/nest/binary_sensor.py @@ -1,166 +1,15 @@ -"""Support for Nest Thermostat binary sensors.""" -from itertools import chain -import logging +"""Support for Nest binary sensors that dispatches between API versions.""" -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_SOUND, - BinarySensorEntity, -) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType -from . import CONF_BINARY_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice +from .const import DATA_SDM +from .legacy.sensor import async_setup_legacy_entry -_LOGGER = logging.getLogger(__name__) -BINARY_TYPES = {"online": DEVICE_CLASS_CONNECTIVITY} - -CLIMATE_BINARY_TYPES = { - "fan": None, - "is_using_emergency_heat": "heat", - "is_locked": None, - "has_leaf": None, -} - -CAMERA_BINARY_TYPES = { - "motion_detected": DEVICE_CLASS_MOTION, - "sound_detected": DEVICE_CLASS_SOUND, - "person_detected": DEVICE_CLASS_OCCUPANCY, -} - -STRUCTURE_BINARY_TYPES = {"away": None} - -STRUCTURE_BINARY_STATE_MAP = {"away": {"away": True, "home": False}} - -_BINARY_TYPES_DEPRECATED = [ - "hvac_ac_state", - "hvac_aux_heater_state", - "hvac_heater_state", - "hvac_heat_x2_state", - "hvac_heat_x3_state", - "hvac_alt_heat_state", - "hvac_alt_heat_x2_state", - "hvac_emer_heat_state", -] - -_VALID_BINARY_SENSOR_TYPES = { - **BINARY_TYPES, - **CLIMATE_BINARY_TYPES, - **CAMERA_BINARY_TYPES, - **STRUCTURE_BINARY_TYPES, -} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nest binary sensors. - - No longer used. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up a Nest binary sensor based on a config entry.""" - nest = hass.data[DATA_NEST] - - discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {}) - - # Add all available binary sensors if no Nest binary sensor config is set - if discovery_info == {}: - conditions = _VALID_BINARY_SENSOR_TYPES - else: - conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) - - for variable in conditions: - if variable in _BINARY_TYPES_DEPRECATED: - wstr = ( - f"{variable} is no a longer supported " - "monitored_conditions. See " - "https://www.home-assistant.io/integrations/binary_sensor.nest/ " - "for valid options." - ) - _LOGGER.error(wstr) - - def get_binary_sensors(): - """Get the Nest binary sensors.""" - sensors = [] - for structure in nest.structures(): - sensors += [ - NestBinarySensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_BINARY_TYPES - ] - device_chain = chain(nest.thermostats(), nest.smoke_co_alarms(), nest.cameras()) - for structure, device in device_chain: - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in BINARY_TYPES - ] - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CLIMATE_BINARY_TYPES and device.is_thermostat - ] - - if device.is_camera: - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CAMERA_BINARY_TYPES - ] - for activity_zone in device.activity_zones: - sensors += [ - NestActivityZoneSensor(structure, device, activity_zone) - ] - - return sensors - - async_add_entities(await hass.async_add_executor_job(get_binary_sensors), True) - - -class NestBinarySensor(NestSensorDevice, BinarySensorEntity): - """Represents a Nest binary sensor.""" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return _VALID_BINARY_SENSOR_TYPES.get(self.variable) - - def update(self): - """Retrieve latest state.""" - value = getattr(self.device, self.variable) - if self.variable in STRUCTURE_BINARY_TYPES: - self._state = bool(STRUCTURE_BINARY_STATE_MAP[self.variable].get(value)) - else: - self._state = bool(value) - - -class NestActivityZoneSensor(NestBinarySensor): - """Represents a Nest binary sensor for activity in a zone.""" - - def __init__(self, structure, device, zone): - """Initialize the sensor.""" - super().__init__(structure, device, "") - self.zone = zone - self._name = f"{self._name} {self.zone.name} activity" - - @property - def unique_id(self): - """Return unique id based on camera serial and zone id.""" - return f"{self.device.serial}-{self.zone.zone_id}" - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return DEVICE_CLASS_MOTION - - def update(self): - """Retrieve latest state.""" - self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id) +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the binary sensors.""" + assert DATA_SDM not in entry.data + await async_setup_legacy_entry(hass, entry, async_add_entities) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index dfa365a36c358df18cd719d87ffc7905b83ca6b5..f0e0b8e05fa1139470457a8813dc18e89f76877b 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -3,9 +3,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from .camera_legacy import async_setup_legacy_entry from .camera_sdm import async_setup_sdm_entry from .const import DATA_SDM +from .legacy.camera import async_setup_legacy_entry async def async_setup_entry( diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 6e457da039cb4aa8e61907457b9dcf5cb85061e0..a74a50b0f3615f7bcce6735d0a4c5fe3406aedd0 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -3,9 +3,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from .climate_legacy import async_setup_legacy_entry from .climate_sdm import async_setup_sdm_entry from .const import DATA_SDM +from .legacy.climate import async_setup_legacy_entry async def async_setup_entry( diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..218b01fd71b38d6cb3f69003a47aca3cf09332bf --- /dev/null +++ b/homeassistant/components/nest/legacy/__init__.py @@ -0,0 +1,416 @@ +"""Support for Nest devices.""" + +from datetime import datetime, timedelta +import logging +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_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_FILENAME, + 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 DATA_NEST, DATA_NEST_CONFIG, DOMAIN, SIGNAL_NEST_UPDATE + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + +# Configuration for the legacy nest API +SERVICE_CANCEL_ETA = "cancel_eta" +SERVICE_SET_ETA = "set_eta" + +NEST_CONFIG_FILE = "nest.conf" + +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" + +# Services for the legacy API + +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. + + Used for the legacy nest API. + + 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_legacy(hass, config): + """Set up Nest components using the legacy nest API.""" + 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_legacy_entry(hass, entry): + """Set up Nest from legacy 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] = NestLegacyDevice(hass, conf, nest) + if not await hass.async_add_executor_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, f"trip_{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 NestLegacyDevice: + """Structure Nest functions for hass for legacy API.""" + + 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, OSError) 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, OSError) 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, OSError) 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 = f"{self.device.name_long} {self.variable.replace('_', ' ')}" + else: + # structure only + self.device = structure + self._name = f"{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) + + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + ) diff --git a/homeassistant/components/nest/legacy/binary_sensor.py b/homeassistant/components/nest/legacy/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..4470bd14676436b71c600b7157508725d613318a --- /dev/null +++ b/homeassistant/components/nest/legacy/binary_sensor.py @@ -0,0 +1,167 @@ +"""Support for Nest Thermostat binary sensors.""" +from itertools import chain +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_SOUND, + BinarySensorEntity, +) +from homeassistant.const import CONF_BINARY_SENSORS, CONF_MONITORED_CONDITIONS + +from . import NestSensorDevice +from .const import DATA_NEST, DATA_NEST_CONFIG + +_LOGGER = logging.getLogger(__name__) + +BINARY_TYPES = {"online": DEVICE_CLASS_CONNECTIVITY} + +CLIMATE_BINARY_TYPES = { + "fan": None, + "is_using_emergency_heat": "heat", + "is_locked": None, + "has_leaf": None, +} + +CAMERA_BINARY_TYPES = { + "motion_detected": DEVICE_CLASS_MOTION, + "sound_detected": DEVICE_CLASS_SOUND, + "person_detected": DEVICE_CLASS_OCCUPANCY, +} + +STRUCTURE_BINARY_TYPES = {"away": None} + +STRUCTURE_BINARY_STATE_MAP = {"away": {"away": True, "home": False}} + +_BINARY_TYPES_DEPRECATED = [ + "hvac_ac_state", + "hvac_aux_heater_state", + "hvac_heater_state", + "hvac_heat_x2_state", + "hvac_heat_x3_state", + "hvac_alt_heat_state", + "hvac_alt_heat_x2_state", + "hvac_emer_heat_state", +] + +_VALID_BINARY_SENSOR_TYPES = { + **BINARY_TYPES, + **CLIMATE_BINARY_TYPES, + **CAMERA_BINARY_TYPES, + **STRUCTURE_BINARY_TYPES, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Nest binary sensors. + + No longer used. + """ + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a Nest binary sensor based on a config entry.""" + nest = hass.data[DATA_NEST] + + discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {}) + + # Add all available binary sensors if no Nest binary sensor config is set + if discovery_info == {}: + conditions = _VALID_BINARY_SENSOR_TYPES + else: + conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) + + for variable in conditions: + if variable in _BINARY_TYPES_DEPRECATED: + wstr = ( + f"{variable} is no a longer supported " + "monitored_conditions. See " + "https://www.home-assistant.io/integrations/binary_sensor.nest/ " + "for valid options." + ) + _LOGGER.error(wstr) + + def get_binary_sensors(): + """Get the Nest binary sensors.""" + sensors = [] + for structure in nest.structures(): + sensors += [ + NestBinarySensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_BINARY_TYPES + ] + device_chain = chain(nest.thermostats(), nest.smoke_co_alarms(), nest.cameras()) + for structure, device in device_chain: + sensors += [ + NestBinarySensor(structure, device, variable) + for variable in conditions + if variable in BINARY_TYPES + ] + sensors += [ + NestBinarySensor(structure, device, variable) + for variable in conditions + if variable in CLIMATE_BINARY_TYPES and device.is_thermostat + ] + + if device.is_camera: + sensors += [ + NestBinarySensor(structure, device, variable) + for variable in conditions + if variable in CAMERA_BINARY_TYPES + ] + for activity_zone in device.activity_zones: + sensors += [ + NestActivityZoneSensor(structure, device, activity_zone) + ] + + return sensors + + async_add_entities(await hass.async_add_executor_job(get_binary_sensors), True) + + +class NestBinarySensor(NestSensorDevice, BinarySensorEntity): + """Represents a Nest binary sensor.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the device class of the binary sensor.""" + return _VALID_BINARY_SENSOR_TYPES.get(self.variable) + + def update(self): + """Retrieve latest state.""" + value = getattr(self.device, self.variable) + if self.variable in STRUCTURE_BINARY_TYPES: + self._state = bool(STRUCTURE_BINARY_STATE_MAP[self.variable].get(value)) + else: + self._state = bool(value) + + +class NestActivityZoneSensor(NestBinarySensor): + """Represents a Nest binary sensor for activity in a zone.""" + + def __init__(self, structure, device, zone): + """Initialize the sensor.""" + super().__init__(structure, device, "") + self.zone = zone + self._name = f"{self._name} {self.zone.name} activity" + + @property + def unique_id(self): + """Return unique id based on camera serial and zone id.""" + return f"{self.device.serial}-{self.zone.zone_id}" + + @property + def device_class(self): + """Return the device class of the binary sensor.""" + return DEVICE_CLASS_MOTION + + def update(self): + """Retrieve latest state.""" + self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id) diff --git a/homeassistant/components/nest/camera_legacy.py b/homeassistant/components/nest/legacy/camera.py similarity index 95% rename from homeassistant/components/nest/camera_legacy.py rename to homeassistant/components/nest/legacy/camera.py index 48d9cb00783869a1e9372e11e021268edda04747..cc9be9d75883e98c2644e09abe90038365ae4630 100644 --- a/homeassistant/components/nest/camera_legacy.py +++ b/homeassistant/components/nest/legacy/camera.py @@ -4,10 +4,11 @@ import logging import requests -from homeassistant.components import nest from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_ON_OFF, Camera from homeassistant.util.dt import utcnow +from .const import DATA_NEST, DOMAIN + _LOGGER = logging.getLogger(__name__) NEST_BRAND = "Nest" @@ -24,9 +25,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_legacy_entry(hass, entry, async_add_entities): """Set up a Nest sensor based on a config entry.""" - camera_devices = await hass.async_add_executor_job( - hass.data[nest.DATA_NEST].cameras - ) + camera_devices = await hass.async_add_executor_job(hass.data[DATA_NEST].cameras) cameras = [NestCamera(structure, device) for structure, device in camera_devices] async_add_entities(cameras, True) @@ -63,7 +62,7 @@ class NestCamera(Camera): def device_info(self): """Return information about the device.""" return { - "identifiers": {(nest.DOMAIN, self.device.device_id)}, + "identifiers": {(DOMAIN, self.device.device_id)}, "name": self.device.name_long, "manufacturer": "Nest Labs", "model": "Camera", diff --git a/homeassistant/components/nest/climate_legacy.py b/homeassistant/components/nest/legacy/climate.py similarity index 98% rename from homeassistant/components/nest/climate_legacy.py rename to homeassistant/components/nest/legacy/climate.py index ee28a0905c3fb2d5ba78da68c645e454707cf2f7..cd0d66acba87ef5c0abb552b36c395717f68ae1b 100644 --- a/homeassistant/components/nest/climate_legacy.py +++ b/homeassistant/components/nest/legacy/climate.py @@ -1,4 +1,4 @@ -"""Support for Nest thermostats.""" +"""Legacy Works with Nest climate implementation.""" import logging from nest.nest import APIError @@ -33,8 +33,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DATA_NEST, DOMAIN as NEST_DOMAIN -from .const import SIGNAL_NEST_UPDATE +from .const import DATA_NEST, DOMAIN, SIGNAL_NEST_UPDATE _LOGGER = logging.getLogger(__name__) @@ -170,7 +169,7 @@ class NestThermostat(ClimateEntity): def device_info(self): """Return information about the device.""" return { - "identifiers": {(NEST_DOMAIN, self.device.device_id)}, + "identifiers": {(DOMAIN, self.device.device_id)}, "name": self.device.name_long, "manufacturer": "Nest Labs", "model": "Thermostat", diff --git a/homeassistant/components/nest/legacy/const.py b/homeassistant/components/nest/legacy/const.py new file mode 100644 index 0000000000000000000000000000000000000000..664606b9edc541697c8106e61eb64e5c1925fd6c --- /dev/null +++ b/homeassistant/components/nest/legacy/const.py @@ -0,0 +1,6 @@ +"""Constants used by the legacy Nest component.""" + +DOMAIN = "nest" +DATA_NEST = "nest" +DATA_NEST_CONFIG = "nest_config" +SIGNAL_NEST_UPDATE = "nest_update" diff --git a/homeassistant/components/nest/local_auth.py b/homeassistant/components/nest/legacy/local_auth.py similarity index 85% rename from homeassistant/components/nest/local_auth.py rename to homeassistant/components/nest/legacy/local_auth.py index 8be2693325e030e3be5c5c5d60ccbb71f4478b03..f5fb286df7e8451ebfc797c7feabcb82147e2e19 100644 --- a/homeassistant/components/nest/local_auth.py +++ b/homeassistant/components/nest/legacy/local_auth.py @@ -7,14 +7,14 @@ from nest.nest import AUTHORIZE_URL, AuthorizationError, NestAuth from homeassistant.const import HTTP_UNAUTHORIZED from homeassistant.core import callback -from . import config_flow +from ..config_flow import CodeInvalid, NestAuthError, register_flow_implementation from .const import DOMAIN @callback def initialize(hass, client_id, client_secret): """Initialize a local auth provider.""" - config_flow.register_flow_implementation( + register_flow_implementation( hass, DOMAIN, "configuration.yaml", @@ -44,7 +44,7 @@ async def resolve_auth_code(hass, client_id, client_secret, code): return await result except AuthorizationError as err: if err.response.status_code == HTTP_UNAUTHORIZED: - raise config_flow.CodeInvalid() - raise config_flow.NestAuthError( + raise CodeInvalid() from err + raise NestAuthError( f"Unknown error: {err} ({err.response.status_code})" - ) + ) from err diff --git a/homeassistant/components/nest/sensor_legacy.py b/homeassistant/components/nest/legacy/sensor.py similarity index 98% rename from homeassistant/components/nest/sensor_legacy.py rename to homeassistant/components/nest/legacy/sensor.py index 2df668513e16146bf6b6c5e5bc27d159295f1e0b..34f525ca7a626b836e96ee14bd547dc4bb572ddd 100644 --- a/homeassistant/components/nest/sensor_legacy.py +++ b/homeassistant/components/nest/legacy/sensor.py @@ -3,6 +3,7 @@ import logging from homeassistant.const import ( CONF_MONITORED_CONDITIONS, + CONF_SENSORS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -11,7 +12,8 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) -from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice +from . import NestSensorDevice +from .const import DATA_NEST, DATA_NEST_CONFIG SENSOR_TYPES = ["humidity", "operation_mode", "hvac_state"] diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 6245c5d83d02f5323fc8039fe8efd6b4ae764d61..0dcc89e2262ccde99f5547e4b83d6c3c1f7e0493 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType from .const import DATA_SDM -from .sensor_legacy import async_setup_legacy_entry +from .legacy.sensor import async_setup_legacy_entry from .sensor_sdm import async_setup_sdm_entry diff --git a/tests/components/nest/test_local_auth.py b/tests/components/nest/test_local_auth.py index 491b9bd9e0701ee95e263936492d1ad0d0895a50..ecc37bbe2446bb000d4a4cd2c4091d41ea7a2155 100644 --- a/tests/components/nest/test_local_auth.py +++ b/tests/components/nest/test_local_auth.py @@ -4,7 +4,8 @@ from urllib.parse import parse_qsl import pytest import requests_mock as rmock -from homeassistant.components.nest import config_flow, const, local_auth +from homeassistant.components.nest import config_flow, const +from homeassistant.components.nest.legacy import local_auth @pytest.fixture