* Climate 1.0 / part 1/2/3 * fix flake * Lint * Update Google Assistant * ambiclimate to climate 1.0 (#24911) * Fix Alexa * Lint * Migrate zhong_hong * Migrate tuya * Migrate honeywell to new climate schema (#24257) * Update one * Fix model climate v2 * Cleanup p4 * Add comfort hold mode * Fix old code * Update homeassistant/components/climate/__init__.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * Update homeassistant/components/climate/const.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * First renaming * Rename operation to hvac for paulus * Rename hold mode to preset mode * Cleanup & update comments * Remove on/off * Fix supported feature count * Update services * Update demo * Fix tests & use current_hvac * Update comment * Fix tests & add typing * Add more typing * Update modes * Fix tests * Cleanup low/high with range * Update homematic part 1 * Finish homematic * Fix lint * fix hm mapping * Support simple devices * convert lcn * migrate oem * Fix xs1 * update hive * update mil * Update toon * migrate deconz * cleanup * update tesla * Fix lint * Fix vera * Migrate zwave * Migrate velbus * Cleanup humity feature * Cleanup * Migrate wink * migrate dyson * Fix current hvac * Renaming * Fix lint * Migrate tfiac * migrate tado * Fix PRESET can be None * apply PR#23913 from dev * remove EU component, etc. * remove EU component, etc. * ready to test now * de-linted * some tweaks * de-lint * better handling of edge cases * delint * fix set_mode typos * apply PR#23913 from dev * remove EU component, etc. * ready to test now * de-linted * some tweaks * de-lint * better handling of edge cases * delint * fix set_mode typos * delint, move debug code * away preset now working * code tidy-up * code tidy-up 2 * code tidy-up 3 * address issues #18932, #15063 * address issues #18932, #15063 - 2/2 * refactor MODE_AUTO to MODE_HEAT_COOL and use F not C * add low/high to set_temp * add low/high to set_temp 2 * add low/high to set_temp - delint * run HA scripts * port changes from PR #24402 * manual rebase * manual rebase 2 * delint * minor change * remove SUPPORT_HVAC_ACTION * Migrate radiotherm * Convert touchline * Migrate flexit * Migrate nuheat * Migrate maxcube * Fix names maxcube const * Migrate proliphix * Migrate heatmiser * Migrate fritzbox * Migrate opentherm_gw * Migrate venstar * Migrate daikin * Migrate modbus * Fix elif * Migrate Homematic IP Cloud to climate-1.0 (#24913) * hmip climate fix * Update hvac_mode and preset_mode * fix lint * Fix lint * Migrate generic_thermostat * Migrate incomfort to new climate schema (#24915) * initial commit * Update climate.py * Migrate eq3btsmart * Lint * cleanup PRESET_MANUAL * Migrate ecobee * No conditional features * KNX: Migrate climate component to new climate platform (#24931) * Migrate climate component * Remove unused code * Corrected line length * Lint * Lint * fix tests * Fix value * Migrate geniushub to new climate schema (#24191) * Update one * Fix model climate v2 * Cleanup p4 * Add comfort hold mode * Fix old code * Update homeassistant/components/climate/__init__.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * Update homeassistant/components/climate/const.py Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io> * First renaming * Rename operation to hvac for paulus * Rename hold mode to preset mode * Cleanup & update comments * Remove on/off * Fix supported feature count * Update services * Update demo * Fix tests & use current_hvac * Update comment * Fix tests & add typing * Add more typing * Update modes * Fix tests * Cleanup low/high with range * Update homematic part 1 * Finish homematic * Fix lint * fix hm mapping * Support simple devices * convert lcn * migrate oem * Fix xs1 * update hive * update mil * Update toon * migrate deconz * cleanup * update tesla * Fix lint * Fix vera * Migrate zwave * Migrate velbus * Cleanup humity feature * Cleanup * Migrate wink * migrate dyson * Fix current hvac * Renaming * Fix lint * Migrate tfiac * migrate tado * delinted * delinted * use latest client * clean up mappings * clean up mappings * add duration to set_temperature * add duration to set_temperature * manual rebase * tweak * fix regression * small fix * fix rebase mixup * address comments * finish refactor * fix regression * tweak type hints * delint * manual rebase * WIP: Fixes for honeywell migration to climate-1.0 (#24938) * add type hints * code tidy-up * Fixes for incomfort migration to climate-1.0 (#24936) * delint type hints * no async unless await * revert: no async unless await * revert: no async unless await 2 * delint * fix typo * Fix homekit_controller on climate-1.0 (#24948) * Fix tests on climate-1.0 branch * As part of climate-1.0, make state return the heating-cooling.current characteristic * Fixes from review * lint * Fix imports * Migrate stibel_eltron * Fix lint * Migrate coolmaster to climate 1.0 (#24967) * Migrate coolmaster to climate 1.0 * fix lint errors * More lint fixes * Fix demo to work with UI * Migrate spider * Demo update * Updated frontend to 20190705.0 * Fix boost mode (#24980) * Prepare Netatmo for climate 1.0 (#24973) * Migration Netatmo * Address comments * Update climate.py * Migrate ephember * Migrate Sensibo * Implemented review comments (#24942) * Migrate ESPHome * Migrate MQTT * Migrate Nest * Migrate melissa * Initial/partial migration of ST * Migrate ST * Remove Away mode (#24995) * Migrate evohome, cache access tokens (#24491) * add water_heater, add storage - initial commit * add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker delint * Add Broker, Water Heater & Refactor add missing code desiderata * update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker * bugfix - loc_idx may not be 0 more refactor - ensure pure async more refactoring appears all r/o attributes are working tweak precsion, DHW & delint remove unused code remove unused code 2 remove unused code, refactor _save_auth_tokens() * support RoundThermostat bugfix opmode, switch to util.dt, add until=1h revert breaking change * store at_expires as naive UTC remove debug code delint tidy up exception handling delint add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker add water_heater, add storage - initial commit delint add missing code desiderata update honeywell client library & CODEOWNER add auth_tokens code, refactor & delint refactor for broker delint bugfix - loc_idx may not be 0 more refactor - ensure pure async more refactoring appears all r/o attributes are working tweak precsion, DHW & delint remove unused code remove unused code 2 remove unused code, refactor _save_auth_tokens() support RoundThermostat bugfix opmode, switch to util.dt, add until=1h revert breaking change store at_expires as naive UTC remove debug code delint tidy up exception handling delint * update CODEOWNERS * fix regression * fix requirements * migrate to climate-1.0 * tweaking * de-lint * TCS working? & delint * tweaking * TCS code finalised * remove available() logic * refactor _switchpoints() * tidy up switchpoint code * tweak * teaking device_state_attributes * some refactoring * move PRESET_CUSTOM back to evohome * move CONF_ACCESS_TOKEN_EXPIRES CONF_REFRESH_TOKEN back to evohome * refactor SP code and dt conversion * delinted * delinted * remove water_heater * fix regression * Migrate homekit * Cleanup away mode * Fix tests * add helpers * fix tests melissa * Fix nehueat * fix zwave * add more tests * fix deconz * Fix climate test emulate_hue * fix tests * fix dyson tests * fix demo with new layout * fix honeywell * Switch homekit_controller to use HVAC_MODE_HEAT_COOL instead of HVAC_MODE_AUTO (#25009) * Lint * PyLint * Pylint * fix fritzbox tests * Fix google * Fix all tests * Fix lint * Fix auto for homekit like controler * Fix lint * fix lint
403 lines
14 KiB
Python
403 lines
14 KiB
Python
"""Support for Nest devices."""
|
|
import logging
|
|
import socket
|
|
from datetime import datetime, timedelta
|
|
import threading
|
|
|
|
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 dispatcher_send, \
|
|
async_dispatcher_connect
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
from .const import DOMAIN
|
|
from . import local_auth
|
|
|
|
_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."""
|
|
from nest import Nest
|
|
|
|
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."""
|
|
from nest.nest import AuthorizationError, APIError
|
|
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."""
|
|
from nest.nest import AuthorizationError, APIError
|
|
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."""
|
|
from nest.nest import AuthorizationError, APIError
|
|
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 "{}-{}".format(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)
|