diff --git a/.coveragerc b/.coveragerc index 9596b29534d..fe3c598e501 100644 --- a/.coveragerc +++ b/.coveragerc @@ -53,6 +53,9 @@ omit = homeassistant/components/digital_ocean.py homeassistant/components/*/digital_ocean.py + homeassistant/components/doorbird.py + homeassistant/components/*/doorbird.py + homeassistant/components/dweet.py homeassistant/components/*/dweet.py @@ -146,6 +149,9 @@ omit = homeassistant/components/rachio.py homeassistant/components/*/rachio.py + homeassistant/components/raincloud.py + homeassistant/components/*/raincloud.py + homeassistant/components/raspihats.py homeassistant/components/*/raspihats.py @@ -158,8 +164,14 @@ omit = homeassistant/components/rpi_pfio.py homeassistant/components/*/rpi_pfio.py + homeassistant/components/satel_integra.py + homeassistant/components/*/satel_integra.py + homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + + homeassistant/components/skybell.py + homeassistant/components/*/skybell.py homeassistant/components/tado.py homeassistant/components/*/tado.py @@ -170,6 +182,12 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + + homeassistant/components/thethingsnetwork.py + homeassistant/components/*/thethingsnetwork.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py @@ -205,12 +223,12 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py - homeassistant/components/xiaomi.py - homeassistant/components/binary_sensor/xiaomi.py - homeassistant/components/cover/xiaomi.py - homeassistant/components/light/xiaomi.py - homeassistant/components/sensor/xiaomi.py - homeassistant/components/switch/xiaomi.py + homeassistant/components/xiaomi_aqara.py + homeassistant/components/binary_sensor/xiaomi_aqara.py + homeassistant/components/cover/xiaomi_aqara.py + homeassistant/components/light/xiaomi_aqara.py + homeassistant/components/sensor/xiaomi_aqara.py + homeassistant/components/switch/xiaomi_aqara.py homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -244,6 +262,7 @@ omit = homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py homeassistant/components/browser.py + homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py @@ -280,6 +299,7 @@ omit = homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py + homeassistant/components/device_tracker/keenetic_ndms2.py homeassistant/components/device_tracker/linksys_ap.py homeassistant/components/device_tracker/linksys_smart.py homeassistant/components/device_tracker/luci.py @@ -328,6 +348,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py + homeassistant/components/light/xiaomi_miio.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py @@ -380,11 +401,14 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py homeassistant/components/notify/clicksend.py + homeassistant/components/notify/clicksendaudio.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py @@ -397,6 +421,7 @@ omit = homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/matrix.py homeassistant/components/notify/message_bird.py + homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py homeassistant/components/notify/nma.py homeassistant/components/notify/prowl.py @@ -405,6 +430,7 @@ omit = homeassistant/components/notify/pushover.py homeassistant/components/notify/pushsafer.py homeassistant/components/notify/rest.py + homeassistant/components/notify/rocketchat.py homeassistant/components/notify/sendgrid.py homeassistant/components/notify/simplepush.py homeassistant/components/notify/slack.py @@ -420,6 +446,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -445,6 +472,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py @@ -480,6 +508,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py @@ -517,11 +546,14 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py + homeassistant/components/sensor/tibber.py homeassistant/components/sensor/time_date.py homeassistant/components/sensor/torque.py homeassistant/components/sensor/transmission.py + homeassistant/components/sensor/travisci.py homeassistant/components/sensor/twitch.py homeassistant/components/sensor/uber.py homeassistant/components/sensor/upnp.py @@ -529,6 +561,7 @@ omit = homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/worldtidesinfo.py + homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/yweather.py homeassistant/components/sensor/zamg.py @@ -555,13 +588,14 @@ omit = homeassistant/components/switch/rest.py homeassistant/components/switch/rpi_rf.py homeassistant/components/switch/tplink.py + homeassistant/components/switch/telnet.py homeassistant/components/switch/transmission.py homeassistant/components/switch/wake_on_lan.py + homeassistant/components/switch/xiaomi_miio.py homeassistant/components/telegram_bot/* homeassistant/components/thingspeak.py homeassistant/components/tts/amazon_polly.py homeassistant/components/tts/picotts.py - homeassistant/components/upnp.py homeassistant/components/vacuum/roomba.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py @@ -571,6 +605,7 @@ omit = homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py homeassistant/components/zwave/util.py + homeassistant/components/vacuum/mqtt.py [report] diff --git a/CODEOWNERS b/CODEOWNERS index 3c975ca3862..51791886e6d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,9 @@ homeassistant/components/weblink.py @home-assistant/core homeassistant/components/websocket_api.py @home-assistant/core homeassistant/components/zone.py @home-assistant/core +# To monitor non-pypi additions +requirements_all.txt @andrey-git + Dockerfile @home-assistant/docker virtualization/Docker/* @home-assistant/docker @@ -36,6 +39,25 @@ homeassistant/components/zwave/* @home-assistant/z-wave homeassistant/components/*/zwave.py @home-assistant/z-wave # Indiviudal components +homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/climate/eq3btsmart.py @rytilahti +homeassistant/components/climate/sensibo.py @andrey-git homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/history_graph.py @andrey-git +homeassistant/components/light/tplink.py @rytilahti +homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills +homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/miflora.py @danielhiversen +homeassistant/components/sensor/tibber.py @danielhiversen +homeassistant/components/sensor/waqi.py @andrey-git +homeassistant/components/switch/rainmachine.py @bachya +homeassistant/components/switch/tplink.py @rytilahti + +homeassistant/components/*/broadlink.py @danielhiversen +homeassistant/components/*/rfxtrx.py @danielhiversen +homeassistant/components/tesla.py @zabuldon +homeassistant/components/*/tesla.py @zabuldon +homeassistant/components/*/xiaomi_aqara.py @danielhiversen +homeassistant/components/*/xiaomi_miio.py @rytilahti diff --git a/Dockerfile b/Dockerfile index f0d5accdf3d..908e8481eee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,10 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP_CLIENT no +#ENV INSTALL_COAP no #ENV INSTALL_SSOCR no + VOLUME /config RUN mkdir -p /usr/src/app @@ -25,7 +26,6 @@ RUN virtualization/Docker/setup_docker_prereqs # Install hass component dependencies COPY requirements_all.txt requirements_all.txt - # Uninstall enum34 because some depenndecies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ diff --git a/README.rst b/README.rst index 039e8a922af..7f0d41b00ea 100644 --- a/README.rst +++ b/README.rst @@ -33,10 +33,6 @@ of a component, check the `Home Assistant help section argparse.Namespace: type=int, default=None, help='Enables daily log rotation and keeps up to the specified days') + parser.add_argument( + '--log-file', + type=str, + default=None, + help='Log file to write to. If not set, CONFIG/home-assistant.log ' + 'is used') parser.add_argument( '--runner', action='store_true', @@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str, } hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, + log_file=args.log_file) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + log_rotate_days=args.log_rotate_days, log_file=args.log_file) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7831036ff59..4978177a658 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -11,13 +11,11 @@ from typing import Any, Optional, Dict import voluptuous as vol -import homeassistant.components as core_components +from homeassistant import ( + core, config as conf_util, loader, components as core_components) from homeassistant.components import persistent_notification -import homeassistant.config as conf_util -import homeassistant.core as core from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component -import homeassistant.loader as loader from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, get_user_site from homeassistant.util.yaml import clear_secret_cache @@ -27,6 +25,10 @@ from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = 'home-assistant.log' + +# hass.data key for logging information. +DATA_LOGGING = 'logging' + FIRST_INIT_COMPONENT = set(( 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', 'frontend', 'history')) @@ -38,7 +40,8 @@ def from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -56,7 +59,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) + log_rotate_days, log_file) ) return hass @@ -69,7 +72,8 @@ def async_from_config_dict(config: Dict[str, Any], enable_log: bool=True, verbose: bool=False, skip_pip: bool=False, - log_rotate_days: Any=None) \ + log_rotate_days: Any=None, + log_file: Any=None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -77,6 +81,18 @@ def async_from_config_dict(config: Dict[str, Any], This method is a coroutine. """ start = time() + + if enable_log: + async_enable_logging(hass, verbose, log_rotate_days, log_file) + + if sys.version_info[:2] < (3, 5): + _LOGGER.warning( + 'Python 3.4 support has been deprecated and will be removed in ' + 'the begining of 2018. Please upgrade Python or your operating ' + 'system. More info: https://home-assistant.io/blog/2017/10/06/' + 'deprecating-python-3.4-support/' + ) + core_config = config.get(core.DOMAIN, {}) try: @@ -87,9 +103,6 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass) - if enable_log: - async_enable_logging(hass, verbose, log_rotate_days) - hass.config.skip_pip = skip_pip if skip_pip: _LOGGER.warning("Skipping pip installation of required modules. " @@ -153,7 +166,8 @@ def from_config_file(config_path: str, hass: Optional[core.HomeAssistant]=None, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -165,7 +179,7 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) + config_path, hass, verbose, skip_pip, log_rotate_days, log_file) ) return hass @@ -176,7 +190,8 @@ def async_from_config_file(config_path: str, hass: core.HomeAssistant, verbose: bool=False, skip_pip: bool=True, - log_rotate_days: Any=None): + log_rotate_days: Any=None, + log_file: Any=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -187,7 +202,7 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days) + async_enable_logging(hass, verbose, log_rotate_days, log_file) try: config_dict = yield from hass.async_add_job( @@ -205,7 +220,7 @@ def async_from_config_file(config_path: str, @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, - log_rotate_days=None) -> None: + log_rotate_days=None, log_file=None) -> None: """Set up the logging. This method must be run in the event loop. @@ -239,13 +254,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, pass # Log errors to a file if we have write access to file or config dir - err_log_path = hass.config.path(ERROR_LOG_FILENAME) + if log_file is None: + err_log_path = hass.config.path(ERROR_LOG_FILENAME) + else: + err_log_path = os.path.abspath(log_file) + err_path_exists = os.path.isfile(err_log_path) + err_dir = os.path.dirname(err_log_path) # Check if we can write to the error log if it exists or that # we can create files in the containing directory if not. if (err_path_exists and os.access(err_log_path, os.W_OK)) or \ - (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)): + (not err_path_exists and os.access(err_dir, os.W_OK)): if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( @@ -272,6 +292,8 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, logger.addHandler(async_handler) logger.setLevel(logging.INFO) + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path else: _LOGGER.error( "Unable to setup error log %s (access denied)", err_log_path) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 1d437d35da7..6db147a5f59 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -101,6 +101,12 @@ def reload_core_config(hass): hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) +@asyncio.coroutine +def async_reload_core_config(hass): + """Reload the core config.""" + yield from hass.services.async_call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG) + + @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 677fcab4f5d..ab13c1534bc 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -4,52 +4,140 @@ This component provides basic support for Abode Home Security system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/abode/ """ +import asyncio import logging +from functools import partial +from os import path import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, + ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD, + CONF_EXCLUDE, CONF_NAME, + EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['abodepy==0.7.1'] +REQUIREMENTS = ['abodepy==0.12.1'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" +CONF_LIGHTS = "lights" +CONF_POLLING = "polling" DOMAIN = 'abode' -DEFAULT_NAME = 'Abode' -DATA_ABODE = 'data_abode' -DEFAULT_ENTITY_NAMESPACE = 'abode' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' +EVENT_ABODE_ALARM = 'abode_alarm' +EVENT_ABODE_ALARM_END = 'abode_alarm_end' +EVENT_ABODE_AUTOMATION = 'abode_automation' +EVENT_ABODE_FAULT = 'abode_panel_fault' +EVENT_ABODE_RESTORE = 'abode_panel_restore' + +SERVICE_SETTINGS = 'change_setting' +SERVICE_CAPTURE_IMAGE = 'capture_image' +SERVICE_TRIGGER = 'trigger_quick_action' + +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_DEVICE_TYPE = 'device_type' +ATTR_EVENT_CODE = 'event_code' +ATTR_EVENT_NAME = 'event_name' +ATTR_EVENT_TYPE = 'event_type' +ATTR_EVENT_UTC = 'event_utc' +ATTR_SETTING = 'setting' +ATTR_USER_NAME = 'user_name' +ATTR_VALUE = 'value' + +ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA, + vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA }), }, extra=vol.ALLOW_EXTRA) +CHANGE_SETTING_SCHEMA = vol.Schema({ + vol.Required(ATTR_SETTING): cv.string, + vol.Required(ATTR_VALUE): cv.string +}) + +CAPTURE_IMAGE_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +TRIGGER_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, +}) + +ABODE_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', + 'camera', 'light' +] + + +class AbodeSystem(object): + """Abode System class.""" + + def __init__(self, username, password, name, polling, exclude, lights): + """Initialize the system.""" + import abodepy + self.abode = abodepy.Abode(username, password, + auto_login=True, + get_devices=True, + get_automations=True) + self.name = name + self.polling = polling + self.exclude = exclude + self.lights = lights + self.devices = [] + + def is_excluded(self, device): + """Check if a device is configured to be excluded.""" + return device.device_id in self.exclude + + def is_automation_excluded(self, automation): + """Check if an automation is configured to be excluded.""" + return automation.automation_id in self.exclude + + def is_light(self, device): + """Check if a switch device is configured as a light.""" + import abodepy.helpers.constants as CONST + + return (device.generic_type == CONST.TYPE_LIGHT or + (device.generic_type == CONST.TYPE_SWITCH and + device.device_id in self.lights)) + def setup(hass, config): """Set up Abode component.""" + from abodepy.exceptions import AbodeException + conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + name = conf.get(CONF_NAME) + polling = conf.get(CONF_POLLING) + exclude = conf.get(CONF_EXCLUDE) + lights = conf.get(CONF_LIGHTS) try: - data = AbodeData(username, password) - hass.data[DATA_ABODE] = data - - for component in ['binary_sensor', 'alarm_control_panel']: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - except (ConnectTimeout, HTTPError) as ex: + hass.data[DOMAIN] = AbodeSystem( + username, password, name, polling, exclude, lights) + except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + hass.components.persistent_notification.create( 'Error: {}
' 'You will need to restart hass after fixing.' @@ -58,18 +146,209 @@ def setup(hass, config): notification_id=NOTIFICATION_ID) return False + setup_hass_services(hass) + setup_hass_events(hass) + setup_abode_events(hass) + + for platform in ABODE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + return True -class AbodeData: - """Shared Abode data.""" +def setup_hass_services(hass): + """Home assistant services.""" + from abodepy.exceptions import AbodeException - def __init__(self, username, password): - """Initialize Abode oject.""" - import abodepy + def change_setting(call): + """Change an Abode system setting.""" + setting = call.data.get(ATTR_SETTING) + value = call.data.get(ATTR_VALUE) - self.abode = abodepy.Abode(username, password) - self.devices = self.abode.get_devices() + try: + hass.data[DOMAIN].abode.set_setting(setting, value) + except AbodeException as ex: + _LOGGER.warning(ex) - _LOGGER.debug("Abode Security set up with %s devices", - len(self.devices)) + def capture_image(call): + """Capture a new image.""" + entity_ids = call.data.get(ATTR_ENTITY_ID) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.capture() + + def trigger_quick_action(call): + """Trigger a quick action.""" + entity_ids = call.data.get(ATTR_ENTITY_ID, None) + + target_devices = [device for device in hass.data[DOMAIN].devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.trigger() + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN] + + hass.services.register( + DOMAIN, SERVICE_SETTINGS, change_setting, + descriptions.get(SERVICE_SETTINGS), + schema=CHANGE_SETTING_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, + descriptions.get(SERVICE_CAPTURE_IMAGE), + schema=CAPTURE_IMAGE_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_TRIGGER, trigger_quick_action, + descriptions.get(SERVICE_TRIGGER), + schema=TRIGGER_SCHEMA) + + +def setup_hass_events(hass): + """Home assistant start and stop callbacks.""" + def startup(event): + """Listen for push events.""" + hass.data[DOMAIN].abode.events.start() + + def logout(event): + """Logout of Abode.""" + if not hass.data[DOMAIN].polling: + hass.data[DOMAIN].abode.events.stop() + + hass.data[DOMAIN].abode.logout() + _LOGGER.info("Logged out of Abode") + + if not hass.data[DOMAIN].polling: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + + +def setup_abode_events(hass): + """Event callbacks.""" + import abodepy.helpers.timeline as TIMELINE + + def event_callback(event, event_json): + """Handle an event callback from Abode.""" + data = { + ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''), + ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''), + ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''), + ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''), + ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''), + ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''), + ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''), + ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''), + ATTR_DATE: event_json.get(ATTR_DATE, ''), + ATTR_TIME: event_json.get(ATTR_TIME, ''), + } + + hass.bus.fire(event, data) + + events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP] + + for event in events: + hass.data[DOMAIN].abode.events.add_event_callback( + event, + partial(event_callback, event)) + + +class AbodeDevice(Entity): + """Representation of an Abode device.""" + + def __init__(self, data, device): + """Initialize a sensor for Abode device.""" + self._data = data + self._device = device + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + self.hass.async_add_job( + self._data.abode.events.add_device_callback, + self._device.device_id, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_low': self._device.battery_low, + 'no_response': self._device.no_response, + 'device_type': self._device.type + } + + def _update_callback(self, device): + """Update the device state.""" + self.schedule_update_ha_state() + + +class AbodeAutomation(Entity): + """Representation of an Abode automation.""" + + def __init__(self, data, automation, event=None): + """Initialize for Abode automation.""" + self._data = data + self._automation = automation + self._event = event + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + if self._event: + self.hass.async_add_job( + self._data.abode.events.add_event_callback, + self._event, self._update_callback + ) + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + def update(self): + """Update automation state.""" + self._automation.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._automation.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'automation_id': self._automation.automation_id, + 'type': self._automation.type, + 'sub_type': self._automation.sub_type + } + + def _update_callback(self, device): + """Update the device state.""" + self._automation.refresh() + self.schedule_update_ha_state() diff --git a/homeassistant/components/alarm_control_panel/abode.py b/homeassistant/components/alarm_control_panel/abode.py index 7d7ce931c20..aa4e86a2318 100644 --- a/homeassistant/components/alarm_control_panel/abode.py +++ b/homeassistant/components/alarm_control_panel/abode.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/alarm_control_panel.abode/ """ import logging -from homeassistant.components.abode import (DATA_ABODE, DEFAULT_NAME) -from homeassistant.const import (STATE_ALARM_ARMED_AWAY, +from homeassistant.components.abode import ( + AbodeDevice, DOMAIN as ABODE_DOMAIN, CONF_ATTRIBUTION) +from homeassistant.components.alarm_control_panel import (AlarmControlPanel) +from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) -import homeassistant.components.alarm_control_panel as alarm + DEPENDENCIES = ['abode'] @@ -20,29 +22,22 @@ ICON = 'mdi:security' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + data = hass.data[ABODE_DOMAIN] - add_devices([AbodeAlarm(hass, data, data.abode.get_alarm())]) + alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)] + + data.devices.extend(alarm_devices) + + add_devices(alarm_devices) -class AbodeAlarm(alarm.AlarmControlPanel): +class AbodeAlarm(AbodeDevice, AlarmControlPanel): """An alarm_control_panel implementation for Abode.""" - def __init__(self, hass, data, device): + def __init__(self, data, device, name): """Initialize the alarm control panel.""" - super(AbodeAlarm, self).__init__() - self._device = device - self._name = "{0}".format(DEFAULT_NAME) - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + super().__init__(data, device) + self._name = name @property def icon(self): @@ -52,11 +47,11 @@ class AbodeAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._device.mode == "standby": + if self._device.is_standby: state = STATE_ALARM_DISARMED - elif self._device.mode == "away": + elif self._device.is_away: state = STATE_ALARM_ARMED_AWAY - elif self._device.mode == "home": + elif self._device.is_home: state = STATE_ALARM_ARMED_HOME else: state = None @@ -65,18 +60,26 @@ class AbodeAlarm(alarm.AlarmControlPanel): def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() - self.schedule_update_ha_state() def alarm_arm_home(self, code=None): """Send arm home command.""" self._device.set_home() - self.schedule_update_ha_state() def alarm_arm_away(self, code=None): """Send arm away command.""" self._device.set_away() - self.schedule_update_ha_state() - def update(self): - """Update the device state.""" - self._device.refresh() + @property + def name(self): + """Return the name of the alarm.""" + return self._name or super().name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'battery_backup': self._device.battery, + 'cellular_backup': self._device.is_cellular + } diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index f54774b8923..3b58eb0b71d 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -57,19 +57,19 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): if message.alarm_sounding or message.fire_alarm: if self._state != STATE_ALARM_TRIGGERED: self._state = STATE_ALARM_TRIGGERED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_away: if self._state != STATE_ALARM_ARMED_AWAY: self._state = STATE_ALARM_ARMED_AWAY - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() elif message.armed_home: if self._state != STATE_ALARM_ARMED_HOME: self._state = STATE_ALARM_ARMED_HOME - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() else: if self._state != STATE_ALARM_DISARMED: self._state = STATE_ALARM_DISARMED - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def name(self): diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py new file mode 100644 index 00000000000..2dad3857c4d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/arlo.py @@ -0,0 +1,121 @@ +""" +Support for Arlo Alarm Control Panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.arlo/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanel, PLATFORM_SCHEMA) +from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION) +from homeassistant.const import ( + ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) + +_LOGGER = logging.getLogger(__name__) + +ARMED = 'armed' + +CONF_HOME_MODE_NAME = 'home_mode_name' + +DEPENDENCIES = ['arlo'] + +DISARMED = 'disarmed' + +ICON = 'mdi:security' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Arlo Alarm Control Panels.""" + data = hass.data[DATA_ARLO] + + if not data.base_stations: + return + + home_mode_name = config.get(CONF_HOME_MODE_NAME) + base_stations = [] + for base_station in data.base_stations: + base_stations.append(ArloBaseStation(base_station, home_mode_name)) + async_add_devices(base_stations, True) + + +class ArloBaseStation(AlarmControlPanel): + """Representation of an Arlo Alarm Control Panel.""" + + def __init__(self, data, home_mode_name): + """Initialize the alarm control panel.""" + self._base_station = data + self._home_mode_name = home_mode_name + self._state = None + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Update the state of the device.""" + # PyArlo sometimes returns None for mode. So retry 3 times before + # returning None. + num_retries = 3 + i = 0 + while i < num_retries: + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + return + i += 1 + self._state = None + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._base_station.mode = DISARMED + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + self._base_station.mode = ARMED + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command. Uses custom mode.""" + self._base_station.mode = self._home_mode_name + + @property + def name(self): + """Return the name of the base station.""" + return self._base_station.name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._base_station.device_id + } + + def _get_state_from_mode(self, mode): + """Convert Arlo mode to Home Assistant state.""" + if mode == ARMED: + return STATE_ALARM_ARMED_AWAY + elif mode == DISARMED: + return STATE_ALARM_DISARMED + elif mode == self._home_mode_name: + return STATE_ALARM_ARMED_HOME + return None diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index df815424ee9..291d4bc80b5 100755 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -107,7 +107,7 @@ class Concord232Alarm(alarm.AlarmControlPanel): newstate = STATE_ALARM_ARMED_AWAY if not newstate == self._state: - _LOGGER.info("State Chnage from %s to %s", self._state, newstate) + _LOGGER.info("State Change from %s to %s", self._state, newstate) self._state = newstate return self._state diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index 8ebf0a93c38..00dae5c2779 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,10 +5,26 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_TRIGGERED, CONF_PENDING_TIME) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo alarm control panel platform.""" add_devices([ - manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False), + manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, { + STATE_ALARM_ARMED_AWAY: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_HOME: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_PENDING_TIME: 5 + }, + STATE_ALARM_TRIGGERED: { + CONF_PENDING_TIME: 5 + }, + }), ]) diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index fe7db95651b..7719ab884bc 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,18 +18,19 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.18'] +REQUIREMENTS = ['pythonegardia==1.0.22'] _LOGGER = logging.getLogger(__name__) CONF_REPORT_SERVER_CODES = 'report_server_codes' CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' CONF_REPORT_SERVER_PORT = 'report_server_port' +CONF_REPORT_SERVER_CODES_IGNORE = 'ignore' DEFAULT_NAME = 'Egardia' DEFAULT_PORT = 80 DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 85 +DEFAULT_REPORT_SERVER_PORT = 52010 DOMAIN = 'egardia' NOTIFICATION_ID = 'egardia_notification' @@ -148,14 +149,21 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def parsestatus(self, status): """Parse the status.""" - newstatus = ([v for k, v in STATES.items() - if status.upper() == k][0]) - self._status = newstatus + _LOGGER.debug("Parsing status %s", status) + # Ignore the statuscode if it is IGNORE + if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE: + _LOGGER.debug("Not ignoring status") + newstatus = ([v for k, v in STATES.items() + if status.upper() == k][0]) + self._status = newstatus + else: + _LOGGER.error("Ignoring status") def update(self): """Update the alarm status.""" - status = self._egardiasystem.getstate() - self.parsestatus(status) + if not self._rs_enabled: + status = self._egardiasystem.getstate() + self.parsestatus(status) def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index f6d388a6c5b..026d2324ed3 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -106,7 +106,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): def _update_callback(self, partition): """Update Home Assistant state, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def code_format(self): diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 97820ab4b2b..237959ab10d 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -4,6 +4,7 @@ Support for manual alarms. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual/ """ +import copy import datetime import logging @@ -24,7 +25,28 @@ DEFAULT_PENDING_TIME = 60 DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False -PLATFORM_SCHEMA = vol.Schema({ +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + + +PLATFORM_SCHEMA = vol.Schema(vol.All({ vol.Required(CONF_PLATFORM): 'manual', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -34,7 +56,11 @@ PLATFORM_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, -}) + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, +}, _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -47,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_CODE), config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), - config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER) + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config )]) @@ -61,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel): or disarm if `disarm_after_trigger` is true. """ - def __init__(self, hass, name, code, pending_time, - trigger_time, disarm_after_trigger): + def __init__(self, hass, name, code, pending_time, trigger_time, + disarm_after_trigger, config): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass self._name = name self._code = str(code) if code else None - self._pending_time = datetime.timedelta(seconds=pending_time) self._trigger_time = datetime.timedelta(seconds=trigger_time) self._disarm_after_trigger = disarm_after_trigger self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + @property def should_poll(self): """Return the plling state.""" @@ -87,24 +118,27 @@ class ManualAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -124,58 +158,47 @@ class ManualAlarm(alarm.AlarmControlPanel): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): return - self._state = STATE_ALARM_ARMED_NIGHT - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" @@ -183,3 +206,13 @@ class ManualAlarm(alarm.AlarmControlPanel): if not check: _LOGGER.warning("Invalid code given for %s", state) return check + + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index b554a667b2a..44247616b59 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ """ import asyncio +import copy import datetime import logging @@ -13,9 +14,9 @@ import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm import homeassistant.util.dt as dt_util from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, - CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) import homeassistant.components.mqtt as mqtt @@ -28,6 +29,7 @@ from homeassistant.helpers.event import track_point_in_time CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' +CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night' DEFAULT_ALARM_NAME = 'HA Alarm' DEFAULT_PENDING_TIME = 60 @@ -35,11 +37,32 @@ DEFAULT_TRIGGER_TIME = 120 DEFAULT_DISARM_AFTER_TRIGGER = False DEFAULT_ARM_AWAY = 'ARM_AWAY' DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_ARM_NIGHT = 'ARM_NIGHT' DEFAULT_DISARM = 'DISARM' +SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED] + +ATTR_POST_PENDING_STATE = 'post_pending_state' + + +def _state_validator(config): + config = copy.deepcopy(config) + for state in SUPPORTED_PENDING_STATES: + if CONF_PENDING_TIME not in config[state]: + config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + + return config + + +STATE_SETTING_SCHEMA = vol.Schema({ + vol.Optional(CONF_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)) +}) + DEPENDENCIES = ['mqtt'] -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'manual_mqtt', vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, vol.Optional(CONF_CODE): cv.string, @@ -49,12 +72,17 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA, + vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA, vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, -}) +}), _state_validator)) _LOGGER = logging.getLogger(__name__) @@ -73,7 +101,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(mqtt.CONF_QOS), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY))]) + config.get(CONF_PAYLOAD_ARM_AWAY), + config.get(CONF_PAYLOAD_ARM_NIGHT), + config)]) class ManualMQTTAlarm(alarm.AlarmControlPanel): @@ -89,7 +119,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): def __init__(self, hass, name, code, pending_time, trigger_time, disarm_after_trigger, state_topic, command_topic, qos, - payload_disarm, payload_arm_home, payload_arm_away): + payload_disarm, payload_arm_home, payload_arm_away, + payload_arm_night, config): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass @@ -101,12 +132,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self._pre_trigger_state = self._state self._state_ts = None + self._pending_time_by_state = {} + for state in SUPPORTED_PENDING_STATES: + self._pending_time_by_state[state] = datetime.timedelta( + seconds=config[state][CONF_PENDING_TIME]) + self._state_topic = state_topic self._command_topic = command_topic self._qos = qos self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away + self._payload_arm_night = payload_arm_night @property def should_poll(self): @@ -121,23 +158,27 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): @property def state(self): """Return the state of the device.""" - if self._state in (STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY) and \ - self._pending_time and self._state_ts + self._pending_time > \ - dt_util.utcnow(): - return STATE_ALARM_PENDING - if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: - if self._state_ts + self._pending_time > dt_util.utcnow(): + if self._within_pending_time(self._state): return STATE_ALARM_PENDING - elif (self._state_ts + self._pending_time + + elif (self._state_ts + self._pending_time_by_state[self._state] + self._trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - return self._pre_trigger_state + else: + self._state = self._pre_trigger_state + return self._state + + if self._state in SUPPORTED_PENDING_STATES and \ + self._within_pending_time(self._state): + return STATE_ALARM_PENDING return self._state + def _within_pending_time(self, state): + pending_time = self._pending_time_by_state[state] + return self._state_ts + pending_time > dt_util.utcnow() + @property def code_format(self): """One or more characters.""" @@ -157,44 +198,47 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return - self._state = STATE_ALARM_ARMED_HOME - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() - - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._update_state(STATE_ALARM_ARMED_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return - self._state = STATE_ALARM_ARMED_AWAY - self._state_ts = dt_util.utcnow() - self.schedule_update_ha_state() + self._update_state(STATE_ALARM_ARMED_AWAY) - if self._pending_time: - track_point_in_time( - self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT): + return + + self._update_state(STATE_ALARM_ARMED_NIGHT) def alarm_trigger(self, code=None): """Send alarm trigger command. No code needed.""" self._pre_trigger_state = self._state - self._state = STATE_ALARM_TRIGGERED + + self._update_state(STATE_ALARM_TRIGGERED) + + def _update_state(self, state): + self._state = state self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - if self._trigger_time: + pending_time = self._pending_time_by_state[state] + + if state == STATE_ALARM_TRIGGERED and self._trigger_time: track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time) + self._state_ts + pending_time) track_point_in_time( self._hass, self.async_update_ha_state, - self._state_ts + self._pending_time + self._trigger_time) + self._state_ts + self._trigger_time + pending_time) + elif state in SUPPORTED_PENDING_STATES and pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + pending_time) def _validate_code(self, code, state): """Validate given code.""" @@ -203,6 +247,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Invalid code given for %s", state) return check + @property + def device_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + + if self.state == STATE_ALARM_PENDING: + state_attr[ATTR_POST_PENDING_STATE] = self._state + + return state_attr + def async_added_to_hass(self): """Subscribe mqtt events. @@ -221,6 +275,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): self.async_alarm_arm_home(self._code) elif payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) + elif payload == self._payload_arm_night: + self.async_alarm_arm_night(self._code) else: _LOGGER.warning("Received unexpected payload: %s", payload) return diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 33bfe464eea..fca935388c1 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -87,7 +87,7 @@ class MqttAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() return mqtt.async_subscribe( self.hass, self._state_topic, message_received, self._qos) diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py new file mode 100644 index 00000000000..6115311f873 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -0,0 +1,94 @@ +""" +Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ . + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.satel_integra/ +""" +import asyncio +import logging + +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.satel_integra import (CONF_ARM_HOME_MODE, + DATA_SATEL, + SIGNAL_PANEL_MESSAGE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['satel_integra'] + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up for AlarmDecoder alarm panels.""" + if not discovery_info: + return + + device = SatelIntegraAlarmPanel("Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE)) + async_add_devices([device]) + + +class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): + """Representation of an AlarmDecoder-based alarm panel.""" + + def __init__(self, name, arm_home_mode): + """Initialize the alarm panel.""" + self._name = name + self._state = None + self._arm_home_mode = arm_home_mode + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + + @callback + def _message_callback(self, message): + + if message != self._state: + self._state = message + self.async_schedule_update_ha_state() + else: + _LOGGER.warning("Ignoring alarm status message, same state") + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return the regex for code format or None if no code is required.""" + return '^\\d{4,6}$' + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @asyncio.coroutine + def async_alarm_disarm(self, code=None): + """Send disarm command.""" + if code: + yield from self.hass.data[DATA_SATEL].disarm(code) + + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code) + + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + if code: + yield from self.hass.data[DATA_SATEL].arm(code, + self._arm_home_mode) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index de4d5098b41..1682ef2ae02 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SPC alarm control panel platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_AREAS] is None): return - entities = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) - for area in discovery_info[ATTR_DISCOVER_AREAS]] + devices = [SpcAlarm(hass=hass, + area_id=area['id'], + name=area['name'], + state=_get_alarm_state(area['mode'])) + for area in discovery_info[ATTR_DISCOVER_AREAS]] - async_add_entities(entities) + async_add_devices(devices) class SpcAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py new file mode 100644 index 00000000000..65243aa83ce --- /dev/null +++ b/homeassistant/components/alexa/__init__.py @@ -0,0 +1,52 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .const import ( + DOMAIN, CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL) +from . import flash_briefings, intent + +_LOGGER = logging.getLogger(__name__) + + +DEPENDENCIES = ['http'] + +CONF_FLASH_BRIEFINGS = 'flash_briefings' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_FLASH_BRIEFINGS: { + cv.string: vol.All(cv.ensure_list, [{ + vol.Optional(CONF_UID): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Optional(CONF_AUDIO): cv.template, + vol.Required(CONF_TEXT, default=""): cv.template, + vol.Optional(CONF_DISPLAY_URL): cv.template, + }]), + } + } +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + config = config.get(DOMAIN, {}) + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) + + intent.async_setup(hass) + + if flash_briefings_config: + flash_briefings.async_setup(hass, flash_briefings_config) + + return True diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py new file mode 100644 index 00000000000..9550b6dbade --- /dev/null +++ b/homeassistant/components/alexa/const.py @@ -0,0 +1,18 @@ +"""Constants for the Alexa integration.""" +DOMAIN = 'alexa' + +# Flash briefing constants +CONF_UID = 'uid' +CONF_TITLE = 'title' +CONF_AUDIO = 'audio' +CONF_TEXT = 'text' +CONF_DISPLAY_URL = 'display_url' + +ATTR_UID = 'uid' +ATTR_UPDATE_DATE = 'updateDate' +ATTR_TITLE_TEXT = 'titleText' +ATTR_STREAM_URL = 'streamUrl' +ATTR_MAIN_TEXT = 'mainText' +ATTR_REDIRECTION_URL = 'redirectionURL' + +DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py new file mode 100644 index 00000000000..ec7e3521c0a --- /dev/null +++ b/homeassistant/components/alexa/flash_briefings.py @@ -0,0 +1,96 @@ +""" +Support for Alexa skill service end point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import copy +import logging +from datetime import datetime +import uuid + +from homeassistant.core import callback +from homeassistant.helpers import template +from homeassistant.components import http + +from .const import ( + CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL, ATTR_UID, + ATTR_UPDATE_DATE, ATTR_TITLE_TEXT, ATTR_STREAM_URL, ATTR_MAIN_TEXT, + ATTR_REDIRECTION_URL, DATE_FORMAT) + + +_LOGGER = logging.getLogger(__name__) + +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' + + +@callback +def async_setup(hass, flash_briefing_config): + """Activate Alexa component.""" + hass.http.register_view( + AlexaFlashBriefingView(hass, flash_briefing_config)) + + +class AlexaFlashBriefingView(http.HomeAssistantView): + """Handle Alexa Flash Briefing skill requests.""" + + url = FLASH_BRIEFINGS_API_ENDPOINT + name = 'api:alexa:flash_briefings' + + def __init__(self, hass, flash_briefings): + """Initialize Alexa view.""" + super().__init__() + self.flash_briefings = copy.deepcopy(flash_briefings) + template.attach(hass, self.flash_briefings) + + @callback + def get(self, request, briefing_id): + """Handle Alexa Flash Briefing request.""" + _LOGGER.debug('Received Alexa flash briefing request for: %s', + briefing_id) + + if self.flash_briefings.get(briefing_id) is None: + err = 'No configured Alexa flash briefing was found for: %s' + _LOGGER.error(err, briefing_id) + return b'', 404 + + briefing = [] + + for item in self.flash_briefings.get(briefing_id, []): + output = {} + if item.get(CONF_TITLE) is not None: + if isinstance(item.get(CONF_TITLE), template.Template): + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() + else: + output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) + + if item.get(CONF_TEXT) is not None: + if isinstance(item.get(CONF_TEXT), template.Template): + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() + else: + output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) + + uid = item.get(CONF_UID) + if uid is None: + uid = str(uuid.uuid4()) + output[ATTR_UID] = uid + + if item.get(CONF_AUDIO) is not None: + if isinstance(item.get(CONF_AUDIO), template.Template): + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() + else: + output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) + + if item.get(CONF_DISPLAY_URL) is not None: + if isinstance(item.get(CONF_DISPLAY_URL), + template.Template): + output[ATTR_REDIRECTION_URL] = \ + item[CONF_DISPLAY_URL].async_render() + else: + output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) + + output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + + briefing.append(output) + + return self.json(briefing) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa/intent.py similarity index 60% rename from homeassistant/components/alexa.py rename to homeassistant/components/alexa/intent.py index 25b6537e255..a0d0062414d 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa/intent.py @@ -5,52 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alexa/ """ import asyncio -import copy import enum import logging -import uuid -from datetime import datetime - -import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.helpers import intent from homeassistant.components import http -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' -CONF_ACTION = 'action' -CONF_CARD = 'card' -CONF_INTENTS = 'intents' -CONF_SPEECH = 'speech' - -CONF_TYPE = 'type' -CONF_TITLE = 'title' -CONF_CONTENT = 'content' -CONF_TEXT = 'text' - -CONF_FLASH_BRIEFINGS = 'flash_briefings' -CONF_UID = 'uid' -CONF_TITLE = 'title' -CONF_AUDIO = 'audio' -CONF_TEXT = 'text' -CONF_DISPLAY_URL = 'display_url' - -ATTR_UID = 'uid' -ATTR_UPDATE_DATE = 'updateDate' -ATTR_TITLE_TEXT = 'titleText' -ATTR_STREAM_URL = 'streamUrl' -ATTR_MAIN_TEXT = 'mainText' -ATTR_REDIRECTION_URL = 'redirectionURL' - -DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' - -DOMAIN = 'alexa' -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) class SpeechType(enum.Enum): @@ -73,30 +40,10 @@ class CardType(enum.Enum): link_account = "LinkAccount" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_FLASH_BRIEFINGS: { - cv.string: vol.All(cv.ensure_list, [{ - vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, - vol.Required(CONF_TITLE): cv.template, - vol.Optional(CONF_AUDIO): cv.template, - vol.Required(CONF_TEXT, default=""): cv.template, - vol.Optional(CONF_DISPLAY_URL): cv.template, - }]), - } - } -}, extra=vol.ALLOW_EXTRA) - - -@asyncio.coroutine -def async_setup(hass, config): +@callback +def async_setup(hass): """Activate Alexa component.""" - flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView) - hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) - - return True class AlexaIntentsView(http.HomeAssistantView): @@ -255,66 +202,3 @@ class AlexaResponse(object): 'sessionAttributes': self.session_attributes, 'response': response, } - - -class AlexaFlashBriefingView(http.HomeAssistantView): - """Handle Alexa Flash Briefing skill requests.""" - - url = FLASH_BRIEFINGS_API_ENDPOINT - name = 'api:alexa:flash_briefings' - - def __init__(self, hass, flash_briefings): - """Initialize Alexa view.""" - super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) - template.attach(hass, self.flash_briefings) - - @callback - def get(self, request, briefing_id): - """Handle Alexa Flash Briefing request.""" - _LOGGER.debug('Received Alexa flash briefing request for: %s', - briefing_id) - - if self.flash_briefings.get(briefing_id) is None: - err = 'No configured Alexa flash briefing was found for: %s' - _LOGGER.error(err, briefing_id) - return b'', 404 - - briefing = [] - - for item in self.flash_briefings.get(briefing_id, []): - output = {} - if item.get(CONF_TITLE) is not None: - if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() - else: - output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) - - if item.get(CONF_TEXT) is not None: - if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() - else: - output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) - - if item.get(CONF_UID) is not None: - output[ATTR_UID] = item.get(CONF_UID) - - if item.get(CONF_AUDIO) is not None: - if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() - else: - output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) - - if item.get(CONF_DISPLAY_URL) is not None: - if isinstance(item.get(CONF_DISPLAY_URL), - template.Template): - output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].async_render() - else: - output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) - - briefing.append(output) - - return self.json(briefing) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py new file mode 100644 index 00000000000..61db142ac42 --- /dev/null +++ b/homeassistant/components/alexa/smart_home.py @@ -0,0 +1,203 @@ +"""Support for alexa Smart Home Skill API.""" +import asyncio +import logging +from uuid import uuid4 + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) +from homeassistant.components import switch, light +from homeassistant.util.decorator import Registry + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + +API_DIRECTIVE = 'directive' +API_EVENT = 'event' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_ENDPOINT = 'endpoint' + + +MAPPING_COMPONENT = { + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], + light.DOMAIN: [ + 'LIGHT', ('Alexa.PowerController',), { + light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController' + } + ], +} + + +@asyncio.coroutine +def async_handle_message(hass, message): + """Handle incoming API messages.""" + assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' + + # Read head data + message = message[API_DIRECTIVE] + namespace = message[API_HEADER]['namespace'] + name = message[API_HEADER]['name'] + + # Do we support this API request? + funct_ref = HANDLERS.get((namespace, name)) + if not funct_ref: + _LOGGER.warning( + "Unsupported API request %s/%s", namespace, name) + return api_error(message) + + return (yield from funct_ref(hass, message)) + + +def api_message(request, name='Response', namespace='Alexa', payload=None): + """Create a API formatted response message. + + Async friendly. + """ + payload = payload or {} + + response = { + API_EVENT: { + API_HEADER: { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'payloadVersion': '3', + }, + API_PAYLOAD: payload, + } + } + + # If a correlation token exsits, add it to header / Need by Async requests + token = request[API_HEADER].get('correlationToken') + if token: + response[API_EVENT][API_HEADER]['correlationToken'] = token + + # Extend event with endpoint object / Need by Async requests + if API_ENDPOINT in request: + response[API_EVENT][API_ENDPOINT] = request[API_ENDPOINT].copy() + + return response + + +def api_error(request, error_type='INTERNAL_ERROR', error_message=""): + """Create a API formatted error response. + + Async friendly. + """ + payload = { + 'type': error_type, + 'message': error_message, + } + + return api_message(request, name='ErrorResponse', payload=payload) + + +@HANDLERS.register(('Alexa.Discovery', 'Discover')) +@asyncio.coroutine +def async_api_discovery(hass, request): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [] + + for entity in hass.states.async_all(): + class_data = MAPPING_COMPONENT.get(entity.domain) + + if not class_data: + continue + + endpoint = { + 'displayCategories': [class_data[0]], + 'additionalApplianceDetails': {}, + 'endpointId': entity.entity_id.replace('.', '#'), + 'friendlyName': entity.name, + 'description': '', + 'manufacturerName': 'Unknown', + } + actions = set() + + # static actions + if class_data[1]: + actions |= set(class_data[1]) + + # dynamic actions + if class_data[2]: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature, action_name in class_data[2].items(): + if feature & supported > 0: + actions.add(action_name) + + # Write action into capabilities + capabilities = [] + for action in actions: + capabilities.append({ + 'type': 'AlexaInterface', + 'interface': action, + 'version': 3, + }) + + endpoint['capabilities'] = capabilities + discovery_endpoints.append(endpoint) + + return api_message( + request, name='Discover.Response', namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}) + + +def extract_entity(funct): + """Decorator for extract entity object from request.""" + @asyncio.coroutine + def async_api_entity_wrapper(hass, request): + """Process a turn on request.""" + entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') + + # extract state object + entity = hass.states.get(entity_id) + if not entity: + _LOGGER.error("Can't process %s for %s", + request[API_HEADER]['name'], entity_id) + return api_error(request, error_type='NO_SUCH_ENDPOINT') + + return (yield from funct(hass, request, entity)) + + return async_api_entity_wrapper + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) +@extract_entity +@asyncio.coroutine +def async_api_turn_on(hass, request, entity): + """Process a turn on request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) +@extract_entity +@asyncio.coroutine +def async_api_turn_off(hass, request, entity): + """Process a turn off request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) +@extract_entity +@asyncio.coroutine +def async_api_set_brightness(hass, request, entity): + """Process a set brightness request.""" + brightness = request[API_PAYLOAD]['brightness'] + + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id, + light.ATTR_BRIGHTNESS: brightness, + }, blocking=True) + + return api_message(request) diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 2fb039f0ab3..2883fca9ab6 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -263,7 +263,7 @@ class AndroidIPCamEntity(Entity): """Update callback.""" if self._host != host: return - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index c22683970bf..3b905ab0420 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -13,7 +13,7 @@ import async_timeout import homeassistant.core as ha import homeassistant.remote as rem -from homeassistant.bootstrap import ERROR_LOG_FILENAME +from homeassistant.bootstrap import DATA_LOGGING from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, @@ -51,8 +51,9 @@ def setup(hass, config): hass.http.register_view(APIComponentsView) hass.http.register_view(APITemplateView) - hass.http.register_static_path( - URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False) + log_path = hass.data.get(DATA_LOGGING, None) + if log_path: + hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False) return True diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 7a2ff7610f7..5e02f80f229 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol +from typing import Union, TypeVar, Sequence from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -17,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.4'] +REQUIREMENTS = ['pyatv==0.3.5'] _LOGGER = logging.getLogger(__name__) @@ -45,8 +46,19 @@ NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication' NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' +T = TypeVar('T') + + +# This version of ensure_list interprets an empty dict as no value +def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: + """Wrap value in list if it is not one.""" + if value is None or (isinstance(value, dict) and not value): + return [] + return value if isinstance(value, list) else [value] + + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -133,6 +145,10 @@ def async_setup(hass, config): """Handler for service calls.""" entity_ids = service.data.get(ATTR_ENTITY_ID) + if service.service == SERVICE_SCAN: + hass.async_add_job(scan_for_apple_tvs, hass) + return + if entity_ids: devices = [device for device in hass.data[DATA_ENTITIES] if device.entity_id in entity_ids] @@ -140,16 +156,16 @@ def async_setup(hass, config): devices = hass.data[DATA_ENTITIES] for device in devices: + if service.service != SERVICE_AUTHENTICATE: + continue + atv = device.atv - if service.service == SERVICE_AUTHENTICATE: - credentials = yield from atv.airplay.generate_credentials() - yield from atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) - yield from atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) - elif service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + credentials = yield from atv.airplay.generate_credentials() + yield from atv.airplay.load_credentials(credentials) + _LOGGER.debug('Generated new credentials: %s', credentials) + yield from atv.airplay.start_authentication() + hass.async_add_job(request_configuration, + hass, config, atv, credentials) @asyncio.coroutine def atv_discovered(service, info): diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 1ba2acb4fe0..f3397a884d1 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -1,5 +1,5 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +This component provides support for Netgear Arlo IP cameras. For more details about this component, please refer to the documentation at https://home-assistant.io/components/arlo/ @@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['pyarlo==0.0.4'] +REQUIREMENTS = ['pyarlo==0.0.7'] _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ DEFAULT_BRAND = 'Netgear Arlo' DOMAIN = 'arlo' NOTIFICATION_ID = 'arlo_notification' -NOTIFICATION_TITLE = 'Arlo Camera Setup' +NOTIFICATION_TITLE = 'Arlo Component Setup' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 32d2d245bef..90baeaded14 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.Schema({ vol.Required(CONF_PLATFORM): 'event', vol.Required(CONF_EVENT_TYPE): cv.string, - vol.Optional(CONF_EVENT_DATA): dict, + vol.Optional(CONF_EVENT_DATA, default={}): dict, }) @@ -29,18 +29,24 @@ TRIGGER_SCHEMA = vol.Schema({ def async_trigger(hass, config, action): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - event_data = config.get(CONF_EVENT_DATA) + event_data_schema = vol.Schema( + config.get(CONF_EVENT_DATA), + extra=vol.ALLOW_EXTRA) @callback def handle_event(event): """Listen for events and calls the action when data matches.""" - if not event_data or all(val == event.data.get(key) for key, val - in event_data.items()): - hass.async_run_job(action, { - 'trigger': { - 'platform': 'event', - 'event': event, - }, - }) + try: + event_data_schema(event.data) + except vol.Invalid: + # If event data doesn't match requested schema, skip event + return + + hass.async_run_job(action, { + 'trigger': { + 'platform': 'event', + 'event': event, + }, + }) return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 3657724f679..571888038a6 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import async_track_state_change + CONF_BELOW, CONF_ABOVE, CONF_FOR) +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', vol.Required(CONF_ENTITY_ID): cv.entity_ids, - CONF_BELOW: vol.Coerce(float), - CONF_ABOVE: vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), }), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE)) _LOGGER = logging.getLogger(__name__) @@ -33,15 +35,18 @@ def async_trigger(hass, config, action): entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) + time_delta = config.get(CONF_FOR) value_template = config.get(CONF_VALUE_TEMPLATE) + async_remove_track_same = None + if value_template is not None: value_template.hass = hass @callback - def state_automation_listener(entity, from_s, to_s): - """Listen for state changes and calls action.""" + def check_numeric_state(entity, from_s, to_s): + """Return True if they should trigger.""" if to_s is None: - return + return False variables = { 'trigger': { @@ -55,17 +60,56 @@ def async_trigger(hass, config, action): # If new one doesn't match, nothing to do if not condition.async_numeric_state( hass, to_s, below, above, value_template, variables): + return False + + return True + + @callback + def state_automation_listener(entity, from_s, to_s): + """Listen for state changes and calls action.""" + nonlocal async_remove_track_same + + if not check_numeric_state(entity, from_s, to_s): return + variables = { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': entity, + 'below': below, + 'above': above, + 'from_state': from_s, + 'to_state': to_s, + } + } + # Only match if old didn't exist or existed but didn't match # Written as: skip if old one did exist and matched if from_s is not None and condition.async_numeric_state( hass, from_s, below, above, value_template, variables): return - variables['trigger']['from_state'] = from_s - variables['trigger']['to_state'] = to_s + @callback + def call_action(): + """Call action with right context.""" + hass.async_run_job(action, variables) - hass.async_run_job(action, variables) + if not time_delta: + call_action() + return - return async_track_state_change(hass, entity_id, state_automation_listener) + async_remove_track_same = async_track_same_state( + hass, time_delta, call_action, entity_ids=entity_id, + async_check_same_func=check_numeric_state) + + unsub = async_track_state_change( + hass, entity_id, state_automation_listener) + + @callback + def async_remove(): + """Remove state listeners async.""" + unsub() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable + + return async_remove diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 8ad5c40bb80..7ed44761be8 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -8,28 +8,23 @@ import asyncio import voluptuous as vol from homeassistant.core import callback -import homeassistant.util.dt as dt_util -from homeassistant.const import MATCH_ALL, CONF_PLATFORM +from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR from homeassistant.helpers.event import ( - async_track_state_change, async_track_point_in_utc_time) + async_track_state_change, async_track_same_state) import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_FOR = 'for' -TRIGGER_SCHEMA = vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): 'state', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - # These are str on purpose. Want to catch YAML conversions - CONF_FROM: str, - CONF_TO: str, - CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), - }), - cv.key_dependency(CONF_FOR, CONF_TO), -) +TRIGGER_SCHEMA = vol.All(vol.Schema({ + vol.Required(CONF_PLATFORM): 'state', + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + # These are str on purpose. Want to catch YAML conversions + vol.Optional(CONF_FROM): str, + vol.Optional(CONF_TO): str, + vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta), +}), cv.key_dependency(CONF_FOR, CONF_TO)) @asyncio.coroutine @@ -39,28 +34,15 @@ def async_trigger(hass, config, action): from_state = config.get(CONF_FROM, MATCH_ALL) to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) - async_remove_state_for_cancel = None - async_remove_state_for_listener = None match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) - - @callback - def clear_listener(): - """Clear all unsub listener.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - - # pylint: disable=not-callable - if async_remove_state_for_listener is not None: - async_remove_state_for_listener() - async_remove_state_for_listener = None - if async_remove_state_for_cancel is not None: - async_remove_state_for_cancel() - async_remove_state_for_cancel = None + async_remove_track_same = None @callback def state_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + nonlocal async_remove_track_same + @callback def call_action(): """Call action with right context.""" hass.async_run_job(action, { @@ -78,33 +60,14 @@ def async_trigger(hass, config, action): from_s.last_changed == to_s.last_changed): return - if time_delta is None: + if not time_delta: call_action() return - @callback - def state_for_listener(now): - """Fire on state changes after a delay and calls action.""" - nonlocal async_remove_state_for_listener - async_remove_state_for_listener = None - clear_listener() - call_action() - - @callback - def state_for_cancel_listener(entity, inner_from_s, inner_to_s): - """Fire on changes and cancel for listener if changed.""" - if inner_to_s.state == to_s.state: - return - clear_listener() - - # cleanup previous listener - clear_listener() - - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + time_delta) - - async_remove_state_for_cancel = async_track_state_change( - hass, entity, state_for_cancel_listener) + async_remove_track_same = async_track_same_state( + hass, time_delta, call_action, + lambda _, _2, to_state: to_state.state == to_s.state, + entity_ids=entity_id) unsub = async_track_state_change( hass, entity_id, state_automation_listener, from_state, to_state) @@ -113,6 +76,7 @@ def async_trigger(hass, config, action): def async_remove(): """Remove state listeners async.""" unsub() - clear_listener() + if async_remove_track_same: + async_remove_track_same() # pylint: disable=not-callable return async_remove diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index eaf85937658..aee8dbc415b 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, CONF_HOST, CONF_INCLUDE, CONF_NAME, - CONF_PASSWORD, CONF_TRIGGER_TIME, + CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.discovery import SERVICE_AXIS from homeassistant.helpers import config_validation as cv @@ -23,7 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['axis==8'] +REQUIREMENTS = ['axis==12'] _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(CONF_PORT, default=80): cv.positive_int, vol.Optional(ATTR_LOCATION, default=''): cv.string, }) @@ -76,7 +77,7 @@ SERVICE_SCHEMA = vol.Schema({ }) -def request_configuration(hass, name, host, serialnumber): +def request_configuration(hass, config, name, host, serialnumber): """Request configuration steps from the user.""" configurator = hass.components.configurator @@ -91,15 +92,15 @@ def request_configuration(hass, name, host, serialnumber): if CONF_NAME not in callback_data: callback_data[CONF_NAME] = name try: - config = DEVICE_SCHEMA(callback_data) + device_config = DEVICE_SCHEMA(callback_data) except vol.Invalid: configurator.notify_errors(request_id, "Bad input, please check spelling.") return False - if setup_device(hass, config): + if setup_device(hass, config, device_config): config_file = _read_config(hass) - config_file[serialnumber] = dict(config) + config_file[serialnumber] = dict(device_config) del config_file[serialnumber]['hass'] _write_config(hass, config_file) configurator.request_done(request_id) @@ -132,6 +133,9 @@ def request_configuration(hass, name, host, serialnumber): {'id': ATTR_LOCATION, 'name': "Physical location of device (optional)", 'type': 'text'}, + {'id': CONF_PORT, + 'name': "HTTP port (default=80)", + 'type': 'number'}, {'id': CONF_TRIGGER_TIME, 'name': "Sensor update interval (optional)", 'type': 'number'}, @@ -139,7 +143,7 @@ def request_configuration(hass, name, host, serialnumber): ) -def setup(hass, base_config): +def setup(hass, config): """Common setup for Axis devices.""" def _shutdown(call): # pylint: disable=unused-argument """Stop the metadatastream on shutdown.""" @@ -160,16 +164,17 @@ def setup(hass, base_config): if serialnumber in config_file: # Device config saved to file try: - config = DEVICE_SCHEMA(config_file[serialnumber]) - config[CONF_HOST] = host + device_config = DEVICE_SCHEMA(config_file[serialnumber]) + device_config[CONF_HOST] = host except vol.Invalid as err: _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) return False - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", + device_config[CONF_NAME]) else: # New device, create configuration request for UI - request_configuration(hass, name, host, serialnumber) + request_configuration(hass, config, name, host, serialnumber) else: # Device already registered, but on a different IP device = AXIS_DEVICES[serialnumber] @@ -181,13 +186,13 @@ def setup(hass, base_config): # Register discovery service discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) - if DOMAIN in base_config: - for device in base_config[DOMAIN]: - config = base_config[DOMAIN][device] - if CONF_NAME not in config: - config[CONF_NAME] = device - if not setup_device(hass, config): - _LOGGER.error("Couldn\'t set up %s", config[CONF_NAME]) + if DOMAIN in config: + for device in config[DOMAIN]: + device_config = config[DOMAIN][device] + if CONF_NAME not in device_config: + device_config[CONF_NAME] = device + if not setup_device(hass, config, device_config): + _LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME]) # Services to communicate with device. descriptions = load_yaml_config_file( @@ -215,20 +220,20 @@ def setup(hass, base_config): return True -def setup_device(hass, config): +def setup_device(hass, config, device_config): """Set up device.""" from axis import AxisDevice - config['hass'] = hass - device = AxisDevice(config) # Initialize device + device_config['hass'] = hass + device = AxisDevice(device_config) # Initialize device enable_metadatastream = False if device.serial_number is None: # If there is no serial number a connection could not be made - _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + _LOGGER.error("Couldn\'t connect to %s", device_config[CONF_HOST]) return False - for component in config[CONF_INCLUDE]: + for component in device_config[CONF_INCLUDE]: if component in EVENT_TYPES: # Sensors are created by device calling event_initialized # when receiving initialize messages on metadatastream @@ -236,7 +241,18 @@ def setup_device(hass, config): if not enable_metadatastream: enable_metadatastream = True else: - discovery.load_platform(hass, component, DOMAIN, config) + camera_config = { + CONF_HOST: device_config[CONF_HOST], + CONF_NAME: device_config[CONF_NAME], + CONF_PORT: device_config[CONF_PORT], + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD] + } + discovery.load_platform(hass, + component, + DOMAIN, + camera_config, + config) if enable_metadatastream: device.initialize_new_event = event_initialized diff --git a/homeassistant/components/binary_sensor/abode.py b/homeassistant/components/binary_sensor/abode.py index 9abff53026d..8ad40158958 100644 --- a/homeassistant/components/binary_sensor/abode.py +++ b/homeassistant/components/binary_sensor/abode.py @@ -6,76 +6,69 @@ https://home-assistant.io/components/binary_sensor.abode/ """ import logging -from homeassistant.components.abode import (CONF_ATTRIBUTION, DATA_ABODE) -from homeassistant.const import (ATTR_ATTRIBUTION) -from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.abode import (AbodeDevice, AbodeAutomation, + DOMAIN as ABODE_DOMAIN) +from homeassistant.components.binary_sensor import BinarySensorDevice + DEPENDENCIES = ['abode'] _LOGGER = logging.getLogger(__name__) -# Sensor types: Name, device_class -SENSOR_TYPES = { - 'Door Contact': 'opening', - 'Motion Camera': 'motion', -} - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a sensor for an Abode device.""" - data = hass.data.get(DATA_ABODE) + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE - sensors = [] - for sensor in data.devices: - _LOGGER.debug('Sensor type %s', sensor.type) - if sensor.type in ['Door Contact', 'Motion Camera']: - sensors.append(AbodeBinarySensor(hass, data, sensor)) + data = hass.data[ABODE_DOMAIN] - _LOGGER.debug('Adding %d sensors', len(sensors)) - add_devices(sensors) + device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING] + + devices = [] + for device in data.abode.get_devices(generic_type=device_types): + if data.is_excluded(device): + continue + + devices.append(AbodeBinarySensor(data, device)) + + for automation in data.abode.get_automations( + generic_type=CONST.TYPE_QUICK_ACTION): + if data.is_automation_excluded(automation): + continue + + devices.append(AbodeQuickActionBinarySensor( + data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + + data.devices.extend(devices) + + add_devices(devices) -class AbodeBinarySensor(BinarySensorDevice): +class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): """A binary sensor implementation for Abode device.""" - def __init__(self, hass, data, device): - """Initialize a sensor for Abode device.""" - super(AbodeBinarySensor, self).__init__() - self._device = device - - @property - def should_poll(self): - """Return the polling state.""" - return True - - @property - def name(self): - """Return the name of the sensor.""" - return "{0} {1}".format(self._device.type, self._device.name) - @property def is_on(self): """Return True if the binary sensor is on.""" - if self._device.type == 'Door Contact': - return self._device.status != 'Closed' - elif self._device.type == 'Motion Camera': - return self._device.get_value('motion_event') == '1' + return self._device.is_on @property def device_class(self): """Return the class of the binary sensor.""" - return SENSOR_TYPES.get(self._device.type) + return self._device.generic_type + + +class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): + """A binary sensor implementation for Abode quick action automations.""" + + def trigger(self): + """Trigger a quick automation.""" + self._automation.trigger() @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - attrs['device_id'] = self._device.device_id - attrs['battery_low'] = self._device.battery_low - - return attrs - - def update(self): - """Update the device state.""" - self._device.refresh() + def is_on(self): + """Return True if the binary sensor is on.""" + return self._automation.is_active diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 495feaf64ab..bc05e4d84d8 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -102,11 +102,11 @@ class AlarmDecoderBinarySensor(BinarySensorDevice): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 1 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: self._state = 0 - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py new file mode 100644 index 00000000000..13908fb5472 --- /dev/null +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -0,0 +1,217 @@ +""" +Use Bayesian Inference to trigger a binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.bayesian/ +""" +import asyncio +import logging +from collections import OrderedDict + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + CONF_PLATFORM, CONF_STATE, STATE_UNKNOWN) +from homeassistant.core import callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +CONF_OBSERVATIONS = 'observations' +CONF_PRIOR = 'prior' +CONF_PROBABILITY_THRESHOLD = 'probability_threshold' +CONF_P_GIVEN_F = 'prob_given_false' +CONF_P_GIVEN_T = 'prob_given_true' +CONF_TO_STATE = 'to_state' + +DEFAULT_NAME = 'BayesianBinary' + +NUMERIC_STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: 'numeric_state', + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +STATE_SCHEMA = vol.Schema({ + CONF_PLATFORM: CONF_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TO_STATE): cv.string, + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float) +}, required=True) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): + cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Required(CONF_OBSERVATIONS): vol.Schema( + vol.All(cv.ensure_list, [vol.Any(NUMERIC_STATE_SCHEMA, + STATE_SCHEMA)]) + ), + vol.Required(CONF_PRIOR): vol.Coerce(float), + vol.Optional(CONF_PROBABILITY_THRESHOLD): + vol.Coerce(float), +}) + + +def update_probability(prior, prob_true, prob_false): + """Update probability using Bayes' rule.""" + numerator = prob_true * prior + denominator = numerator + prob_false * (1 - prior) + + probability = numerator / denominator + return probability + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Threshold sensor.""" + name = config.get(CONF_NAME) + observations = config.get(CONF_OBSERVATIONS) + prior = config.get(CONF_PRIOR) + probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD, 0.5) + device_class = config.get(CONF_DEVICE_CLASS) + + async_add_devices([ + BayesianBinarySensor(name, prior, observations, probability_threshold, + device_class) + ], True) + + +class BayesianBinarySensor(BinarySensorDevice): + """Representation of a Bayesian sensor.""" + + def __init__(self, name, prior, observations, probability_threshold, + device_class): + """Initialize the Bayesian sensor.""" + self._name = name + self._observations = observations + self._probability_threshold = probability_threshold + self._device_class = device_class + self._deviation = False + self.prior = prior + self.probability = prior + + self.current_obs = OrderedDict({}) + + to_observe = set(obs['entity_id'] for obs in self._observations) + + self.entity_obs = dict.fromkeys(to_observe, []) + + for ind, obs in enumerate(self._observations): + obs["id"] = ind + self.entity_obs[obs['entity_id']].append(obs) + + self.watchers = { + 'numeric_state': self._process_numeric_state, + 'state': self._process_state + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + @callback + # pylint: disable=invalid-name + def async_threshold_sensor_state_listener(entity, old_state, + new_state): + """Handle sensor state changes.""" + if new_state.state == STATE_UNKNOWN: + return + + entity_obs_list = self.entity_obs[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs['platform'] + + self.watchers[platform](entity_obs) + + prior = self.prior + for obs in self.current_obs.values(): + prior = update_probability(prior, obs['prob_true'], + obs['prob_false']) + self.probability = prior + + self.hass.async_add_job(self.async_update_ha_state, True) + + entities = [obs['entity_id'] for obs in self._observations] + async_track_state_change( + self.hass, entities, async_threshold_sensor_state_listener) + + def _update_current_obs(self, entity_observation, should_trigger): + """Update current observation.""" + obs_id = entity_observation['id'] + + if should_trigger: + prob_true = entity_observation['prob_given_true'] + prob_false = entity_observation.get( + 'prob_given_false', 1 - prob_true) + + self.current_obs[obs_id] = { + 'prob_true': prob_true, + 'prob_false': prob_false + } + + else: + self.current_obs.pop(obs_id, None) + + def _process_numeric_state(self, entity_observation): + """Add entity to current_obs if numeric state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.async_numeric_state( + self.hass, entity, + entity_observation.get('below'), + entity_observation.get('above'), None, entity_observation) + + self._update_current_obs(entity_observation, should_trigger) + + def _process_state(self, entity_observation): + """Add entity to current observations if state conditions are met.""" + entity = entity_observation['entity_id'] + + should_trigger = condition.state( + self.hass, entity, entity_observation.get('to_state')) + + self._update_current_obs(entity_observation, should_trigger) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._deviation + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class of the sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + 'observations': [val for val in self.current_obs.values()], + 'probability': round(self.probability, 2), + 'probability_threshold': self._probability_threshold + } + + @asyncio.coroutine + def async_update(self): + """Get the latest data and update the states.""" + self._deviation = bool(self.probability > self._probability_threshold) diff --git a/homeassistant/components/binary_sensor/doorbird.py b/homeassistant/components/binary_sensor/doorbird.py new file mode 100644 index 00000000000..9a13687fc54 --- /dev/null +++ b/homeassistant/components/binary_sensor/doorbird.py @@ -0,0 +1,60 @@ +"""Support for reading binary states from a DoorBird video doorbell.""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.util import Throttle + +DEPENDENCIES = ['doorbird'] + +_LOGGER = logging.getLogger(__name__) +_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250) + +SENSOR_TYPES = { + "doorbell": { + "name": "Doorbell Ringing", + "icon": { + True: "bell-ring", + False: "bell", + None: "bell-outline" + } + } +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DoorBird binary sensor component.""" + device = hass.data.get(DOORBIRD_DOMAIN) + add_devices([DoorBirdBinarySensor(device, "doorbell")], True) + + +class DoorBirdBinarySensor(BinarySensorDevice): + """A binary sensor of a DoorBird device.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor on a DoorBird device.""" + self._device = device + self._sensor_type = sensor_type + self._state = None + + @property + def name(self): + """Get the name of the sensor.""" + return SENSOR_TYPES[self._sensor_type]["name"] + + @property + def icon(self): + """Get an icon to display.""" + state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state] + return "mdi:{}".format(state_icon) + + @property + def is_on(self): + """Get the state of the binary sensor.""" + return self._state + + @Throttle(_MIN_UPDATE_INTERVAL) + def update(self): + """Pull the latest value from the device.""" + self._state = self._device.doorbell_state() diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 5fbc1eb90a1..7d35c0c9e94 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -80,4 +80,4 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 1bbf39dd6e0..47b1be988bf 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -73,7 +73,7 @@ class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @property def is_on(self): diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab8..2f464bc73cc 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 448ceae8636..0702ce8bb9e 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -55,12 +55,12 @@ class InsteonPLMBinarySensorDevice(BinarySensorDevice): @property def address(self): - """Return the the address of the node.""" + """Return the address of the node.""" return self._address @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self._name @property diff --git a/homeassistant/components/binary_sensor/iss.py b/homeassistant/components/binary_sensor/iss.py index 3b927853c00..d35c36a012e 100644 --- a/homeassistant/components/binary_sensor/iss.py +++ b/homeassistant/components/binary_sensor/iss.py @@ -13,7 +13,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE) +from homeassistant.const import ( + CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP) from homeassistant.util import Throttle REQUIREMENTS = ['pyiss==1.0.1'] @@ -23,8 +24,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_ISS_NEXT_RISE = 'next_rise' ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space' -CONF_SHOW_ON_MAP = 'show_on_map' - DEFAULT_NAME = 'ISS' DEFAULT_DEVICE_CLASS = 'visible' diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c..406f60f99bb 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up binary sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False - pass + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up binary sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXBinarySensor(hass, device)) + async_add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up binary senor for KNX platform configured within plattform.""" + name = config.get(CONF_NAME) + import xknx + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + async_add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 3702b32d586..c5fba72bde0 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,14 +16,21 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS) -from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS) +from homeassistant.components.mqtt import ( + CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' + DEFAULT_NAME = 'MQTT Binary sensor' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' + DEPENDENCIES = ['mqtt'] PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ @@ -31,6 +38,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, }) @@ -47,10 +59,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_DEVICE_CLASS), config.get(CONF_QOS), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), value_template )]) @@ -58,15 +73,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttBinarySensor(BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, device_class, qos, payload_on, - payload_off, value_template): + def __init__(self, name, state_topic, availability_topic, device_class, + qos, payload_on, payload_off, payload_available, + payload_not_available, value_template): """Initialize the MQTT binary sensor.""" self._name = name - self._state = False + self._state = None self._state_topic = state_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._device_class = device_class self._payload_on = payload_on self._payload_off = payload_off + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._qos = qos self._template = value_template @@ -76,8 +96,8 @@ class MqttBinarySensor(BinarySensorDevice): This method must be run in the event loop and returns a coroutine. """ @callback - def message_received(topic, payload, qos): - """Handle a new received MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle a new received MQTT state message.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -86,10 +106,25 @@ class MqttBinarySensor(BinarySensorDevice): elif payload == self._payload_off: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() - return mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + yield from mqtt.async_subscribe( + self.hass, self._state_topic, state_message_received, self._qos) + + @callback + def availability_message_received(topic, payload, qos): + """Handle a new received MQTT availability message.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) @property def should_poll(self): @@ -101,6 +136,11 @@ class MqttBinarySensor(BinarySensorDevice): """Return the name of the binary sensor.""" return self._name + @property + def available(self) -> bool: + """Return if the binary sensor is available.""" + return self._available + @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 767ed858ec7..4b83f0c8f2d 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -4,62 +4,27 @@ Support for MySensors binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.binary_sensor import (DEVICE_CLASSES, +from homeassistant.components.binary_sensor import (DEVICE_CLASSES, DOMAIN, BinarySensorDevice) from homeassistant.const import STATE_ON -_LOGGER = logging.getLogger(__name__) -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for sensors.""" - # Only act if loaded via mysensors by discovery event. - # Otherwise gateway is not setup. - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - # Define the S_TYPES and V_TYPES that the platform should handle as - # states. Map them in a dict of lists. - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_DOOR: [set_req.V_TRIPPED], - pres.S_MOTION: [set_req.V_TRIPPED], - pres.S_SMOKE: [set_req.V_TRIPPED], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_SPRINKLER: [set_req.V_TRIPPED], - pres.S_WATER_LEAK: [set_req.V_TRIPPED], - pres.S_SOUND: [set_req.V_TRIPPED], - pres.S_VIBRATION: [set_req.V_TRIPPED], - pres.S_MOISTURE: [set_req.V_TRIPPED], - }) - - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsBinarySensor, add_devices)) + """Setup the mysensors platform for binary sensors.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsBinarySensor, + add_devices=add_devices) class MySensorsBinarySensor( - mysensors.MySensorsDeviceEntity, BinarySensorDevice): + mysensors.MySensorsEntity, BinarySensorDevice): """Represent the value of a MySensors Binary Sensor child node.""" @property def is_on(self): """Return True if the binary sensor is on.""" - if self.value_type in self._values: - return self._values[self.value_type] == STATE_ON - return False + return self._values.get(self.value_type) == STATE_ON @property def device_class(self): diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 08ab1f4a8b7..2afaa032745 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -92,4 +92,4 @@ class MyStromBinarySensor(BinarySensorDevice): def async_on_update(self, value): """Receive an update.""" self._state = value - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py new file mode 100644 index 00000000000..f75f7644c4e --- /dev/null +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -0,0 +1,72 @@ +""" +Support for Melnor RainCloud sprinkler water timer. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.raincloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.raincloud import ( + BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['raincloud'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a raincloud device.""" + raincloud = hass.data[DATA_RAINCLOUD].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type == 'status': + sensors.append( + RainCloudBinarySensor(raincloud.controller, sensor_type)) + sensors.append( + RainCloudBinarySensor(raincloud.controller.faucet, + sensor_type)) + + else: + # create an sensor for each zone managed by faucet + for zone in raincloud.controller.faucet.zones: + sensors.append(RainCloudBinarySensor(zone, sensor_type)) + + add_devices(sensors, True) + return True + + +class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice): + """A sensor implementation for raincloud device.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating RainCloud sensor: %s", self._name) + self._state = getattr(self.data, self._sensor_type) + if self._sensor_type == 'status': + self._state = self._state == 'Online' + + @property + def icon(self): + """Return the icon of this device.""" + if self._sensor_type == 'is_watering': + return 'mdi:water' if self.is_on else 'mdi:water-off' + elif self._sensor_type == 'status': + return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected' + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7f..5c9a644f6b7 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ class RingBinarySensor(BinarySensorDevice): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False diff --git a/homeassistant/components/binary_sensor/satel_integra.py b/homeassistant/components/binary_sensor/satel_integra.py new file mode 100644 index 00000000000..f373809f7c0 --- /dev/null +++ b/homeassistant/components/binary_sensor/satel_integra.py @@ -0,0 +1,90 @@ +""" +Support for Satel Integra zone states- represented as binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.satel_integra/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.satel_integra import (CONF_ZONES, + CONF_ZONE_NAME, + CONF_ZONE_TYPE, + SIGNAL_ZONES_UPDATED) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['satel_integra'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Satel Integra binary sensor devices.""" + if not discovery_info: + return + + configured_zones = discovery_info[CONF_ZONES] + + devices = [] + + for zone_num, device_config_data in configured_zones.items(): + zone_type = device_config_data[CONF_ZONE_TYPE] + zone_name = device_config_data[CONF_ZONE_NAME] + device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type) + devices.append(device) + + async_add_devices(devices) + + +class SatelIntegraBinarySensor(BinarySensorDevice): + """Representation of an Satel Integra binary sensor.""" + + def __init__(self, zone_number, zone_name, zone_type): + """Initialize the binary_sensor.""" + self._zone_number = zone_number + self._name = zone_name + self._zone_type = zone_type + self._state = 0 + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated) + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Icon for device by its type.""" + if self._zone_type == 'smoke': + return "mdi:fire" + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state == 1 + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + return self._zone_type + + @callback + def _zones_updated(self, zones): + """Update the zone's state, if needed.""" + if self._zone_number in zones \ + and self._state != zones[self._zone_number]: + self._state = zones[self._zone_number] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/skybell.py b/homeassistant/components/binary_sensor/skybell.py new file mode 100644 index 00000000000..734f8e03375 --- /dev/null +++ b/homeassistant/components/binary_sensor/skybell.py @@ -0,0 +1,97 @@ +""" +Binary sensor support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.skybell/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.skybell import ( + DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice) +from homeassistant.const import ( + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + +# Sensor types: Name, device_class, event +SENSOR_TYPES = { + 'button': ['Button', 'occupancy', 'device:sensor:button'], + 'motion': ['Motion', 'motion', 'device:sensor:motion'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in skybell.get_devices(): + sensors.append(SkybellBinarySensor(device, sensor_type)) + + add_devices(sensors, True) + + +class SkybellBinarySensor(SkybellDevice, BinarySensorDevice): + """A binary sensor implementation for Skybell devices.""" + + def __init__(self, device, sensor_type): + """Initialize a binary sensor for a Skybell device.""" + super().__init__(device) + self._sensor_type = sensor_type + self._name = "{0} {1}".format(self._device.name, + SENSOR_TYPES[self._sensor_type][0]) + self._device_class = SENSOR_TYPES[self._sensor_type][1] + self._event = {} + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = super().device_state_attributes + + attrs['event_date'] = self._event.get('createdAt') + + return attrs + + def update(self): + """Get the latest data and updates the state.""" + super().update() + + event = self._device.latest(SENSOR_TYPES[self._sensor_type][2]) + + self._state = bool(event and event.get('id') != self._event.get('id')) + + self._event = event diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index 8023e1cf4b3..af3669c2b15 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -41,14 +41,14 @@ def _create_sensor(hass, zone): @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Initialize the platform.""" if (discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None): return - async_add_entities( + async_add_devices( _create_sensor(hass, zone) for zone in discovery_info[ATTR_DISCOVER_DEVICES] if _get_device_class(zone['type'])) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 330e8eaea9d..9356d87d7ea 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.const import ( from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice): self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.async_schedule_update_ha_state() + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, period, set_state, entity_ids=self._entities, + async_check_same_func=lambda *args: self._async_render() == state) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 00000000000..a7cda90b3f6 --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,56 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 866e16ecbe2..5ca037767f2 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -20,15 +20,18 @@ from homeassistant.helpers.event import async_track_state_change _LOGGER = logging.getLogger(__name__) +ATTR_HYSTERESIS = 'hysteresis' ATTR_SENSOR_VALUE = 'sensor_value' ATTR_THRESHOLD = 'threshold' ATTR_TYPE = 'type' +CONF_HYSTERESIS = 'hysteresis' CONF_LOWER = 'lower' CONF_THRESHOLD = 'threshold' CONF_UPPER = 'upper' DEFAULT_NAME = 'Threshold' +DEFAULT_HYSTERESIS = 0.0 SENSOR_TYPES = [CONF_LOWER, CONF_UPPER] @@ -36,6 +39,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_THRESHOLD): vol.Coerce(float), vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), + vol.Optional( + CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, }) @@ -47,28 +52,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) threshold = config.get(CONF_THRESHOLD) + hysteresis = config.get(CONF_HYSTERESIS) limit_type = config.get(CONF_TYPE) device_class = config.get(CONF_DEVICE_CLASS) - async_add_devices( - [ThresholdSensor(hass, entity_id, name, threshold, limit_type, - device_class)], True) + async_add_devices([ThresholdSensor( + hass, entity_id, name, threshold, + hysteresis, limit_type, device_class) + ], True) + return True class ThresholdSensor(BinarySensorDevice): """Representation of a Threshold sensor.""" - def __init__(self, hass, entity_id, name, threshold, limit_type, - device_class): + def __init__(self, hass, entity_id, name, threshold, + hysteresis, limit_type, device_class): """Initialize the Threshold sensor.""" self._hass = hass self._entity_id = entity_id self.is_upper = limit_type == 'upper' self._name = name self._threshold = threshold + self._hysteresis = hysteresis self._device_class = device_class - self._deviation = False + self._state = False self.sensor_value = 0 @callback @@ -97,7 +106,7 @@ class ThresholdSensor(BinarySensorDevice): @property def is_on(self): """Return true if sensor is on.""" - return self._deviation + return self._state @property def should_poll(self): @@ -116,13 +125,16 @@ class ThresholdSensor(BinarySensorDevice): ATTR_ENTITY_ID: self._entity_id, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_THRESHOLD: self._threshold, + ATTR_HYSTERESIS: self._hysteresis, ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER, } @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" - if self.is_upper: - self._deviation = bool(self.sensor_value > self._threshold) - else: - self._deviation = bool(self.sensor_value < self._threshold) + if self._hysteresis == 0 and self.sensor_value == self._threshold: + self._state = False + elif self.sensor_value > (self._threshold + self._hysteresis): + self._state = self.is_upper + elif self.sensor_value < (self._threshold - self._hysteresis): + self._state = not self.is_upper diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index b4910687da7..05de0b51aa8 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -136,8 +136,9 @@ class WinkHub(WinkBinarySensorDevice): def device_state_attributes(self): """Return the state attributes.""" return { - 'update needed': self.wink.update_needed(), - 'firmware version': self.wink.firmware_version() + 'update_needed': self.wink.update_needed(), + 'firmware_version': self.wink.firmware_version(), + 'pairing_mode': self.wink.pairing_mode() } diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py similarity index 89% rename from homeassistant/components/binary_sensor/xiaomi.py rename to homeassistant/components/binary_sensor/xiaomi_aqara.py index fafdc098c5d..d60d265b849 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -1,8 +1,9 @@ -"""Support for Xiaomi binary sensors.""" +"""Support for Xiaomi aqara binary sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) @@ -31,6 +32,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(XiaomiDoorSensor(device, gateway)) elif model == 'sensor_magnet.aq2': devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'sensor_wleak.aq1': + devices.append(XiaomiWaterLeakSensor(device, gateway)) elif model == 'smoke': devices.append(XiaomiSmokeSensor(device, gateway)) elif model == 'natgas': @@ -214,6 +217,35 @@ class XiaomiDoorSensor(XiaomiBinarySensor): return False +class XiaomiWaterLeakSensor(XiaomiBinarySensor): + """Representation of a XiaomiWaterLeakSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiWaterLeakSensor.""" + XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', + xiaomi_hub, 'status', 'moisture') + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'leak': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'no_leak': + if self._state: + self._state = False + return True + return False + + class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 4e088c8a640..5198381b976 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -12,6 +12,7 @@ import re from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.config_validation import time_period_str from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml new file mode 100644 index 00000000000..952e2302091 --- /dev/null +++ b/homeassistant/components/calendar/services.yaml @@ -0,0 +1,19 @@ +todoist: + new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. [Required] + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. [Optional] + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. [Optional] + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional] + example: 2 + due_date: + description: The day this task is due, in format YYYY-MM-DD. [Optional] + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py new file mode 100644 index 00000000000..eb9f0a2677e --- /dev/null +++ b/homeassistant/components/calendar/todoist.py @@ -0,0 +1,544 @@ +""" +Support for Todoist task management (https://todoist.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/calendar.todoist/ +""" + + +from datetime import datetime +from datetime import timedelta +import logging +import os + +import voluptuous as vol + +from homeassistant.components.calendar import ( + CalendarEventDevice, PLATFORM_SCHEMA) +from homeassistant.components.google import ( + CONF_DEVICE_ID) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_ID, CONF_NAME, CONF_TOKEN) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util import dt +from homeassistant.util import Throttle + +REQUIREMENTS = ['todoist-python==7.0.17'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'todoist' + +# Calendar Platform: Does this calendar event last all day? +ALL_DAY = 'all_day' +# Attribute: All tasks in this project +ALL_TASKS = 'all_tasks' +# Todoist API: "Completed" flag -- 1 if complete, else 0 +CHECKED = 'checked' +# Attribute: Is this task complete? +COMPLETED = 'completed' +# Todoist API: What is this task about? +# Service Call: What is this task about? +CONTENT = 'content' +# Calendar Platform: Get a calendar event's description +DESCRIPTION = 'description' +# Calendar Platform: Used in the '_get_date()' method +DATETIME = 'dateTime' +# Attribute: When is this task due? +# Service Call: When is this task due? +DUE_DATE = 'due_date' +# Todoist API: Look up a task's due date +DUE_DATE_UTC = 'due_date_utc' +# Attribute: Is this task due today? +DUE_TODAY = 'due_today' +# Calendar Platform: When a calendar event ends +END = 'end' +# Todoist API: Look up a Project/Label/Task ID +ID = 'id' +# Todoist API: Fetch all labels +# Service Call: What are the labels attached to this task? +LABELS = 'labels' +# Todoist API: "Name" value +NAME = 'name' +# Attribute: Is this task overdue? +OVERDUE = 'overdue' +# Attribute: What is this task's priority? +# Todoist API: Get a task's priority +# Service Call: What is this task's priority? +PRIORITY = 'priority' +# Todoist API: Look up the Project ID a Task belongs to +PROJECT_ID = 'project_id' +# Service Call: What Project do you want a Task added to? +PROJECT_NAME = 'project' +# Todoist API: Fetch all Projects +PROJECTS = 'projects' +# Calendar Platform: When does a calendar event start? +START = 'start' +# Calendar Platform: What is the next calendar event about? +SUMMARY = 'summary' +# Todoist API: Fetch all Tasks +TASKS = 'items' + +SERVICE_NEW_TASK = 'new_task' +NEW_TASK_SERVICE_SCHEMA = vol.Schema({ + vol.Required(CONTENT): cv.string, + vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), + vol.Optional(LABELS): cv.ensure_list_csv, + vol.Optional(PRIORITY): vol.All(vol.Coerce(int), + vol.Range(min=1, max=4)), + vol.Optional(DUE_DATE): cv.string +}) + +CONF_EXTRA_PROJECTS = 'custom_projects' +CONF_PROJECT_DUE_DATE = 'due_date_days' +CONF_PROJECT_WHITELIST = 'include_projects' +CONF_PROJECT_LABEL_WHITELIST = 'labels' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXTRA_PROJECTS, default=[]): + vol.All(cv.ensure_list, vol.Schema([ + vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int), + vol.Optional(CONF_PROJECT_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]): + vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]) + }) + ])) +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Todoist platform.""" + # Check token: + token = config.get(CONF_TOKEN) + + # Look up IDs based on (lowercase) names. + project_id_lookup = {} + label_id_lookup = {} + + from todoist.api import TodoistAPI + api = TodoistAPI(token) + api.sync() + + # Setup devices: + # Grab all projects. + projects = api.state[PROJECTS] + + # Grab all labels + labels = api.state[LABELS] + + # Add all Todoist-defined projects. + project_devices = [] + for project in projects: + # Project is an object, not a dict! + # Because of that, we convert what we need to a dict. + project_data = { + CONF_NAME: project[NAME], + CONF_ID: project[ID] + } + project_devices.append( + TodoistProjectDevice(hass, project_data, labels, api) + ) + # Cache the names so we can easily look up name->ID. + project_id_lookup[project[NAME].lower()] = project[ID] + + # Cache all label names + for label in labels: + label_id_lookup[label[NAME].lower()] = label[ID] + + # Check config for more projects. + extra_projects = config.get(CONF_EXTRA_PROJECTS) + for project in extra_projects: + # Special filter: By date + project_due_date = project.get(CONF_PROJECT_DUE_DATE) + + # Special filter: By label + project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST) + + # Special filter: By name + # Names must be converted into IDs. + project_name_filter = project.get(CONF_PROJECT_WHITELIST) + project_id_filter = [ + project_id_lookup[project_name.lower()] + for project_name in project_name_filter] + + # Create the custom project and add it to the devices array. + project_devices.append( + TodoistProjectDevice( + hass, project, labels, api, project_due_date, + project_label_filter, project_id_filter + ) + ) + + add_devices(project_devices) + + # Services: + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def handle_new_task(call): + """Called when a user creates a new Todoist Task from HASS.""" + project_name = call.data[PROJECT_NAME] + project_id = project_id_lookup[project_name] + + # Create the task + item = api.items.add(call.data[CONTENT], project_id) + + if LABELS in call.data: + task_labels = call.data[LABELS] + label_ids = [ + label_id_lookup[label.lower()] + for label in task_labels] + item.update(labels=label_ids) + + if PRIORITY in call.data: + item.update(priority=call.data[PRIORITY]) + + if DUE_DATE in call.data: + due_date = dt.parse_datetime(call.data[DUE_DATE]) + if due_date is None: + due = dt.parse_date(call.data[DUE_DATE]) + due_date = datetime(due.year, due.month, due.day) + # Format it in the manner Todoist expects + due_date = dt.as_utc(due_date) + date_format = '%Y-%m-%dT%H:%M' + due_date = datetime.strftime(due_date, date_format) + item.update(due_date_utc=due_date) + # Commit changes + api.commit() + _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT]) + + hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task, + descriptions[DOMAIN][SERVICE_NEW_TASK], + schema=NEW_TASK_SERVICE_SCHEMA) + + +class TodoistProjectDevice(CalendarEventDevice): + """A device for getting the next Task from a Todoist Project.""" + + def __init__(self, hass, data, labels, token, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Create the Todoist Calendar Event Device.""" + self.data = TodoistProjectData( + data, labels, token, latest_task_due_date, + whitelisted_labels, whitelisted_projects + ) + + # Set up the calendar side of things + calendar_format = { + CONF_NAME: data[CONF_NAME], + # Set Entity ID to use the name so we can identify calendars + CONF_DEVICE_ID: data[CONF_NAME] + } + + super().__init__(hass, calendar_format) + + def update(self): + """Update all Todoist Calendars.""" + # Set basic calendar data + super().update() + + # Set Todoist-specific data that can't easily be grabbed + self._cal_data[ALL_TASKS] = [ + task[SUMMARY] for task in self.data.all_project_tasks] + + def cleanup(self): + """Clean up all calendar data.""" + super().cleanup() + self._cal_data[ALL_TASKS] = [] + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.data.event is None: + # No tasks, we don't REALLY need to show anything. + return {} + + attributes = super().device_state_attributes + + # Add additional attributes. + attributes[DUE_TODAY] = self.data.event[DUE_TODAY] + attributes[OVERDUE] = self.data.event[OVERDUE] + attributes[ALL_TASKS] = self._cal_data[ALL_TASKS] + attributes[PRIORITY] = self.data.event[PRIORITY] + attributes[LABELS] = self.data.event[LABELS] + + return attributes + + +class TodoistProjectData(object): + """ + Class used by the Task Device service object to hold all Todoist Tasks. + + This is analogous to the GoogleCalendarData found in the Google Calendar + component. + + Takes an object with a 'name' field and optionally an 'id' field (either + user-defined or from the Todoist API), a Todoist API token, and an optional + integer specifying the latest number of days from now a task can be due (7 + means everything due in the next week, 0 means today, etc.). + + This object has an exposed 'event' property (used by the Calendar platform + to determine the next calendar event) and an exposed 'update' method (used + by the Calendar platform to poll for new calendar events). + + The 'event' is a representation of a Todoist Task, with defined parameters + of 'due_today' (is the task due today?), 'all_day' (does the task have a + due date?), 'task_labels' (all labels assigned to the task), 'message' + (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing + to the task on the Todoist website), 'end_time' (what time the event is + due), 'start_time' (what time this event was last updated), 'overdue' (is + the task past its due date?), 'priority' (1-4, how important the task is, + with 4 being the most important), and 'all_tasks' (all tasks in this + project, sorted by how important they are). + + 'offset_reached', 'location', and 'friendly_name' are defined by the + platform itself, but are not used by this component at all. + + The 'update' method polls the Todoist API for new projects/tasks, as well + as any updates to current projects/tasks. This is throttled to every + MIN_TIME_BETWEEN_UPDATES minutes. + """ + + def __init__(self, project_data, labels, api, + latest_task_due_date=None, whitelisted_labels=None, + whitelisted_projects=None): + """Initialize a Todoist Project.""" + self.event = None + + self._api = api + self._name = project_data.get(CONF_NAME) + # If no ID is defined, fetch all tasks. + self._id = project_data.get(CONF_ID) + + # All labels the user has defined, for easy lookup. + self._labels = labels + # Not tracked: order, indent, comment_count. + + self.all_project_tasks = [] + + # The latest date a task can be due (for making lists of everything + # due today, or everything due in the next week, for example). + if latest_task_due_date is not None: + self._latest_due_date = dt.utcnow() + timedelta( + days=latest_task_due_date) + else: + self._latest_due_date = None + + # Only tasks with one of these labels will be included. + if whitelisted_labels is not None: + self._label_whitelist = whitelisted_labels + else: + self._label_whitelist = [] + + # This project includes only projects with these names. + if whitelisted_projects is not None: + self._project_id_whitelist = whitelisted_projects + else: + self._project_id_whitelist = [] + + def create_todoist_task(self, data): + """ + Create a dictionary based on a Task passed from the Todoist API. + + Will return 'None' if the task is to be filtered out. + """ + task = {} + # Fields are required to be in all returned task objects. + task[SUMMARY] = data[CONTENT] + task[COMPLETED] = data[CHECKED] == 1 + task[PRIORITY] = data[PRIORITY] + task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format( + data[ID]) + + # All task Labels (optional parameter). + task[LABELS] = [ + label[NAME].lower() for label in self._labels + if label[ID] in data[LABELS]] + + if self._label_whitelist and ( + not any(label in task[LABELS] + for label in self._label_whitelist)): + # We're not on the whitelist, return invalid task. + return None + + # Due dates (optional parameter). + # The due date is the END date -- the task cannot be completed + # past this time. + # That means that the START date is the earliest time one can + # complete the task. + # Generally speaking, that means right now. + task[START] = dt.utcnow() + if data[DUE_DATE_UTC] is not None: + due_date = data[DUE_DATE_UTC] + + # Due dates are represented in RFC3339 format, in UTC. + # Home Assistant exclusively uses UTC, so it'll + # handle the conversion. + time_format = '%a %d %b %Y %H:%M:%S %z' + # HASS' built-in parse time function doesn't like + # Todoist's time format; strptime has to be used. + task[END] = datetime.strptime(due_date, time_format) + + if self._latest_due_date is not None and ( + task[END] > self._latest_due_date): + # This task is out of range of our due date; + # it shouldn't be counted. + return None + + task[DUE_TODAY] = task[END].date() == datetime.today().date() + + # Special case: Task is overdue. + if task[END] <= task[START]: + task[OVERDUE] = True + # Set end time to the current time plus 1 hour. + # We're pretty much guaranteed to update within that 1 hour, + # so it should be fine. + task[END] = task[START] + timedelta(hours=1) + else: + task[OVERDUE] = False + else: + # If we ask for everything due before a certain date, don't count + # things which have no due dates. + if self._latest_due_date is not None: + return None + + # Define values for tasks without due dates + task[END] = None + task[ALL_DAY] = True + task[DUE_TODAY] = False + task[OVERDUE] = False + + # Not tracked: id, comments, project_id order, indent, recurring. + return task + + @staticmethod + def select_best_task(project_tasks): + """ + Search through a list of events for the "best" event to select. + + The "best" event is determined by the following criteria: + * A proposed event must not be completed + * A proposed event must have a end date (otherwise we go with + the event at index 0, selected above) + * A proposed event must be on the same day or earlier as our + current event + * If a proposed event is an earlier day than what we have so + far, select it + * If a proposed event is on the same day as our current event + and the proposed event has a higher priority than our current + event, select it + * If a proposed event is on the same day as our current event, + has the same priority as our current event, but is due earlier + in the day, select it + """ + # Start at the end of the list, so if tasks don't have a due date + # the newest ones are the most important. + + event = project_tasks[-1] + + for proposed_event in project_tasks: + if event == proposed_event: + continue + if proposed_event[COMPLETED]: + # Event is complete! + continue + if proposed_event[END] is None: + # No end time: + if event[END] is None and ( + proposed_event[PRIORITY] < event[PRIORITY]): + # They also have no end time, + # but we have a higher priority. + event = proposed_event + continue + else: + continue + elif event[END] is None: + # We have an end time, they do not. + event = proposed_event + continue + if proposed_event[END].date() > event[END].date(): + # Event is too late. + continue + elif proposed_event[END].date() < event[END].date(): + # Event is earlier than current, select it. + event = proposed_event + continue + else: + if proposed_event[PRIORITY] > event[PRIORITY]: + # Proposed event has a higher priority. + event = proposed_event + continue + elif proposed_event[PRIORITY] == event[PRIORITY] and ( + proposed_event[END] < event[END]): + event = proposed_event + continue + return event + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + # If we have no data, we can just return right away. + if not project_task_data: + self.event = None + return True + + # Keep an updated list of all tasks in this project. + project_tasks = [] + + for task in project_task_data: + todoist_task = self.create_todoist_task(task) + if todoist_task is not None: + # A None task means it is invalid for this project + project_tasks.append(todoist_task) + + if not project_tasks: + # We had no valid tasks + return True + + # Organize the best tasks (so users can see all the tasks + # they have, organized) + while len(project_tasks) > 0: + best_task = self.select_best_task(project_tasks) + _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) + project_tasks.remove(best_task) + self.all_project_tasks.append(best_task) + + self.event = self.all_project_tasks[0] + + # Convert datetime to a string again + if self.event is not None: + if self.event[START] is not None: + self.event[START] = { + DATETIME: self.event[START].strftime(DATE_STR_FORMAT) + } + if self.event[END] is not None: + self.event[END] = { + DATETIME: self.event[END].strftime(DATE_STR_FORMAT) + } + else: + # HASS gets cranky if a calendar event never ends + # Let's set our "due date" to tomorrow + self.event[END] = { + DATETIME: ( + datetime.utcnow() + + timedelta(days=1) + ).strftime(DATE_STR_FORMAT) + } + _LOGGER.debug("Updated %s", self._name) + return True diff --git a/homeassistant/components/camera/abode.py b/homeassistant/components/camera/abode.py new file mode 100644 index 00000000000..3c0c0a54e0e --- /dev/null +++ b/homeassistant/components/camera/abode.py @@ -0,0 +1,101 @@ +""" +This component provides HA camera support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.abode/ +""" +import asyncio +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + + +DEPENDENCIES = ['abode'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discoveryy_info=None): + """Set up Abode camera devices.""" + import abodepy.helpers.constants as CONST + import abodepy.helpers.timeline as TIMELINE + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA): + if data.is_excluded(device): + continue + + devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCamera(AbodeDevice, Camera): + """Representation of an Abode camera.""" + + def __init__(self, data, device, event): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, data, device) + Camera.__init__(self) + self._event = event + self._response = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe Abode events.""" + yield from super().async_added_to_hass() + + self.hass.async_add_job( + self._data.abode.events.add_timeline_callback, + self._event, self._capture_callback + ) + + def capture(self): + """Request a new image capture.""" + return self._device.capture() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def refresh_image(self): + """Find a new image on the timeline.""" + if self._device.refresh_image(): + self.get_image() + + def get_image(self): + """Attempt to download the most recent capture.""" + if self._device.image_url: + try: + self._response = requests.get( + self._device.image_url, stream=True) + + self._response.raise_for_status() + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + else: + self._response = None + + def camera_image(self): + """Get a camera image.""" + self.refresh_image() + + if self._response: + return self._response.content + + return None + + def _capture_callback(self, capture): + """Update the image with the device then refresh device.""" + self._device.update_image_location(capture) + self.get_image() + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 51b8ff13906..aba1bb08c93 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -62,7 +62,7 @@ class AmcrestCam(Camera): self._token = self._auth = authentication def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data response = self._camera.snapshot(channel=self._resolution) return response.data diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index 80833e34b20..be58b61fb8c 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -1,30 +1,50 @@ """ -This component provides basic support for Netgear Arlo IP cameras. +Support for Netgear Arlo IP cameras. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.arlo/ """ import asyncio import logging +from datetime import timedelta import voluptuous as vol -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG - -DEPENDENCIES = ['arlo', 'ffmpeg'] +from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +SCAN_INTERVAL = timedelta(minutes=10) + ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' +ATTR_BRIGHTNESS = 'brightness' +ATTR_FLIPPED = 'flipped' +ATTR_MIRRORED = 'mirrored' +ATTR_MOTION = 'motion_detection_sensitivity' +ATTR_POWERSAVE = 'power_save_mode' +ATTR_SIGNAL_STRENGTH = 'signal_strength' +ATTR_UNSEEN_VIDEOS = 'unseen_videos' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +DEPENDENCIES = ['arlo', 'ffmpeg'] + +POWERSAVE_MODE_MAPPING = { + 1: 'best_battery_life', + 2: 'optimized', + 3: 'best_video' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): + cv.string, }) @@ -53,6 +73,7 @@ class ArloCam(Camera): self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self.attrs = {} def camera_image(self): """Return a still image response from the camera.""" @@ -80,14 +101,28 @@ class ArloCam(Camera): """Return the name of this camera.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: self.attrs.get(ATTR_BATTERY_LEVEL), + ATTR_BRIGHTNESS: self.attrs.get(ATTR_BRIGHTNESS), + ATTR_FLIPPED: self.attrs.get(ATTR_FLIPPED), + ATTR_MIRRORED: self.attrs.get(ATTR_MIRRORED), + ATTR_MOTION: self.attrs.get(ATTR_MOTION), + ATTR_POWERSAVE: self.attrs.get(ATTR_POWERSAVE), + ATTR_SIGNAL_STRENGTH: self.attrs.get(ATTR_SIGNAL_STRENGTH), + ATTR_UNSEEN_VIDEOS: self.attrs.get(ATTR_UNSEEN_VIDEOS), + } + @property def model(self): - """Camera model.""" + """Return the camera model.""" return self._camera.model_id @property def brand(self): - """Camera brand.""" + """Return the camera brand.""" return DEFAULT_BRAND @property @@ -97,7 +132,7 @@ class ArloCam(Camera): @property def motion_detection_enabled(self): - """Camera Motion Detection Status.""" + """Return the camera motion detection status.""" return self._motion_status def set_base_station_mode(self, mode): @@ -105,7 +140,7 @@ class ArloCam(Camera): # Get the list of base stations identified by library base_stations = self.hass.data[DATA_ARLO].base_stations - # Some Arlo cameras does not have basestation + # Some Arlo cameras does not have base station # So check if there is base station detected first # if yes, then choose the primary base station # Set the mode on the chosen base station @@ -122,3 +157,16 @@ class ArloCam(Camera): """Disable the motion detection in base station (Disarm).""" self._motion_status = False self.set_base_station_mode(ARLO_MODE_DISARMED) + + def update(self): + """Add an attribute-update task to the executor pool.""" + self.attrs[ATTR_BATTERY_LEVEL] = self._camera.get_battery_level + self.attrs[ATTR_BRIGHTNESS] = self._camera.get_battery_level + self.attrs[ATTR_FLIPPED] = self._camera.get_flip_state, + self.attrs[ATTR_MIRRORED] = self._camera.get_mirror_state, + self.attrs[ + ATTR_MOTION] = self._camera.get_motion_detection_sensitivity, + self.attrs[ATTR_POWERSAVE] = POWERSAVE_MODE_MAPPING[ + self._camera.get_powersave_mode], + self.attrs[ATTR_SIGNAL_STRENGTH] = self._camera.get_signal_strength, + self.attrs[ATTR_UNSEEN_VIDEOS] = self._camera.unseen_videos diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index b0295b9ee34..ee8ccce1a9c 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.axis/ import logging from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) @@ -19,38 +19,44 @@ DOMAIN = 'axis' DEPENDENCIES = [DOMAIN] -def _get_image_url(host, mode): +def _get_image_url(host, port, mode): if mode == 'mjpeg': - return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) elif mode == 'single': - return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) def setup_platform(hass, config, add_devices, discovery_info=None): """Setup Axis camera.""" - config = { + camera_config = { CONF_NAME: discovery_info[CONF_NAME], CONF_USERNAME: discovery_info[CONF_USERNAME], CONF_PASSWORD: discovery_info[CONF_PASSWORD], - CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'), + CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), + 'mjpeg'), CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST], + str(discovery_info[CONF_PORT]), 'single'), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } - add_devices([AxisCamera(hass, config)]) + add_devices([AxisCamera(hass, + camera_config, + str(discovery_info[CONF_PORT]))]) class AxisCamera(MjpegCamera): """AxisCamera class.""" - def __init__(self, hass, config): + def __init__(self, hass, config, port): """Initialize Axis Communications camera component.""" super().__init__(hass, config) + self.port = port async_dispatcher_connect(hass, DOMAIN + '_' + config[CONF_NAME] + '_new_ip', self._new_ip) def _new_ip(self, host): """Set new IP for video stream.""" - self._mjpeg_url = _get_image_url(host, 'mjpeg') - self._still_image_url = _get_image_url(host, 'mjpeg') + self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg') + self._still_image_url = _get_image_url(host, self.port, 'single') diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index bca4fafec4f..4b708817cfd 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -76,6 +76,6 @@ class BlinkCamera(Camera): return self.data.camera_thumbs[self._name] def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" self.request_image() return self.response.content diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py new file mode 100644 index 00000000000..cf6b6b2871f --- /dev/null +++ b/homeassistant/components/camera/doorbird.py @@ -0,0 +1,90 @@ +"""Support for viewing the camera feed from a DoorBird video doorbell.""" + +import asyncio +import datetime +import logging +import voluptuous as vol + +import aiohttp +import async_timeout + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DEPENDENCIES = ['doorbird'] + +_CAMERA_LIVE = "DoorBird Live" +_CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_LIVE_INTERVAL = datetime.timedelta(seconds=1) +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LOGGER = logging.getLogger(__name__) +_TIMEOUT = 10 # seconds + +CONF_SHOW_LAST_VISITOR = 'last_visitor' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the DoorBird camera platform.""" + device = hass.data.get(DOORBIRD_DOMAIN) + + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE) + entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, + _LIVE_INTERVAL)] + + if config.get(CONF_SHOW_LAST_VISITOR): + _LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR) + entities.append(DoorBirdCamera(device.history_image_url(1), + _CAMERA_LAST_VISITOR, + _LAST_VISITOR_INTERVAL)) + + async_add_devices(entities) + _LOGGER.info("Added DoorBird camera(s)") + + +class DoorBirdCamera(Camera): + """The camera on a DoorBird device.""" + + def __init__(self, url, name, interval=None): + """Initialize the camera on a DoorBird device.""" + self._url = url + self._name = name + self._last_image = None + self._interval = interval or datetime.timedelta + self._last_update = datetime.datetime.min + super().__init__() + + @property + def name(self): + """Get the name of the camera.""" + return self._name + + @asyncio.coroutine + def async_camera_image(self): + """Pull a still image from the camera.""" + now = datetime.datetime.now() + + if self._last_image and now - self._last_update < self._interval: + return self._last_image + + try: + websession = async_get_clientsession(self.hass) + + with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + response = yield from websession.get(self._url) + + self._last_image = yield from response.read() + self._last_update = now + return self._last_image + except asyncio.TimeoutError: + _LOGGER.error("Camera image timed out") + return self._last_image + except aiohttp.ClientError as error: + _LOGGER.error("Error getting camera image: %s", error) + return self._last_image diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 15138e2c253..3cc391eae33 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyfoscam==1.2'] +REQUIREMENTS = ['libpyfoscam==1.0'] CONF_IP = 'ip' @@ -53,13 +53,13 @@ class FoscamCam(Camera): self._name = device_info.get(CONF_NAME) self._motion_status = False - from foscam import FoscamCamera + from libpyfoscam import FoscamCamera self._foscam_session = FoscamCamera(ip_address, port, self._username, self._password, verbose=False) def camera_image(self): - """Return a still image reponse from the camera.""" + """Return a still image response from the camera.""" # Send the request to snap a picture and return raw jpg data # Handle exception if host is not reachable or url failed result, response = self._foscam_session.snap_picture_2() diff --git a/homeassistant/components/camera/skybell.py b/homeassistant/components/camera/skybell.py new file mode 100644 index 00000000000..be3504dab78 --- /dev/null +++ b/homeassistant/components/camera/skybell.py @@ -0,0 +1,67 @@ +""" +Camera support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.skybell/ +""" +from datetime import timedelta +import logging + +import requests + +from homeassistant.components.camera import Camera +from homeassistant.components.skybell import ( + DOMAIN as SKYBELL_DOMAIN, SkybellDevice) + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=90) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a Skybell device.""" + skybell = hass.data.get(SKYBELL_DOMAIN) + + sensors = [] + for device in skybell.get_devices(): + sensors.append(SkybellCamera(device)) + + add_devices(sensors, True) + + +class SkybellCamera(SkybellDevice, Camera): + """A camera implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a camera for a Skybell device.""" + SkybellDevice.__init__(self, device) + Camera.__init__(self) + self._name = self._device.name + self._url = None + self._response = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def camera_image(self): + """Get the latest camera image.""" + super().update() + + if self._url != self._device.image: + self._url = self._device.image + + try: + self._response = requests.get( + self._url, stream=True, timeout=10) + except requests.HTTPError as err: + _LOGGER.warning("Failed to get camera image: %s", err) + self._response = None + + if not self._response: + return None + + return self._response.content diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 90dfa58d8c5..0b97f55397c 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -7,44 +7,25 @@ https://home-assistant.io/components/camera.synology/ import asyncio import logging +import requests import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.const import ( CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT) from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_create_clientsession, + async_create_clientsession, async_aiohttp_proxy_web) import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_coroutine_threadsafe + +REQUIREMENTS = ['py-synology==0.1.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Synology Camera' -DEFAULT_STREAM_ID = '0' DEFAULT_TIMEOUT = 5 -CONF_CAMERA_NAME = 'camera_name' -CONF_STREAM_ID = 'stream_id' - -QUERY_CGI = 'query.cgi' -QUERY_API = 'SYNO.API.Info' -AUTH_API = 'SYNO.API.Auth' -CAMERA_API = 'SYNO.SurveillanceStation.Camera' -STREAMING_API = 'SYNO.SurveillanceStation.VideoStream' -SESSION_ID = '0' - -WEBAPI_PATH = '/webapi/' -AUTH_PATH = 'auth.cgi' -CAMERA_PATH = 'camera.cgi' -STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi' -CONTENT_TYPE_HEADER = 'Content-Type' - -SYNO_API_URL = '{0}{1}{2}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,189 +43,89 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a Synology IP Camera.""" verify_ssl = config.get(CONF_VERIFY_SSL) timeout = config.get(CONF_TIMEOUT) - websession_init = async_get_clientsession(hass, verify_ssl) - # Determine API to use for authentication - syno_api_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI) - - query_payload = { - 'api': QUERY_API, - 'method': 'Query', - 'version': '1', - 'query': 'SYNO.' - } try: - with async_timeout.timeout(timeout, loop=hass.loop): - query_req = yield from websession_init.get( - syno_api_url, - params=query_payload - ) - - # Skip content type check because Synology doesn't return JSON with - # right content type - query_resp = yield from query_req.json(content_type=None) - auth_path = query_resp['data'][AUTH_API]['path'] - camera_api = query_resp['data'][CAMERA_API]['path'] - camera_path = query_resp['data'][CAMERA_API]['path'] - streaming_path = query_resp['data'][STREAMING_API]['path'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_api_url) + from synology.surveillance_station import SurveillanceStation + surveillance = SurveillanceStation( + config.get(CONF_URL), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + verify_ssl=verify_ssl, + timeout=timeout + ) + except (requests.exceptions.RequestException, ValueError): + _LOGGER.exception("Error when initializing SurveillanceStation") return False - # Authticate to NAS to get a session id - syno_auth_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, auth_path) - - session_id = yield from get_session_id( - hass, - websession_init, - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - syno_auth_url, - timeout - ) - - # init websession - websession = async_create_clientsession( - hass, verify_ssl, cookies={'id': session_id}) - - # Use SessionID to get cameras in system - syno_camera_url = SYNO_API_URL.format( - config.get(CONF_URL), WEBAPI_PATH, camera_api) - - camera_payload = { - 'api': CAMERA_API, - 'method': 'List', - 'version': '1' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - camera_req = yield from websession.get( - syno_camera_url, - params=camera_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", syno_camera_url) - return False - - camera_resp = yield from camera_req.json(content_type=None) - cameras = camera_resp['data']['cameras'] + cameras = surveillance.get_all_cameras() + websession = async_create_clientsession(hass, verify_ssl) # add cameras devices = [] for camera in cameras: if not config.get(CONF_WHITELIST): - camera_id = camera['id'] - snapshot_path = camera['snapshot_path'] - - device = SynologyCamera( - hass, websession, config, camera_id, camera['name'], - snapshot_path, streaming_path, camera_path, auth_path, timeout - ) + device = SynologyCamera(websession, surveillance, camera.camera_id) devices.append(device) async_add_devices(devices) -@asyncio.coroutine -def get_session_id(hass, websession, username, password, login_url, timeout): - """Get a session id.""" - auth_payload = { - 'api': AUTH_API, - 'method': 'Login', - 'version': '2', - 'account': username, - 'passwd': password, - 'session': 'SurveillanceStation', - 'format': 'sid' - } - try: - with async_timeout.timeout(timeout, loop=hass.loop): - auth_req = yield from websession.get( - login_url, - params=auth_payload - ) - auth_resp = yield from auth_req.json(content_type=None) - return auth_resp['data']['sid'] - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.exception("Error on %s", login_url) - return False - - class SynologyCamera(Camera): """An implementation of a Synology NAS based IP camera.""" - def __init__(self, hass, websession, config, camera_id, - camera_name, snapshot_path, streaming_path, camera_path, - auth_path, timeout): + def __init__(self, websession, surveillance, camera_id): """Initialize a Synology Surveillance Station camera.""" super().__init__() - self.hass = hass self._websession = websession - self._name = camera_name - self._synology_url = config.get(CONF_URL) - self._camera_name = config.get(CONF_CAMERA_NAME) - self._stream_id = config.get(CONF_STREAM_ID) + self._surveillance = surveillance self._camera_id = camera_id - self._snapshot_path = snapshot_path - self._streaming_path = streaming_path - self._camera_path = camera_path - self._auth_path = auth_path - self._timeout = timeout + self._camera = self._surveillance.get_camera(camera_id) + self._motion_setting = self._surveillance.get_motion_setting(camera_id) + self.is_streaming = self._camera.is_enabled def camera_image(self): """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() - - @asyncio.coroutine - def async_camera_image(self): - """Return a still image response from the camera.""" - image_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._camera_path) - - image_payload = { - 'api': CAMERA_API, - 'method': 'GetSnapshot', - 'version': '1', - 'cameraId': self._camera_id - } - try: - with async_timeout.timeout(self._timeout, loop=self.hass.loop): - response = yield from self._websession.get( - image_url, - params=image_payload - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error fetching %s", image_url) - return None - - image = yield from response.read() - - return image + return self._surveillance.get_camera_image(self._camera_id) @asyncio.coroutine def handle_async_mjpeg_stream(self, request): """Return a MJPEG stream image response directly from the camera.""" - streaming_url = SYNO_API_URL.format( - self._synology_url, WEBAPI_PATH, self._streaming_path) - - streaming_payload = { - 'api': STREAMING_API, - 'method': 'Stream', - 'version': '1', - 'cameraId': self._camera_id, - 'format': 'mjpeg' - } - stream_coro = self._websession.get( - streaming_url, params=streaming_payload) + streaming_url = self._camera.video_stream_url + stream_coro = self._websession.get(streaming_url) yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) @property def name(self): """Return the name of this device.""" - return self._name + return self._camera.name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._camera.is_recording + + def should_poll(self): + """Update the recording state periodically.""" + return True + + def update(self): + """Update the status of the camera.""" + self._surveillance.update() + self._camera = self._surveillance.get_camera(self._camera.camera_id) + self._motion_setting = self._surveillance.get_motion_setting( + self._camera.camera_id) + self.is_streaming = self._camera.is_enabled + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_setting.is_enabled + + def enable_motion_detection(self): + """Enable motion detection in the camera.""" + self._surveillance.enable_motion_detection(self._camera_id) + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + self._surveillance.disable_motion_detection(self._camera_id) diff --git a/homeassistant/components/camera/usps.py b/homeassistant/components/camera/usps.py index 545ea9798de..6c76d0d66d8 100644 --- a/homeassistant/components/camera/usps.py +++ b/homeassistant/components/camera/usps.py @@ -77,7 +77,7 @@ class USPSCamera(Camera): def model(self): """Return date of mail as model.""" try: - return 'Date: {}'.format(self._usps.mail[0]['date']) + return 'Date: {}'.format(str(self._usps.mail[0]['date'])) except IndexError: return None diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 3203a10b391..685b6d64364 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PORT from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['uvcclient==0.10.0'] +REQUIREMENTS = ['uvcclient==0.10.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 1f919301254..53e60380a38 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -44,6 +44,12 @@ STATE_IDLE = 'idle' STATE_AUTO = 'auto' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' ATTR_CURRENT_TEMPERATURE = 'current_temperature' ATTR_MAX_TEMP = 'max_temp' @@ -147,7 +153,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): @bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): - """Turn all or specified climate devices auxillary heater on.""" + """Turn all or specified climate devices auxiliary heater on.""" data = { ATTR_AUX_HEAT: aux_heat } @@ -661,22 +667,22 @@ class ClimateDevice(Entity): return self.hass.async_add_job(self.set_hold_mode, hold_mode) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" + """Turn auxiliary heater on.""" raise NotImplementedError() def async_turn_aux_heat_on(self): - """Turn auxillary heater on. + """Turn auxiliary heater on. This method must be run in the event loop and returns a coroutine. """ return self.hass.async_add_job(self.turn_aux_heat_on) def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" raise NotImplementedError() def async_turn_aux_heat_off(self): - """Turn auxillary heater off. + """Turn auxiliary heater off. This method must be run in the event loop and returns a coroutine. """ diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 24b40af7eb1..377985aaa12 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -114,7 +114,7 @@ class DemoClimate(ClimateDevice): @property def is_aux_heat_on(self): - """Return true if away mode is on.""" + """Return true if aux heat is on.""" return self._aux @property @@ -183,11 +183,11 @@ class DemoClimate(ClimateDevice): self.schedule_update_ha_state() def turn_aux_heat_on(self): - """Turn away auxillary heater on.""" + """Turn auxillary heater on.""" self._aux = True self.schedule_update_ha_state() def turn_aux_heat_off(self): - """Turn auxillary heater off.""" + """Turn auxiliary heater off.""" self._aux = False self.schedule_update_ha_state() diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 6780d3745f0..d6d92432730 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -27,6 +27,7 @@ ATTR_RESUME_ALL = 'resume_all' DEFAULT_RESUME_ALL = False TEMPERATURE_HOLD = 'temp' VACATION_HOLD = 'vacation' +AWAY_MODE = 'awayMode' DEPENDENCIES = ['ecobee'] @@ -144,20 +145,20 @@ class Thermostat(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self.thermostat['runtime']['actualTemperature'] / 10 + return self.thermostat['runtime']['actualTemperature'] / 10.0 @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 return None @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" if self.current_operation == STATE_AUTO: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -166,9 +167,9 @@ class Thermostat(ClimateDevice): if self.current_operation == STATE_AUTO: return None if self.current_operation == STATE_HEAT: - return int(self.thermostat['runtime']['desiredHeat'] / 10) + return self.thermostat['runtime']['desiredHeat'] / 10.0 elif self.current_operation == STATE_COOL: - return int(self.thermostat['runtime']['desiredCool'] / 10) + return self.thermostat['runtime']['desiredCool'] / 10.0 return None @property @@ -186,6 +187,11 @@ class Thermostat(ClimateDevice): @property def current_hold_mode(self): """Return current hold mode.""" + mode = self._current_hold_mode + return None if mode == AWAY_MODE else mode + + @property + def _current_hold_mode(self): events = self.thermostat['events'] for event in events: if event['running']: @@ -195,8 +201,8 @@ class Thermostat(ClimateDevice): int(event['startDate'][0:4]) <= 1: # A temporary hold from away climate is a hold return 'away' - # A permanent hold from away climate is away_mode - return None + # A permanent hold from away climate + return AWAY_MODE elif event['holdClimateRef'] != "": # Any other hold based on climate return event['holdClimateRef'] @@ -269,7 +275,7 @@ class Thermostat(ClimateDevice): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self.current_hold_mode == 'away' + return self._current_hold_mode == AWAY_MODE @property def is_aux_heat_on(self): @@ -277,12 +283,17 @@ class Thermostat(ClimateDevice): return 'auxHeat' in self.thermostat['equipmentStatus'] def turn_away_mode_on(self): - """Turn away on.""" - self.set_hold_mode('away') + """Turn away mode on by setting it on away hold indefinitely.""" + if self._current_hold_mode != AWAY_MODE: + self.data.ecobee.set_climate_hold(self.thermostat_index, 'away', + 'indefinite') + self.update_without_throttle = True def turn_away_mode_off(self): """Turn away off.""" - self.set_hold_mode(None) + if self._current_hold_mode == AWAY_MODE: + self.data.ecobee.resume_program(self.thermostat_index) + self.update_without_throttle = True def set_hold_mode(self, hold_mode): """Set hold mode (away, home, temp, sleep, etc.).""" @@ -299,7 +310,7 @@ class Thermostat(ClimateDevice): self.data.ecobee.resume_program(self.thermostat_index) else: if hold_mode == TEMPERATURE_HOLD: - self.set_temp_hold(int(self.current_temperature)) + self.set_temp_hold(self.current_temperature) else: self.data.ecobee.set_climate_hold( self.thermostat_index, hold_mode, self.hold_preference()) @@ -325,15 +336,11 @@ class Thermostat(ClimateDevice): elif self.current_operation == STATE_COOL: heat_temp = temp - 20 cool_temp = temp - - self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp, - heat_temp, self.hold_preference()) - _LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, " - "cool=%s, is=%s", heat_temp, isinstance( - heat_temp, (int, float)), cool_temp, - isinstance(cool_temp, (int, float))) - - self.update_without_throttle = True + else: + # In auto mode set temperature between + heat_temp = temp - 10 + cool_temp = temp + 10 + self.set_auto_temp_hold(heat_temp, cool_temp) def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -343,9 +350,9 @@ class Thermostat(ClimateDevice): if self.current_operation == STATE_AUTO and low_temp is not None \ and high_temp is not None: - self.set_auto_temp_hold(int(low_temp), int(high_temp)) + self.set_auto_temp_hold(low_temp, high_temp) elif temp is not None: - self.set_temp_hold(int(temp)) + self.set_temp_hold(temp) else: _LOGGER.error( "Missing valid arguments for set_temperature in %s", kwargs) @@ -364,7 +371,7 @@ class Thermostat(ClimateDevice): def resume_program(self, resume_all): """Resume the thermostat schedule program.""" self.data.ecobee.resume_program( - self.thermostat_index, str(resume_all).lower()) + self.thermostat_index, 'true' if resume_all else 'false') self.update_without_throttle = True def hold_preference(self): diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index ff13dd48cac..d70890317fd 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -17,7 +17,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.5'] +REQUIREMENTS = ['python-eq3bt==0.1.6'] _LOGGER = logging.getLogger(__name__) @@ -164,4 +164,8 @@ class EQ3BTSmartThermostat(ClimateDevice): def update(self): """Update the data from the thermostat.""" - self._thermostat.update() + from bluepy.btle import BTLEException + try: + self._thermostat.update() + except BTLEException as ex: + _LOGGER.warning("Updating the state failed: %s", ex) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 9442b7da194..6af06323fd0 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -211,7 +211,7 @@ class GenericThermostat(ClimateDevice): """Handle heater switch state changes.""" if new_state is None: return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback def _async_keep_alive(self, time): diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9..ce6e9580e54 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab..0b2df903e17 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ class RoundThermostat(ClimateDevice): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dca..9bf44c9b9ab 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' -DEFAULT_NAME = 'KNX Thermostat' +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up climate(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up climates for KNX platform configured within plattform.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXClimate(hass, device)) + async_add_devices(entities) - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up climate for KNX platform configured within plattform.""" + import xknx + climate = xknx.devices.Climate( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + async_add_devices([KNXClimate(hass, climate)]) - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py new file mode 100644 index 00000000000..2f7bba74185 --- /dev/null +++ b/homeassistant/components/climate/mqtt.py @@ -0,0 +1,483 @@ +""" +Support for MQTT climate devices. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.mqtt/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.components.mqtt as mqtt + +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, + PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, + ATTR_OPERATION_MODE) +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME) +from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, + SPEED_HIGH) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +DEFAULT_NAME = 'MQTT HVAC' + +CONF_POWER_COMMAND_TOPIC = 'power_command_topic' +CONF_POWER_STATE_TOPIC = 'power_state_topic' +CONF_MODE_COMMAND_TOPIC = 'mode_command_topic' +CONF_MODE_STATE_TOPIC = 'mode_state_topic' +CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic' +CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic' +CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic' +CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic' +CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic' +CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic' +CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic' +CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic' +CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic' +CONF_HOLD_STATE_TOPIC = 'hold_state_topic' +CONF_AUX_COMMAND_TOPIC = 'aux_command_topic' +CONF_AUX_STATE_TOPIC = 'aux_state_topic' + +CONF_CURRENT_TEMPERATURE_TOPIC = 'current_temperature_topic' + +CONF_PAYLOAD_ON = 'payload_on' +CONF_PAYLOAD_OFF = 'payload_off' + +CONF_FAN_MODE_LIST = 'fan_modes' +CONF_MODE_LIST = 'modes' +CONF_SWING_MODE_LIST = 'swing_modes' +CONF_INITIAL = 'initial' +CONF_SEND_IF_OFF = 'send_if_off' + +PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CURRENT_TEMPERATURE_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_LIST, + default=[STATE_AUTO, SPEED_LOW, + SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list, + vol.Optional(CONF_SWING_MODE_LIST, + default=[STATE_ON, STATE_OFF]): cv.ensure_list, + vol.Optional(CONF_MODE_LIST, + default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT, + STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INITIAL, default=21): cv.positive_int, + vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, + vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the MQTT climate devices.""" + async_add_devices([ + MqttClimate( + hass, + config.get(CONF_NAME), + { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + }, + config.get(CONF_QOS), + config.get(CONF_RETAIN), + config.get(CONF_MODE_LIST), + config.get(CONF_FAN_MODE_LIST), + config.get(CONF_SWING_MODE_LIST), + config.get(CONF_INITIAL), + False, None, SPEED_LOW, + STATE_OFF, STATE_OFF, False, + config.get(CONF_SEND_IF_OFF), + config.get(CONF_PAYLOAD_ON), + config.get(CONF_PAYLOAD_OFF)) + ]) + + +class MqttClimate(ClimateDevice): + """Representation of a demo climate device.""" + + def __init__(self, hass, name, topic, qos, retain, mode_list, + fan_mode_list, swing_mode_list, target_temperature, away, + hold, current_fan_mode, current_swing_mode, + current_operation, aux, send_if_off, payload_on, + payload_off): + """Initialize the climate device.""" + self.hass = hass + self._name = name + self._topic = topic + self._qos = qos + self._retain = retain + self._target_temperature = target_temperature + self._unit_of_measurement = hass.config.units.temperature_unit + self._away = away + self._hold = hold + self._current_temperature = None + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = fan_mode_list + self._operation_list = mode_list + self._swing_list = swing_mode_list + self._target_temperature_step = 1 + self._send_if_off = send_if_off + self._payload_on = payload_on + self._payload_off = payload_off + + def async_added_to_hass(self): + """Handle being added to home assistant.""" + @callback + def handle_current_temp_received(topic, payload, qos): + """Handle current temperature coming via MQTT.""" + try: + self._current_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + handle_current_temp_received, self._qos) + + @callback + def handle_mode_received(topic, payload, qos): + """Handle receiving mode via MQTT.""" + if payload not in self._operation_list: + _LOGGER.error("Invalid mode: %s", payload) + else: + self._current_operation = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_MODE_STATE_TOPIC], + handle_mode_received, self._qos) + + @callback + def handle_temperature_received(topic, payload, qos): + """Handle target temperature coming via MQTT.""" + try: + self._target_temperature = float(payload) + self.async_schedule_update_ha_state() + except ValueError: + _LOGGER.error("Could not parse temperature from %s", payload) + + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], + handle_temperature_received, self._qos) + + @callback + def handle_fan_mode_received(topic, payload, qos): + """Handle receiving fan mode via MQTT.""" + if payload not in self._fan_list: + _LOGGER.error("Invalid fan mode: %s", payload) + else: + self._current_fan_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], + handle_fan_mode_received, self._qos) + + @callback + def handle_swing_mode_received(topic, payload, qos): + """Handle receiving swing mode via MQTT.""" + if payload not in self._swing_list: + _LOGGER.error("Invalid swing mode: %s", payload) + else: + self._current_swing_mode = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], + handle_swing_mode_received, self._qos) + + @callback + def handle_away_mode_received(topic, payload, qos): + """Handle receiving away mode via MQTT.""" + if payload == self._payload_on: + self._away = True + elif payload == self._payload_off: + self._away = False + else: + _LOGGER.error("Invalid away mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], + handle_away_mode_received, self._qos) + + @callback + def handle_aux_mode_received(topic, payload, qos): + """Handle receiving aux mode via MQTT.""" + if payload == self._payload_on: + self._aux = True + elif payload == self._payload_off: + self._aux = False + else: + _LOGGER.error("Invalid aux mode: %s", payload) + + self.async_schedule_update_ha_state() + + if self._topic[CONF_AUX_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_AUX_STATE_TOPIC], + handle_aux_mode_received, self._qos) + + @callback + def handle_hold_mode_received(topic, payload, qos): + """Handle receiving hold mode via MQTT.""" + self._hold = payload + self.async_schedule_update_ha_state() + + if self._topic[CONF_HOLD_STATE_TOPIC] is not None: + yield from mqtt.async_subscribe( + self.hass, self._topic[CONF_HOLD_STATE_TOPIC], + handle_hold_mode_received, self._qos) + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._target_temperature_step + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + @property + def current_hold_mode(self): + """Return hold mode setting.""" + return self._hold + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._fan_list + + @asyncio.coroutine + def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if kwargs.get(ATTR_OPERATION_MODE) is not None: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + yield from self.async_set_operation_mode(operation_mode) + + if kwargs.get(ATTR_TEMPERATURE) is not None: + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + # optimistic mode + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], + kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_swing_mode(self, swing_mode): + """Set new swing mode.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], + swing_mode, self._qos, self._retain) + + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = swing_mode + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_fan_mode(self, fan): + """Set new target temperature.""" + if self._send_if_off or self._current_operation != STATE_OFF: + mqtt.async_publish( + self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], + fan, self._qos, self._retain) + + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = fan + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode) -> None: + """Set new operation mode.""" + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + if (self._current_operation == STATE_OFF and + operation_mode != STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + elif (self._current_operation != STATE_OFF and + operation_mode == STATE_OFF): + mqtt.async_publish( + self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish( + self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], + operation_mode, self._qos, self._retain) + + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = operation_mode + self.async_schedule_update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @asyncio.coroutine + def async_turn_away_mode_on(self): + """Turn away mode on.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_away_mode_off(self): + """Turn away mode off.""" + if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: + self._away = False + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_set_hold_mode(self, hold): + """Update hold mode on.""" + if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, + self._topic[CONF_HOLD_COMMAND_TOPIC], + hold, self._qos, self._retain) + + if self._topic[CONF_HOLD_STATE_TOPIC] is None: + self._hold = hold + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_on(self): + """Turn auxillary heater on.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_on, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = True + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_turn_aux_heat_off(self): + """Turn auxillary heater off.""" + if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: + mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], + self._payload_off, self._qos, self._retain) + + if self._topic[CONF_AUX_STATE_TOPIC] is None: + self._aux = False + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 82ed8a94e2b..d4316c2cfba 100755 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -4,15 +4,11 @@ MySensors platform that offers a Climate (MySensors-HVAC) component. For more details about this platform, please refer to the documentation https://home-assistant.io/components/climate.mysensors/ """ -import logging - from homeassistant.components import mysensors from homeassistant.components.climate import ( - STATE_COOL, STATE_HEAT, STATE_OFF, STATE_AUTO, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW) -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE - -_LOGGER = logging.getLogger(__name__) + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO, + STATE_COOL, STATE_HEAT, STATE_OFF, ClimateDevice) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT DICT_HA_TO_MYS = { STATE_AUTO: 'AutoChangeOver', @@ -29,28 +25,12 @@ DICT_MYS_TO_HA = { def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the mysensors climate.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - if float(gateway.protocol_version) < 1.5: - continue - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_HVAC: [set_req.V_HVAC_FLOW_STATE], - } - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsHVAC, add_devices)) + """Setup the mysensors climate.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsHVAC, add_devices=add_devices) -class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): +class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property @@ -84,26 +64,28 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) + return float(temp) if temp is not None else None @property def target_temperature_high(self): """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_HEAT in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_COOL)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) + return float(temp) if temp is not None else None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq if set_req.V_HVAC_SETPOINT_COOL in self._values: - return float(self._values.get(set_req.V_HVAC_SETPOINT_HEAT)) + temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) + return float(temp) if temp is not None else None @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.gateway.const.SetReq.V_HVAC_FLOW_STATE) + return self._values.get(self.value_type) @property def operation_list(self): @@ -128,7 +110,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): high = kwargs.get(ATTR_TARGET_TEMP_HIGH) heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - updates = () + updates = [] if temp is not None: if heat is not None: # Set HEAT Target temperature @@ -146,7 +128,7 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[value_type] = value self.schedule_update_ha_state() @@ -156,54 +138,22 @@ class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) if self.gateway.optimistic: - # optimistically assume that switch has changed state + # optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): """Set new target temperature.""" - set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_FLOW_STATE, + self.node_id, self.child_id, self.value_type, DICT_HA_TO_MYS[operation_mode]) if self.gateway.optimistic: - # optimistically assume that switch has changed state - self._values[set_req.V_HVAC_FLOW_STATE] = operation_mode + # optimistically assume that device has changed state + self._values[self.value_type] = operation_mode self.schedule_update_ha_state() def update(self): """Update the controller with the latest value from a sensor.""" - set_req = self.gateway.const.SetReq - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - for value_type, value in child.values.items(): - _LOGGER.debug( - "%s: value_type %s, value = %s", self._name, value_type, value) - if value_type == set_req.V_HVAC_FLOW_STATE: - self._values[value_type] = DICT_MYS_TO_HA[value] - else: - self._values[value_type] = value - - def set_humidity(self, humidity): - """Set new target humidity.""" - _LOGGER.error("Service Not Implemented yet") - - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_on(self): - """Turn away mode on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_away_mode_off(self): - """Turn away mode off.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - _LOGGER.error("Service Not Implemented yet") - - def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - _LOGGER.error("Service Not Implemented yet") + super().update() + self._values[self.value_type] = DICT_MYS_TO_HA[ + self._values[self.value_type]] diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4aebb1c85c9..92d821ebbaf 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,5 +1,5 @@ set_aux_heat: - description: Turn auxillary heater on/off for climate device + description: Turn auxiliary heater on/off for climate device fields: entity_id: diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 00000000000..684d131d960 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,92 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f52340dc627..f72cefc0841 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -1,30 +1,45 @@ """ -Support for Wink thermostats. +Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ +import logging import asyncio from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, - ATTR_CURRENT_HUMIDITY) + ATTR_TEMPERATURE, STATE_FAN_ONLY, + ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, + STATE_PERFORMANCE, STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, STATE_GAS) from homeassistant.const import ( TEMP_CELSIUS, STATE_ON, STATE_OFF, STATE_UNKNOWN) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ['wink'] -STATE_AUX = 'aux' -STATE_ECO = 'eco' -STATE_FAN = 'fan' SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' +HA_STATE_TO_WINK = {STATE_AUTO: 'auto', + STATE_ECO: 'eco', + STATE_FAN_ONLY: 'fan_only', + STATE_HEAT: 'heat_only', + STATE_COOL: 'cool_only', + STATE_PERFORMANCE: 'performance', + STATE_HIGH_DEMAND: 'high_demand', + STATE_HEAT_PUMP: 'heat_pump', + STATE_ELECTRIC: 'electric_only', + STATE_GAS: 'gas', + STATE_OFF: 'off'} +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} + ATTR_EXTERNAL_TEMPERATURE = "external_temperature" ATTR_SMART_TEMPERATURE = "smart_temperature" ATTR_ECO_TARGET = "eco_target" @@ -32,28 +47,26 @@ ATTR_OCCUPIED = "occupied" def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Wink thermostat.""" + """Set up the Wink climate devices.""" import pywink - temp_unit = hass.config.units.temperature_unit for climate in pywink.get_thermostats(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkThermostat(climate, hass, temp_unit)]) + add_devices([WinkThermostat(climate, hass)]) for climate in pywink.get_air_conditioners(): _id = climate.object_id() + climate.name() if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkAC(climate, hass, temp_unit)]) + add_devices([WinkAC(climate, hass)]) + for water_heater in pywink.get_water_heaters(): + _id = water_heater.object_id() + water_heater.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkWaterHeater(water_heater, hass)]) # pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @asyncio.coroutine def async_added_to_hass(self): """Callback when entity is added to hass.""" @@ -139,18 +152,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_hvac_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_hvac_mode() == 'heat_only': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'aux': - current_op = STATE_HEAT - elif self.wink.current_hvac_mode() == 'auto': - current_op = STATE_AUTO - elif self.wink.current_hvac_mode() == 'eco': - current_op = STATE_ECO else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op == 'aux': + return STATE_HEAT + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -199,11 +206,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def is_aux_heat_on(self): """Return true if aux heater.""" - if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on(): + if 'aux' not in self.wink.hvac_modes(): + return None + + if self.wink.current_hvac_mode() == 'aux': return True - elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on(): - return False - return None + return False def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -223,32 +231,27 @@ class WinkThermostat(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_HEAT: - self.wink.set_operation_mode('heat_only') - elif operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_AUTO: - self.wink.set_operation_mode('auto') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_AUX: - self.wink.set_operation_mode('aux') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('eco') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + # The only way to disable aux heat is with the toggle + if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT: + return + self.wink.set_operation_mode(op_mode_to_set) @property def operation_list(self): """List of available operation modes.""" op_list = ['off'] modes = self.wink.hvac_modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'heat_only' in modes or 'aux' in modes: - op_list.append(STATE_HEAT) - if 'auto' in modes: - op_list.append(STATE_AUTO) - if 'eco' in modes: - op_list.append(STATE_ECO) + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def turn_away_mode_on(self): @@ -281,12 +284,12 @@ class WinkThermostat(WinkDevice, ClimateDevice): self.wink.set_fan_mode(fan.lower()) def turn_aux_heat_on(self): - """Turn auxillary heater on.""" - self.set_operation_mode(STATE_AUX) + """Turn auxiliary heater on.""" + self.wink.set_operation_mode('aux') def turn_aux_heat_off(self): - """Turn auxillary heater off.""" - self.set_operation_mode(STATE_AUTO) + """Turn auxiliary heater off.""" + self.set_operation_mode(STATE_HEAT) @property def min_temp(self): @@ -344,11 +347,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): class WinkAC(WinkDevice, ClimateDevice): """Representation of a Wink air conditioner.""" - def __init__(self, wink, hass, temp_unit): - """Initialize the Wink device.""" - super().__init__(wink, hass) - self._config_temp_unit = temp_unit - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -382,14 +380,10 @@ class WinkAC(WinkDevice, ClimateDevice): """Return current operation ie. heat, cool, idle.""" if not self.wink.is_on(): current_op = STATE_OFF - elif self.wink.current_mode() == 'cool_only': - current_op = STATE_COOL - elif self.wink.current_mode() == 'auto_eco': - current_op = STATE_ECO - elif self.wink.current_mode() == 'fan_only': - current_op = STATE_FAN else: - current_op = STATE_UNKNOWN + current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + if current_op is None: + current_op = STATE_UNKNOWN return current_op @property @@ -397,12 +391,14 @@ class WinkAC(WinkDevice, ClimateDevice): """List of available operation modes.""" op_list = ['off'] modes = self.wink.modes() - if 'cool_only' in modes: - op_list.append(STATE_COOL) - if 'auto_eco' in modes: - op_list.append(STATE_ECO) - if 'fan_only' in modes: - op_list.append(STATE_FAN) + for mode in modes: + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) return op_list def set_temperature(self, **kwargs): @@ -412,30 +408,16 @@ class WinkAC(WinkDevice, ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" - if operation_mode == STATE_COOL: - self.wink.set_operation_mode('cool_only') - elif operation_mode == STATE_ECO: - self.wink.set_operation_mode('auto_eco') - elif operation_mode == STATE_OFF: - self.wink.set_operation_mode('off') - elif operation_mode == STATE_FAN: - self.wink.set_operation_mode('fan_only') + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + if op_mode_to_set == 'eco': + op_mode_to_set = 'auto_eco' + self.wink.set_operation_mode(op_mode_to_set) @property def target_temperature(self): """Return the temperature we try to reach.""" return self.wink.current_max_set_point() - @property - def target_temperature_low(self): - """Only supports cool.""" - return None - - @property - def target_temperature_high(self): - """Only supports cool.""" - return None - @property def current_fan_mode(self): """Return the current fan mode.""" @@ -453,12 +435,97 @@ class WinkAC(WinkDevice, ClimateDevice): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, mode): + def set_fan_mode(self, fan): """Set fan speed.""" - if mode == SPEED_LOW: + if fan == SPEED_LOW: speed = 0.4 - elif mode == SPEED_MEDIUM: + elif fan == SPEED_MEDIUM: speed = 0.8 - elif mode == SPEED_HIGH: + elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) + + +class WinkWaterHeater(WinkDevice, ClimateDevice): + """Representation of a Wink water heater.""" + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + # The Wink API always returns temp in Celsius + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + data = {} + data["vacation_mode"] = self.wink.vacation_mode_enabled() + data["rheem_type"] = self.wink.rheem_type() + + return data + + @property + def current_operation(self): + """ + Return current operation one of the following. + + ["eco", "performance", "heat_pump", + "high_demand", "electric_only", "gas] + """ + if not self.wink.is_on(): + current_op = STATE_OFF + else: + current_op = WINK_STATE_TO_HA.get(self.wink.current_mode()) + if current_op is None: + current_op = STATE_UNKNOWN + return current_op + + @property + def operation_list(self): + """List of available operation modes.""" + op_list = ['off'] + modes = self.wink.modes() + for mode in modes: + if mode == 'aux': + continue + ha_mode = WINK_STATE_TO_HA.get(mode) + if ha_mode is not None: + op_list.append(ha_mode) + else: + error = "Invaid operation mode mapping. " + mode + \ + " doesn't map. Please report this." + _LOGGER.error(error) + return op_list + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temp = kwargs.get(ATTR_TEMPERATURE) + self.wink.set_temperature(target_temp) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode) + self.wink.set_operation_mode(op_mode_to_set) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.wink.current_set_point() + + def turn_away_mode_on(self): + """Turn away on.""" + self.wink.set_vacation_mode(True) + + def turn_away_mode_off(self): + """Turn away off.""" + self.wink.set_vacation_mode(False) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.wink.min_set_point() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.wink.max_set_point() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..44796f97166 --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,47 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, auth_api +from .const import DOMAIN + + +REQUIREMENTS = ['warrant==0.2.0'] +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py new file mode 100644 index 00000000000..0baadeece46 --- /dev/null +++ b/homeassistant/components/cloud/auth_api.py @@ -0,0 +1,270 @@ +"""Package to offer tools to authenticate with the cloud.""" +import json +import logging +import os + +from .const import AUTH_FILE, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UserNotFound(CloudError): + """Raised when a user is not found.""" + + +class UserNotConfirmed(CloudError): + """Raised when a user has not confirmed email yet.""" + + +class ExpiredCode(CloudError): + """Raised when an expired code is encoutered.""" + + +class InvalidCode(CloudError): + """Raised when an invalid code is submitted.""" + + +class PasswordChangeRequired(CloudError): + """Raised when a password change is required.""" + + def __init__(self, message='Password change required.'): + """Initialize a password change required error.""" + super().__init__(message) + + +class UnknownError(CloudError): + """Raised when an unknown error occurrs.""" + + +AWS_EXCEPTIONS = { + 'UserNotFoundException': UserNotFound, + 'NotAuthorizedException': Unauthenticated, + 'ExpiredCodeException': ExpiredCode, + 'UserNotConfirmedException': UserNotConfirmed, + 'PasswordResetRequiredException': PasswordChangeRequired, + 'CodeMismatchException': InvalidCode, +} + + +def _map_aws_exception(err): + """Map AWS exception to our exceptions.""" + ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) + return ex(err.response['Error']['Message']) + + +def load_auth(hass): + """Load authentication from disk and verify it.""" + info = _read_info(hass) + + if info is None: + return Auth(hass) + + auth = Auth(hass, _cognito( + hass, + id_token=info['id_token'], + access_token=info['access_token'], + refresh_token=info['refresh_token'], + )) + + if auth.validate_auth(): + return auth + + return Auth(hass) + + +def register(hass, email, password): + """Register a new account.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.register(email, password) + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_register(hass, confirmation_code, email): + """Confirm confirmation code after registration.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_sign_up(confirmation_code, email) + except ClientError as err: + raise _map_aws_exception(err) + + +def forgot_password(hass, email): + """Initiate forgotten password flow.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.initiate_forgot_password() + except ClientError as err: + raise _map_aws_exception(err) + + +def confirm_forgot_password(hass, confirmation_code, email, new_password): + """Confirm forgotten password code and change password.""" + from botocore.exceptions import ClientError + + cognito = _cognito(hass, username=email) + try: + cognito.confirm_forgot_password(confirmation_code, new_password) + except ClientError as err: + raise _map_aws_exception(err) + + +class Auth(object): + """Class that holds Cloud authentication.""" + + def __init__(self, hass, cognito=None): + """Initialize Hass cloud info object.""" + self.hass = hass + self.cognito = cognito + self.account = None + + @property + def is_logged_in(self): + """Return if user is logged in.""" + return self.account is not None + + def validate_auth(self): + """Validate that the contained auth is valid.""" + from botocore.exceptions import ClientError + + try: + self._refresh_account_info() + except ClientError as err: + if err.response['Error']['Code'] != 'NotAuthorizedException': + _LOGGER.error('Unexpected error verifying auth: %s', err) + return False + + try: + self.renew_access_token() + self._refresh_account_info() + except ClientError: + _LOGGER.error('Unable to refresh auth token: %s', err) + return False + + return True + + def login(self, username, password): + """Login using a username and password.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException + + cognito = _cognito(self.hass, username=username) + + try: + cognito.authenticate(password=password) + self.cognito = cognito + self._refresh_account_info() + _write_info(self.hass, self) + + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) + + def _refresh_account_info(self): + """Refresh the account info. + + Raises boto3 exceptions. + """ + self.account = self.cognito.get_user() + + def renew_access_token(self): + """Refresh token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.renew_access_token() + _write_info(self.hass, self) + return True + except ClientError as err: + _LOGGER.error('Error refreshing token: %s', err) + return False + + def logout(self): + """Invalidate token.""" + from botocore.exceptions import ClientError + + try: + self.cognito.logout() + self.account = None + _write_info(self.hass, self) + except ClientError as err: + raise _map_aws_exception(err) + + +def _read_info(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_info(hass, auth): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if auth.is_logged_in: + content[mode] = { + 'id_token': auth.cognito.id_token, + 'access_token': auth.cognito.access_token, + 'refresh_token': auth.cognito.refresh_token, + } + else: + content.pop(mode, None) + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _cognito(hass, **kwargs): + """Get the client credentials.""" + from warrant import Cognito + + mode = get_mode(hass) + + info = SERVERS.get(mode) + + if info is None: + raise ValueError('Mode {} is not supported.'.format(mode)) + + cognito = Cognito( + user_pool_id=info['identity_pool_id'], + client_id=info['client_id'], + user_pool_region=info['region'], + access_key=info['access_key_id'], + secret_key=info['secret_access_key'], + **kwargs + ) + + return cognito diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..81beab1891b --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'client_id': '3k755iqfcgv8t12o4pl662mnos', + 'identity_pool_id': 'us-west-2_vDOfweDJo', + 'region': 'us-west-2', + 'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ', + 'secret_access_key': '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' + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000..941df7648a6 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,222 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +from functools import wraps +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import ( + HomeAssistantView, RequestDataValidator) + +from . import auth_api +from .const import REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + hass.http.register_view(CloudRegisterView) + hass.http.register_view(CloudConfirmRegisterView) + hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(CloudConfirmForgotPasswordView) + + +_CLOUD_ERRORS = { + auth_api.UserNotFound: (400, "User does not exist."), + auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), + auth_api.Unauthenticated: (401, 'Authentication failed.'), + auth_api.PasswordChangeRequired: (400, 'Password change required.'), + auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), + auth_api.InvalidCode: (400, 'Invalid confirmation code.'), + asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') +} + + +def _handle_cloud_errors(handler): + """Helper method to handle auth errors.""" + @asyncio.coroutine + @wraps(handler) + def error_handler(view, request, *args, **kwargs): + """Handle exceptions that raise from the wrapped request handler.""" + try: + result = yield from handler(view, request, *args, **kwargs) + return result + + except (auth_api.CloudError, asyncio.TimeoutError) as err: + err_info = _CLOUD_ERRORS.get(err.__class__) + if err_info is None: + err_info = (502, 'Unexpected error: {}'.format(err)) + status, msg = err_info + return view.json_message(msg, status_code=status, + message_code=err.__class__.__name__) + + return error_handler + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): str, + })) + def post(self, request, data): + """Handle login request.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.login, data['email'], + data['password']) + + return self.json(_auth_data(auth)) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + @_handle_cloud_errors + def post(self, request): + """Handle logout request.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job(auth.logout) + + return self.json_message('ok') + + +class CloudAccountView(HomeAssistantView): + """View to retrieve account info.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Get account info.""" + hass = request.app['hass'] + auth = hass.data['cloud']['auth'] + + if not auth.is_logged_in: + return self.json_message('Not logged in', 400) + + return self.json(_auth_data(auth)) + + +class CloudRegisterView(HomeAssistantView): + """Register on the Home Assistant cloud.""" + + url = '/api/cloud/register' + name = 'api:cloud:register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + vol.Required('password'): vol.All(str, vol.Length(min=6)), + })) + def post(self, request, data): + """Handle registration request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.register, hass, data['email'], data['password']) + + return self.json_message('ok') + + +class CloudConfirmRegisterView(HomeAssistantView): + """Confirm registration on the Home Assistant cloud.""" + + url = '/api/cloud/confirm_register' + name = 'api:cloud:confirm_register' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle registration confirmation request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_register, hass, data['confirmation_code'], + data['email']) + + return self.json_message('ok') + + +class CloudForgotPasswordView(HomeAssistantView): + """View to start Forgot Password flow..""" + + url = '/api/cloud/forgot_password' + name = 'api:cloud:forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('email'): str, + })) + def post(self, request, data): + """Handle forgot password request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.forgot_password, hass, data['email']) + + return self.json_message('ok') + + +class CloudConfirmForgotPasswordView(HomeAssistantView): + """View to finish Forgot Password flow..""" + + url = '/api/cloud/confirm_forgot_password' + name = 'api:cloud:confirm_forgot_password' + + @asyncio.coroutine + @_handle_cloud_errors + @RequestDataValidator(vol.Schema({ + vol.Required('confirmation_code'): str, + vol.Required('email'): str, + vol.Required('new_password'): vol.All(str, vol.Length(min=6)) + })) + def post(self, request, data): + """Handle forgot password confirm request.""" + hass = request.app['hass'] + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from hass.async_add_job( + auth_api.confirm_forgot_password, hass, + data['confirmation_code'], data['email'], + data['new_password']) + + return self.json_message('ok') + + +def _auth_data(auth): + """Generate the auth data JSON response.""" + return { + 'email': auth.account.email + } diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9e447c8936a..9ce7f30529b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,7 +14,7 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave') @@ -77,11 +77,11 @@ class BaseEditConfigView(HomeAssistantView): """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" raise NotImplementedError - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError @@ -90,7 +90,7 @@ class BaseEditConfigView(HomeAssistantView): """Fetch device specific config.""" hass = request.app['hass'] current = yield from self.read_config(hass) - value = self._get_value(current, config_key) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message('Resource not found', 404) @@ -121,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) current = yield from self.read_config(hass) - self._write_value(current, config_key, data) + self._write_value(hass, current, config_key, data) yield from hass.async_add_job(_write, path, current) @@ -149,11 +149,11 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Return an empty config.""" return {} - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return data.get(config_key, {}) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" data.setdefault(config_key, {}).update(new_value) @@ -165,14 +165,14 @@ class EditIdBasedConfigView(BaseEditConfigView): """Return an empty config.""" return [] - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return next( (val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(data, config_key) + value = self._get_value(hass, data, config_key) if value is None: value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py new file mode 100644 index 00000000000..d25992ecc90 --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,39 @@ +"""Provide configuration end points for Customize.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import async_reload_core_config +from homeassistant.config import DATA_CUSTOMIZE + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Customize config API.""" + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=async_reload_core_config + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index a40e1f64043..53fa200a1b1 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -55,6 +55,7 @@ class ZWaveNodeValueView(HomeAssistantView): 'label': entity_values.primary.label, 'index': entity_values.primary.index, 'instance': entity_values.primary.instance, + 'poll_intensity': entity_values.primary.poll_intensity, } return self.json(values_data) diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter.py new file mode 100644 index 00000000000..64421306644 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 00000000000..6eb0369aa3f --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,50 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER): + if data.is_excluded(device): + continue + + devices.append(AbodeCover(data, device)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return not self._device.is_open + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce4..9e3d675cabe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648..b840c780645 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,213 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up cover(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + return True -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up covers for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXCover(hass, device)) + async_add_devices(entities) - def __init__(self, hass, config): + +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up cover for KNX platform configured within plattform.""" + import xknx + cover = xknx.devices.Cover( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), + group_address_position=config.get(CONF_POSITION_ADDRESS), + travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP), + invert_position=config.get(CONF_INVERT_POSITION), + invert_angle=config.get(CONF_INVERT_ANGLE)) + + hass.data[DATA_KNX].xknx.devices.add(cover) + async_add_devices([KNXCover(hass, cover)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.hass = hass + self.async_register_callbacks() - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self.device.current_position() @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self.device.is_closed() - @property - def current_cover_position(self): - """Return current position of cover. + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Close the cover.""" + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() - @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + yield from self.device.set_position(position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property def current_cover_tilt_position(self): - """Return current position of cover. + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return self.device.current_angle() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + tilt_position = kwargs[ATTR_TILT_POSITION] + yield from self.device.set_angle(tilt_position) - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None - if self._invert_position: - position = 100-position + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.async_schedule_update_ha_state() + if self.device.position_reached(): + self.stop_auto_updater() - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) - - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) - - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) - - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + self.hass.add_job(self.device.auto_stop_if_necessary()) diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6..31e4f1e3cf2 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index eab64fd7abb..d10166a9469 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -21,8 +21,8 @@ from homeassistant.const import ( CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN) from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, - valid_publish_topic, valid_subscribe_topic) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, + CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,8 @@ CONF_SET_POSITION_TEMPLATE = 'set_position_template' CONF_PAYLOAD_OPEN = 'payload_open' CONF_PAYLOAD_CLOSE = 'payload_close' CONF_PAYLOAD_STOP = 'payload_stop' +CONF_PAYLOAD_AVAILABLE = 'payload_available' +CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' CONF_STATE_OPEN = 'state_open' CONF_STATE_CLOSED = 'state_closed' CONF_TILT_CLOSED_POSITION = 'tilt_closed_value' @@ -50,6 +52,8 @@ DEFAULT_NAME = 'MQTT Cover' DEFAULT_PAYLOAD_OPEN = 'OPEN' DEFAULT_PAYLOAD_CLOSE = 'CLOSE' DEFAULT_PAYLOAD_STOP = 'STOP' +DEFAULT_PAYLOAD_AVAILABLE = 'online' +DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline' DEFAULT_OPTIMISTIC = False DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -69,11 +73,16 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, + vol.Optional(CONF_PAYLOAD_AVAILABLE, + default=DEFAULT_PAYLOAD_AVAILABLE): cv.string, + vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -106,6 +115,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_TILT_COMMAND_TOPIC), config.get(CONF_TILT_STATUS_TOPIC), config.get(CONF_QOS), @@ -115,6 +125,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OPEN), config.get(CONF_PAYLOAD_CLOSE), config.get(CONF_PAYLOAD_STOP), + config.get(CONF_PAYLOAD_AVAILABLE), + config.get(CONF_PAYLOAD_NOT_AVAILABLE), config.get(CONF_OPTIMISTIC), value_template, config.get(CONF_TILT_OPEN_POSITION), @@ -131,9 +143,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttCover(CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, command_topic, tilt_command_topic, - tilt_status_topic, qos, retain, state_open, state_closed, - payload_open, payload_close, payload_stop, + def __init__(self, name, state_topic, command_topic, availability_topic, + tilt_command_topic, tilt_status_topic, qos, retain, + state_open, state_closed, payload_open, payload_close, + payload_stop, payload_available, payload_not_available, optimistic, value_template, tilt_open_position, tilt_closed_position, tilt_min, tilt_max, tilt_optimistic, tilt_invert, position_topic, set_position_template): @@ -143,12 +156,16 @@ class MqttCover(CoverDevice): self._name = name self._state_topic = state_topic self._command_topic = command_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._tilt_command_topic = tilt_command_topic self._tilt_status_topic = tilt_status_topic self._qos = qos self._payload_open = payload_open self._payload_close = payload_close self._payload_stop = payload_stop + self._payload_available = payload_available + self._payload_not_available = payload_not_available self._state_open = state_open self._state_closed = state_closed self._retain = retain @@ -178,11 +195,11 @@ class MqttCover(CoverDevice): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): - """Handle new MQTT message.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -203,14 +220,30 @@ class MqttCover(CoverDevice): payload) return - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() + + @callback + def availability_message_received(topic, payload, qos): + """Handle new MQTT availability messages.""" + if payload == self._payload_available: + self._available = True + elif payload == self._payload_not_available: + self._available = False + + self.async_schedule_update_ha_state() if self._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, + state_message_received, self._qos) + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) if self._tilt_status_topic is None: self._tilt_optimistic = True @@ -230,6 +263,11 @@ class MqttCover(CoverDevice): """Return the name of the cover.""" return self._name + @property + def available(self) -> bool: + """Return if cover is available.""" + return self._available + @property def is_closed(self): """Return if the cover is closed.""" @@ -275,7 +313,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): @@ -289,7 +327,7 @@ class MqttCover(CoverDevice): if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -309,7 +347,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_open_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): @@ -319,7 +357,7 @@ class MqttCover(CoverDevice): self._retain) if self._tilt_optimistic: self._tilt_value = self._tilt_closed_position - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index f48a2110eca..cd4ff62b3e9 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -4,42 +4,18 @@ Support for MySensors covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mysensors/ """ -import logging - from homeassistant.components import mysensors -from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.cover import CoverDevice, ATTR_POSITION, DOMAIN from homeassistant.const import STATE_ON, STATE_OFF -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = [] - def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the MySensors platform for covers.""" - if discovery_info is None: - return - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - if not gateways: - return - - for gateway in gateways: - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - map_sv_types = { - pres.S_COVER: [set_req.V_DIMMER, set_req.V_LIGHT], - } - if float(gateway.protocol_version) >= 1.5: - map_sv_types.update({ - pres.S_COVER: [set_req.V_PERCENTAGE, set_req.V_STATUS], - }) - devices = {} - gateway.platform_callbacks.append(mysensors.pf_callback_factory( - map_sv_types, devices, MySensorsCover, add_devices)) + """Setup the mysensors platform for covers.""" + mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsCover, add_devices=add_devices) -class MySensorsCover(mysensors.MySensorsDeviceEntity, CoverDevice): +class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py new file mode 100644 index 00000000000..a9b7598159f --- /dev/null +++ b/homeassistant/components/cover/rflink.py @@ -0,0 +1,121 @@ +""" +Support for Rflink Cover devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.rflink/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.rflink import ( + DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, + DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand) +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME + + +DEPENDENCIES = ['rflink'] + +_LOGGER = logging.getLogger(__name__) + + +CONF_ALIASES = 'aliases' +CONF_GROUP_ALIASES = 'group_aliases' +CONF_GROUP = 'group' +CONF_NOGROUP_ALIASES = 'nogroup_aliases' +CONF_DEVICE_DEFAULTS = 'device_defaults' +CONF_DEVICES = 'devices' +CONF_AUTOMATIC_ADD = 'automatic_add' +CONF_FIRE_EVENT = 'fire_event' +CONF_IGNORE_DEVICES = 'ignore_devices' +CONF_RECONNECT_INTERVAL = 'reconnect_interval' +CONF_SIGNAL_REPETITIONS = 'signal_repetitions' +CONF_WAIT_FOR_ACK = 'wait_for_ack' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})): + DEVICE_DEFAULTS_SCHEMA, + vol.Optional(CONF_DEVICES, default={}): vol.Schema({ + cv.string: { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_GROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NOGROUP_ALIASES, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int), + vol.Optional(CONF_GROUP, default=True): cv.boolean, + }, + }), +}) + + +def devices_from_config(domain_config, hass=None): + """Parse configuration and add Rflink cover devices.""" + devices = [] + for device_id, config in domain_config[CONF_DEVICES].items(): + device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config) + device = RflinkCover(device_id, hass, **device_config) + devices.append(device) + + # Register entity (and aliases) to listen to incoming rflink events + # Device id and normal aliases respond to normal and group command + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + if config[CONF_GROUP]: + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][device_id].append(device) + for _id in config[CONF_ALIASES]: + hass.data[DATA_ENTITY_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + hass.data[DATA_ENTITY_GROUP_LOOKUP][ + EVENT_KEY_COMMAND][_id].append(device) + return devices + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Rflink cover platform.""" + async_add_devices(devices_from_config(config, hass)) + + +class RflinkCover(RflinkCommand, CoverDevice): + """Rflink entity which can switch on/stop/off (eg: cover).""" + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event['command'] + if command in ['on', 'allon']: + self._state = True + elif command in ['off', 'alloff']: + self._state = False + + @property + def should_poll(self): + """No polling available in RFlink cover.""" + return False + + @property + def is_closed(self): + """Return if the cover is closed.""" + return None + + def async_close_cover(self, **kwargs): + """Turn the device close.""" + return self._async_handle_command("close_cover") + + def async_open_cover(self, **kwargs): + """Turn the device open.""" + return self._async_handle_command("open_cover") + + def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + return self._async_handle_command("stop_cover") diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede1..0e28d3ef701 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 769c2fc4ed6..2e3ad7fff16 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, - STATE_OPEN, STATE_CLOSED) + CONF_OPTIMISTIC, STATE_OPEN, STATE_CLOSED) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id @@ -39,6 +39,8 @@ CLOSE_ACTION = 'close_cover' STOP_ACTION = 'stop_cover' POSITION_ACTION = 'set_cover_position' TILT_ACTION = 'set_cover_tilt_position' +CONF_TILT_OPTIMISTIC = 'tilt_optimistic' + CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position' CONF_OPEN_OR_CLOSE = 'open_or_close' @@ -56,6 +58,8 @@ COVER_SCHEMA = vol.Schema({ vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, @@ -83,11 +87,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): stop_action = device_config.get(STOP_ACTION) position_action = device_config.get(POSITION_ACTION) tilt_action = device_config.get(TILT_ACTION) - - if position_template is None and state_template is None: - _LOGGER.error('Must specify either %s' or '%s', - CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE) - continue + optimistic = device_config.get(CONF_OPTIMISTIC) + tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) if position_action is None and open_action is None: _LOGGER.error('Must specify at least one of %s' or '%s', @@ -125,7 +126,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids ) ) if not covers: @@ -142,7 +144,8 @@ class CoverTemplate(CoverDevice): def __init__(self, hass, device_id, friendly_name, state_template, position_template, tilt_template, icon_template, open_action, close_action, stop_action, - position_action, tilt_action, entity_ids): + position_action, tilt_action, + optimistic, tilt_optimistic, entity_ids): """Initialize the Template cover.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -167,6 +170,9 @@ class CoverTemplate(CoverDevice): self._tilt_script = None if tilt_action is not None: self._tilt_script = Script(hass, tilt_action) + self._optimistic = (optimistic or + (not state_template and not position_template)) + self._tilt_optimistic = tilt_optimistic or not tilt_template self._icon = None self._position = None self._tilt_value = None @@ -191,7 +197,7 @@ class CoverTemplate(CoverDevice): @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) @callback def template_cover_startup(event): @@ -199,7 +205,7 @@ class CoverTemplate(CoverDevice): async_track_state_change( self.hass, self._entities, template_cover_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_cover_startup) @@ -260,19 +266,23 @@ class CoverTemplate(CoverDevice): def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - self.hass.async_add_job(self._open_script.async_run()) + yield from self._open_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 100})) + yield from self._position_script.async_run({"position": 100}) + if self._optimistic: + self._position = 100 + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - self.hass.async_add_job(self._close_script.async_run()) + yield from self._close_script.async_run() elif self._position_script: - self.hass.async_add_job(self._position_script.async_run( - {"position": 0})) + yield from self._position_script.async_run({"position": 0}) + if self._optimistic: + self._position = 0 + self.async_schedule_update_ha_state() @asyncio.coroutine def async_stop_cover(self, **kwargs): @@ -284,29 +294,35 @@ class CoverTemplate(CoverDevice): def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - self.hass.async_add_job(self._position_script.async_run( - {"position": self._position})) + yield from self._position_script.async_run( + {"position": self._position}) + if self._optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run( + {"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - self.hass.async_add_job(self._tilt_script.async_run( - {"tilt": self._tilt_value})) + yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + if self._tilt_optimistic: + self.async_schedule_update_ha_state() @asyncio.coroutine def async_update(self): diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi_aqara.py similarity index 77% rename from homeassistant/components/cover/xiaomi.py rename to homeassistant/components/cover/xiaomi_aqara.py index 7e3b0b7044d..17d056a5010 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -2,7 +2,8 @@ import logging from homeassistant.components.cover import CoverDevice -from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class XiaomiGenericCover(XiaomiDevice, CoverDevice): - """Representation of a XiaomiPlug.""" + """Representation of a XiaomiGenericCover.""" def __init__(self, device, name, data_key, xiaomi_hub): - """Initialize the XiaomiPlug.""" + """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -44,19 +45,19 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): def close_cover(self, **kwargs): """Close the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'close') + self._write_to_hub(self._sid, **{self._data_key['status']: 'close'}) def open_cover(self, **kwargs): """Open the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'open') + self._write_to_hub(self._sid, **{self._data_key['status']: 'open'}) def stop_cover(self, **kwargs): """Stop the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'stop') + self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) def set_cover_position(self, position, **kwargs): """Move the cover to a specific position.""" - self._write_to_hub(self._sid, self._data_key['pos'], str(position)) + self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data): """Parse data sent by gateway.""" diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 2f1dde05bab..b85c2d9a53b 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -87,8 +87,8 @@ def async_setup(hass, config): # Set up input boolean tasks.append(bootstrap.async_setup_component( - hass, 'input_slider', - {'input_slider': { + hass, 'input_number', + {'input_number': { 'noise_allowance': {'icon': 'mdi:bell-ring', 'min': 0, 'max': 10, @@ -163,7 +163,7 @@ def async_setup(hass, config): 'scene.romantic_lights'])) tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [ lights[0], switches[1], media_players[0], - 'input_slider.noise_allowance'])) + 'input_number.noise_allowance'])) tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [ lights[2], 'cover.kitchen_window', 'lock.kitchen_door'])) tasks2.append(group.Group.async_create_group(hass, 'Doors', [ diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8192dfa751d..9a6dffc6101 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -18,11 +18,10 @@ from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.components import group, zone -from homeassistant.components.discovery import SERVICE_NETGEAR from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import async_get_last_state @@ -89,10 +88,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ cv.time_period, cv.positive_timedelta) }) -DISCOVERY_PLATFORMS = { - SERVICE_NETGEAR: 'netgear', -} - @bind_hass def is_on(hass: HomeAssistantType, entity_id: str=None): @@ -180,22 +175,6 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): tracker.async_setup_group() - @callback - def async_device_tracker_discovered(service, info): - """Handle the discovery of device tracker platforms.""" - hass.async_add_job( - async_setup_platform(DISCOVERY_PLATFORMS[service], {}, info)) - - discovery.async_listen( - hass, DISCOVERY_PLATFORMS.keys(), async_device_tracker_discovered) - - @asyncio.coroutine - def async_platform_discovered(platform, info): - """Load a platform.""" - yield from async_setup_platform(platform, {}, disc_info=info) - - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) - # Clean up stale devices async_track_utc_time_change( hass, tracker.async_update_stale, second=range(0, 60, 5)) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index cef5eabd901..79d8806fe22 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -19,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] _DEVICES_REGEX = re.compile( - r'(?P([^\s]+))\s+' + + r'(?P([^\s]+)?)\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + - r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+') + r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 071edf42642..05fe0b6997d 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.0'] +REQUIREMENTS = ['aioautomatic==0.6.3'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -205,6 +205,7 @@ class AutomaticData(object): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -236,6 +237,14 @@ class AutomaticData(object): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -323,15 +332,17 @@ class AutomaticData(object): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -352,4 +363,7 @@ class AutomaticData(object): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 00000000000..d4e576bad74 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f20dad1fceb..472b48fef6e 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -248,7 +248,7 @@ class Icloud(DeviceScanner): self._trusted_device, self._verification_code): raise PyiCloudException('Unknown failure') except PyiCloudException as error: - # Reset to the inital 2FA state to allow the user to retry + # Reset to the initial 2FA state to allow the user to retry _LOGGER.error("Failed to verify verification code: %s", error) self._trusted_device = None self._verification_code = None @@ -307,12 +307,15 @@ class Icloud(DeviceScanner): self.api.authenticate() currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) + try: + for devicename in self.devices: + interval = self._intervals.get(devicename, 1) + if ((currentminutes % interval == 0) or + (interval > 10 and + currentminutes % interval in [2, 4])): + self.update_device(devicename) + except ValueError: + _LOGGER.debug("iCloud API returned an error") def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" @@ -397,7 +400,7 @@ class Icloud(DeviceScanner): self.see(**kwargs) self.seen_devices[devicename] = True except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error("No iCloud Devices found") def lost_iphone(self, devicename): """Call the lost iPhone function if the device is found.""" diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py new file mode 100644 index 00000000000..5a7db36e479 --- /dev/null +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -0,0 +1,121 @@ +""" +Support for Zyxel Keenetic NDMS2 based routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.keenetic_ndms2/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +# Interface name to track devices for. Most likely one will not need to +# change it from default 'Home'. This is needed not to track Guest WI-FI- +# clients and router itself +CONF_INTERFACE = 'interface' + +DEFAULT_INTERFACE = 'Home' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class KeeneticNDMS2DeviceScanner(DeviceScanner): + """This class scans for devices using keenetic NDMS2 web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] + self._interface = config[CONF_INTERFACE] + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the given device or None if we don't know.""" + filter_named = [device.name for device in self.last_results + if device.mac == mac] + + if filter_named: + return filter_named[0] + return None + + def _update_info(self): + """Get ARP from keenetic router.""" + _LOGGER.info("Fetching...") + + last_results = [] + + # doing a request + try: + from requests.auth import HTTPDigestAuth + res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( + self._username, self._password + )) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + if info.get('interface') != self._interface: + continue + mac = info.get('mac') + name = info.get('name') + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 4503c4d1b26..f68eb361ca0 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -4,61 +4,51 @@ Support for tracking MySensors devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mysensors/ """ -import logging - from homeassistant.components import mysensors +from homeassistant.components.device_tracker import DOMAIN +from homeassistant.helpers.dispatcher import dispatcher_connect from homeassistant.util import slugify -DEPENDENCIES = ['mysensors'] - -_LOGGER = logging.getLogger(__name__) - def setup_scanner(hass, config, see, discovery_info=None): - """Set up the MySensors tracker.""" - def mysensors_callback(gateway, msg): - """Set up callback for mysensors platform.""" - node = gateway.sensors[msg.node_id] - if node.sketch_name is None: - _LOGGER.debug("No sketch_name: node %s", msg.node_id) - return + """Set up the MySensors device scanner.""" + new_devices = mysensors.setup_mysensors_platform( + hass, DOMAIN, discovery_info, MySensorsDeviceScanner, + device_args=(see, )) + if not new_devices: + return False - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - - child = node.children.get(msg.child_id) - if child is None: - return - position = child.values.get(set_req.V_POSITION) - if child.type != pres.S_GPS or position is None: - return - try: - latitude, longitude, _ = position.split(',') - except ValueError: - _LOGGER.error("Payload for V_POSITION %s is not of format " - "latitude, longitude, altitude", position) - return - name = '{} {} {}'.format( - node.sketch_name, msg.node_id, child.id) - attr = { - mysensors.ATTR_CHILD_ID: child.id, - mysensors.ATTR_DESCRIPTION: child.description, - mysensors.ATTR_DEVICE: gateway.device, - mysensors.ATTR_NODE_ID: msg.node_id, - } - see( - dev_id=slugify(name), - host_name=name, - gps=(latitude, longitude), - battery=node.battery_level, - attributes=attr - ) - - gateways = hass.data.get(mysensors.MYSENSORS_GATEWAYS) - - for gateway in gateways: - if float(gateway.protocol_version) < 2.0: - continue - gateway.platform_callbacks.append(mysensors_callback) + for device in new_devices: + dev_id = ( + id(device.gateway), device.node_id, device.child_id, + device.value_type) + dispatcher_connect( + hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + device.update_callback) return True + + +class MySensorsDeviceScanner(mysensors.MySensorsDevice): + """Represent a MySensors scanner.""" + + def __init__(self, see, *args): + """Set up instance.""" + super().__init__(*args) + self.see = see + + def update_callback(self): + """Update the device.""" + self.update() + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + position = child.values[self.value_type] + latitude, longitude, _ = position.split(',') + + self.see( + dev_id=slugify(self.name), + host_name=self.name, + gps=(latitude, longitude), + battery=node.battery_level, + attributes=self.device_state_attributes + ) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index b23008336ac..f301b2f454e 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -1,5 +1,5 @@ """ -Support the OwnTracks platform. +Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ @@ -16,7 +16,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME -from homeassistant.util import convert, slugify +from homeassistant.util import slugify, decorator from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA @@ -25,6 +25,8 @@ REQUIREMENTS = ['libnacl==1.5.2'] _LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + BEACON_DEV_ID = 'beacon' CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' @@ -32,17 +34,7 @@ CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -EVENT_TOPIC = 'owntracks/+/+/event' - -LOCATION_TOPIC = 'owntracks/+/+' - -VALIDATE_LOCATION = 'location' -VALIDATE_TRANSITION = 'transition' -VALIDATE_WAYPOINTS = 'waypoints' - -WAYPOINT_LAT_KEY = 'lat' -WAYPOINT_LON_KEY = 'lon' -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint' +OWNTRACKS_TOPIC = 'owntracks/#' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), @@ -72,300 +64,60 @@ def get_cipher(): @asyncio.coroutine def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) + context = context_from_config(async_see, config) - mobile_beacons_active = defaultdict(list) - regions_entered = defaultdict(list) - - def decrypt_payload(topic, ciphertext): - """Decrypt encrypted payload.""" + @asyncio.coroutine + def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - ciphertext = base64.b64decode(ciphertext) - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None - - def validate_payload(topic, payload, data_type): - """Validate the OwnTracks payload.""" - try: - data = json.loads(payload) + message = json.loads(payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return None - if isinstance(data, dict) and \ - data.get('_type') == 'encrypted' and \ - 'data' in data: - plaintext_payload = decrypt_payload(topic, data['data']) - if plaintext_payload is None: - return None - return validate_payload(topic, plaintext_payload, data_type) + message['topic'] = topic - if not isinstance(data, dict) or data.get('_type') != data_type: - _LOGGER.debug("Skipping %s update for following data " - "because of missing or malformatted data: %s", - data_type, data) - return None - if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS: - return data - if max_gps_accuracy is not None and \ - convert(data.get('acc'), float, 0.0) > max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - data_type, max_gps_accuracy, payload) - return None - if convert(data.get('acc'), float, 1.0) == 0.0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - data_type, payload) - return None - - return data - - @callback - def async_owntracks_location_update(topic, payload, qos): - """MQTT message received.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typelocation - data = validate_payload(topic, payload, VALIDATE_LOCATION) - if not data: - return - - dev_id, kwargs = _parse_see_args(topic, data) - - if regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - regions_entered[-1]) - return - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - @callback - def async_owntracks_event_update(topic, payload, qos): - """Handle MQTT event (geofences).""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typetransition - data = validate_payload(topic, payload, VALIDATE_TRANSITION) - if not data: - return - - if data.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") - if location.lower() == 'home': - location = STATE_HOME - - dev_id, kwargs = _parse_see_args(topic, data) - - def enter_event(): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = mobile_beacons_active[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - def leave_event(): - """Execute leave event.""" - regions = regions_entered[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None - - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - "Ignoring GPS in region exit because accuracy" - "is zero: %s", payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - "Ignoring GPS in region exit because expected " - "GPS accuracy %s is not met: %s", - max_gps_accuracy, payload) - if valid_gps: - hass.async_add_job(async_see(**kwargs)) - async_see_beacons(dev_id, kwargs) - - beacons = mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - - if data['event'] == 'enter': - enter_event() - elif data['event'] == 'leave': - leave_event() - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - data['event']) - return - - @callback - def async_owntracks_waypoint_update(topic, payload, qos): - """List of waypoints published by a user.""" - # Docs on available data: - # http://owntracks.org/booklet/tech/json/#_typewaypoints - data = validate_payload(topic, payload, VALIDATE_WAYPOINTS) - if not data: - return - - wayps = data['waypoints'] - _LOGGER.info("Got %d waypoints from %s", len(wayps), topic) - for wayp in wayps: - name = wayp['desc'] - pretty_name = parse_topic(topic, True)[1] + ' - ' + name - lat = wayp[WAYPOINT_LAT_KEY] - lon = wayp[WAYPOINT_LON_KEY] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - hass.async_add_job(zone.async_update_ha_state()) - - @callback - def async_see_beacons(dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - # the battery state applies to the tracking device, not the beacon - kwargs.pop('battery', None) - for beacon in mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - hass.async_add_job(async_see(**kwargs)) + yield from async_handle_message(hass, context, message) yield from mqtt.async_subscribe( - hass, LOCATION_TOPIC, async_owntracks_location_update, 1) - yield from mqtt.async_subscribe( - hass, EVENT_TOPIC, async_owntracks_event_update, 1) - - if waypoint_import: - if waypoint_whitelist is None: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format('+', '+'), - async_owntracks_waypoint_update, 1) - else: - for whitelist_user in waypoint_whitelist: - yield from mqtt.async_subscribe( - hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), - async_owntracks_waypoint_update, 1) + hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1) return True -def parse_topic(topic, pretty=False): +def _parse_topic(topic): """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. Async friendly. """ - parts = topic.split('/') - dev_id_format = '' - if pretty: - dev_id_format = '{} {}' - else: - dev_id_format = '{}_{}' - dev_id = slugify(dev_id_format.format(parts[1], parts[2])) - host_name = parts[1] - return (host_name, dev_id) + _, user, device, *_ = topic.split('/', 3) + + return user, device -def _parse_see_args(topic, data): +def _parse_see_args(message): """Parse the OwnTracks location parameters, into the format see expects. Async friendly. """ - (host_name, dev_id) = parse_topic(topic, False) + user, device = _parse_topic(message['topic']) + dev_id = slugify('{}_{}'.format(user, device)) kwargs = { 'dev_id': dev_id, - 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'host_name': user, + 'gps': (message['lat'], message['lon']), 'attributes': {} } - if 'acc' in data: - kwargs['gps_accuracy'] = data['acc'] - if 'batt' in data: - kwargs['battery'] = data['batt'] - if 'vel' in data: - kwargs['attributes']['velocity'] = data['vel'] - if 'tid' in data: - kwargs['attributes']['tid'] = data['tid'] - if 'addr' in data: - kwargs['attributes']['address'] = data['addr'] + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] return dev_id, kwargs @@ -382,3 +134,281 @@ def _set_gps_from_zone(kwargs, location, zone): kwargs['gps_accuracy'] = zone.attributes['radius'] kwargs['location_name'] = location return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + ciphertext = base64.b64decode(ciphertext) + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +def context_from_config(async_see, config): + """Create an async context from Home Assistant config.""" + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) + + return OwnTracksContext(async_see, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist): + """Initialize an OwnTracks context.""" + self.async_see = async_see + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(list) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + @asyncio.coroutine + def async_see_beacons(self, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + # the battery state applies to the tracking device, not the beacon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + yield from self.async_see(**kwargs) + + +@HANDLERS.register('location') +@asyncio.coroutine +def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + dev_id, kwargs = _parse_see_args(message) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so assume mobile + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.append(location) + _LOGGER.info("Added beacon %s", location) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + +@asyncio.coroutine +def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + new_region = regions[-1] if regions else None + + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + return + + else: + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + yield from context.async_see(**kwargs) + yield from context.async_see_beacons(dev_id, kwargs) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + + +@HANDLERS.register('transition') +@asyncio.coroutine +def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + yield from _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + yield from _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +@HANDLERS.register('waypoints') +@asyncio.coroutine +def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'])[0] + + if user not in context.waypoint_whitelist: + return + + wayps = message['waypoints'] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'])) + + for wayp in wayps: + name = wayp['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = wayp['lat'] + lon = wayp['lon'] + rad = wayp['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + continue + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('encrypted') +@asyncio.coroutine +def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + plaintext_payload = _decrypt_payload(context.secret, message['topic'], + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + decrypted['topic'] = message['topic'] + + yield from async_handle_message(hass, context, decrypted) + + +@asyncio.coroutine +def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + handler = HANDLERS.get(msgtype) + + if handler is None: + _LOGGER.warning( + 'Received unsupported message type: %s.', msgtype) + return + + yield from handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py new file mode 100644 index 00000000000..dcc3300cc12 --- /dev/null +++ b/homeassistant/components/device_tracker/owntracks_http.py @@ -0,0 +1,54 @@ +""" +Device tracker platform that adds support for OwnTracks over HTTP. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.owntracks_http/ +""" +import asyncio + +from aiohttp.web_exceptions import HTTPInternalServerError + +from homeassistant.components.http import HomeAssistantView + +# pylint: disable=unused-import +from .owntracks import ( # NOQA + REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) + + +DEPENDENCIES = ['http'] + + +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up an OwnTracks tracker.""" + context = context_from_config(async_see, config) + + hass.http.register_view(OwnTracksView(context)) + + return True + + +class OwnTracksView(HomeAssistantView): + """View to handle OwnTracks HTTP requests.""" + + url = '/api/owntracks/{user}/{device}' + name = 'api:owntracks' + + def __init__(self, context): + """Initialize OwnTracks URL endpoints.""" + self.context = context + + @asyncio.coroutine + def post(self, request, user, device): + """Handle an OwnTracks message.""" + hass = request.app['hass'] + + message = yield from request.json() + message['topic'] = 'owntracks/{}/{}'.format(user, device) + + try: + yield from async_handle_message(hass, self.context, message) + return self.json([]) + + except ValueError: + raise HTTPInternalServerError diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 3efae2b9ce2..d0cfcff20ef 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_HOST _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.9'] +REQUIREMENTS = ['pysnmp==4.3.10'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' @@ -36,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def get_scanner(hass, config): - """Validate the configuration and return an snmp scanner.""" + """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -75,7 +75,7 @@ class SnmpScanner(DeviceScanner): return [client['mac'] for client in self.last_results if client.get('mac')] - # Supressing no-self-use warning + # Suppressing no-self-use warning # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 00000000000..4945e98a94d --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index a471ca5c96a..3ed41b08082 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -32,7 +32,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( + cv.boolean, cv.isfile) }) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a..7872f8f1f1c 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index 8b8db3da2d8..12e64b724dd 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -69,7 +69,7 @@ class XiaomiDeviceScanner(DeviceScanner): return self.mac2name.get(device.upper(), None) def _update_info(self): - """Ensure the informations from the router are up to date. + """Ensure the information from the router are up to date. Returns true if scanning successful. """ diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a..50cc771ffd3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.1.0'] +REQUIREMENTS = ['netdisco==1.2.2'] DOMAIN = 'discovery' @@ -34,6 +34,7 @@ SERVICE_HASSIO = 'hassio' SERVICE_AXIS = 'axis' SERVICE_APPLE_TV = 'apple_tv' SERVICE_WINK = 'wink' +SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -44,6 +45,7 @@ SERVICE_HANDLERS = { SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), + SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), @@ -100,6 +102,7 @@ def async_setup(hass, config): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py new file mode 100644 index 00000000000..421c85a0f94 --- /dev/null +++ b/homeassistant/components/doorbird.py @@ -0,0 +1,44 @@ +"""Support for a DoorBird video doorbell.""" + +import logging +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['DoorBirdPy==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'doorbird' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the DoorBird component.""" + device_ip = config[DOMAIN].get(CONF_HOST) + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + + from doorbirdpy import DoorBird + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) + hass.data[DOMAIN] = device + return True + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 2e26b306673..0450ba175ee 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -122,7 +122,7 @@ def setup(hass, config): _LOGGER.info("Downloading of %s done", url) except requests.exceptions.ConnectionError: - _LOGGER.exception("ConnectionError occured for %s", url) + _LOGGER.exception("ConnectionError occurred for %s", url) # Remove file if we started downloading but failed if final_path and os.path.isfile(final_path): diff --git a/homeassistant/components/duckdns.py b/homeassistant/components/duckdns.py new file mode 100644 index 00000000000..0045b9421a2 --- /dev/null +++ b/homeassistant/components/duckdns.py @@ -0,0 +1,102 @@ +"""Integrate with DuckDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +DOMAIN = 'duckdns' +UPDATE_URL = 'https://www.duckdns.org/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) +SERVICE_SET_TXT = 'set_txt' +ATTR_TXT = 'txt' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +SERVICE_TXT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TXT): vol.Any(None, cv.string) +}) + + +@bind_hass +@asyncio.coroutine +def async_set_txt(hass, txt): + """Set the txt record. Pass in None to remove it.""" + yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, { + ATTR_TXT: txt + }, blocking=True) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the DuckDNS component.""" + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_duckdns(session, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token) + + @asyncio.coroutine + def update_domain_service(call): + """Update the DuckDNS entry.""" + yield from _update_duckdns(session, domain, token, + txt=call.data[ATTR_TXT]) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + hass.services.async_register( + DOMAIN, SERVICE_SET_TXT, update_domain_service, + schema=SERVICE_TXT_SCHEMA) + + return result + + +_SENTINEL = object() + + +@asyncio.coroutine +def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): + """Update DuckDNS.""" + params = { + 'domains': domain, + 'token': token, + } + + if txt is not _SENTINEL: + if txt is None: + # Pass in empty txt value to indicate it's clearing txt record + params['txt'] = '' + clear = True + else: + params['txt'] = txt + + if clear: + params['clear'] = 'true' + + resp = yield from session.get(UPDATE_URL, params=params) + body = yield from resp.text() + + if body != 'OK': + _LOGGER.warning('Updating DuckDNS domain %s failed', domain) + return False + + return True diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 9e74299e6bc..0b0c9d1d65a 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -15,7 +15,7 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle -REQUIREMENTS = ['python-ecobee-api==0.0.8'] +REQUIREMENTS = ['python-ecobee-api==0.0.10'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 40a5d884aed..dda556ba6a4 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -209,7 +209,7 @@ class EightSleepUserEntity(Entity): @callback def async_eight_user_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) @@ -233,7 +233,7 @@ class EightSleepHeatEntity(Entity): @callback def async_eight_heat_update(): """Update callback.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_schedule_update_ha_state(True) async_dispatcher_connect( self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index ae0a26aaea4..a83f5337cae 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.components.http import REQUIREMENTS # NOQA from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv from .hue_api import ( HueUsernameView, HueAllLightsStateView, HueOneLightStateView, @@ -66,6 +67,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) ATTR_EMULATED_HUE = 'emulated_hue' +ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden' def setup(hass, yaml_config): @@ -129,7 +131,7 @@ class Config(object): if self.type == TYPE_ALEXA: _LOGGER.warning("Alexa type is deprecated and will be removed in a" - "future version") + " future version") # Get the IP address that will be passed to the Echo during discovery self.host_ip_addr = conf.get(CONF_HOST_IP) @@ -148,7 +150,7 @@ class Config(object): self.listen_port) if self.type == TYPE_GOOGLE and self.listen_port != 80: - _LOGGER.warning("When targetting Google Home, listening port has " + _LOGGER.warning("When targeting Google Home, listening port has " "to be port 80") # Get whether or not UPNP binds to multicast address (239.255.255.250) @@ -223,7 +225,15 @@ class Config(object): domain = entity.domain.lower() explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None) - + explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None) + if explicit_expose is True or explicit_hidden is False: + expose = True + elif explicit_expose is False or explicit_hidden is True: + expose = False + else: + expose = None + get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN, + ATTR_EMULATED_HUE, None) domain_exposed_by_default = \ self.expose_by_default and domain in self.exposed_domains @@ -231,9 +241,9 @@ class Config(object): # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed is_default_exposed = \ - domain_exposed_by_default and explicit_expose is not False + domain_exposed_by_default and expose is not False - return is_default_exposed or explicit_expose + return is_default_exposed or expose def _load_numbers_json(self): """Set up helper method to load numbers json.""" diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f8d41424064..548b6f3d771 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,4 +1,4 @@ -"""Provides a UPNP discovery method that mimicks Hue hubs.""" +"""Provides a UPNP discovery method that mimics Hue hubs.""" import threading import socket import logging @@ -123,20 +123,20 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 if ssdp_socket in read: data, addr = ssdp_socket.recvfrom(1024) else: - # most likely the timeout, so check for interupt + # most likely the timeout, so check for interrupt continue except socket.error as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occured: %s", + _LOGGER.error("UPNP Responder socket exception occurred: %s", ex.__str__) # without the following continue, a second exception occurs # because the data object has not been initialized continue - if "M-SEARCH" in data.decode('utf-8'): + if "M-SEARCH" in data.decode('utf-8', errors='ignore'): # SSDP M-SEARCH method received, respond to it with our info resp_socket = socket.socket( socket.AF_INET, socket.SOCK_DGRAM) diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index 5bdfec08427..e12e3476c3a 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -137,7 +137,7 @@ class InsteonLocalFanDevice(FanEntity): @property def name(self): - """Return the the name of the node.""" + """Return the name of the node.""" return self.node.deviceName @property diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index bc732aa0aff..e76e11d4786 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,6 +78,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT fan platform.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + async_add_devices([MqttFan( config.get(CONF_NAME), { @@ -160,7 +163,7 @@ class MqttFan(FanEntity): self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -177,7 +180,7 @@ class MqttFan(FanEntity): self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -193,7 +196,7 @@ class MqttFan(FanEntity): self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: yield from mqtt.async_subscribe( @@ -287,7 +290,7 @@ class MqttFan(FanEntity): if self._optimistic_speed: self._speed = speed - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() @asyncio.coroutine def async_oscillate(self, oscillating: bool) -> None: @@ -309,4 +312,4 @@ class MqttFan(FanEntity): if self._optimistic_oscillation: self._oscillation = oscillating - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 887d07e5855..f5efa1ef623 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -242,7 +242,7 @@ class FFmpegBase(Entity): def async_start_handle(event): """Start FFmpeg process.""" yield from self._async_start_ffmpeg(None) - self.hass.async_add_job(self.async_update_ha_state()) + self.async_schedule_update_ha_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 29f6ef577e5..112c93403b0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -28,6 +28,7 @@ URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') ATTR_THEMES = 'themes' +ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', @@ -50,6 +51,7 @@ for size in (192, 384, 512, 1024): }) DATA_PANELS = 'frontend_panels' +DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -66,6 +68,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(ATTR_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), + vol.Optional(ATTR_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -105,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -123,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, @@ -169,6 +174,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) +@bind_hass +def add_extra_html_url(hass, url): + """Register extra html url to load.""" + url_set = hass.data.get(DATA_EXTRA_HTML_URL) + if url_set is None: + url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -208,6 +222,9 @@ def setup(hass, config): else: hass.data[DATA_PANELS] = {} + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', @@ -217,6 +234,9 @@ def setup(hass, config): themes = config.get(DOMAIN, {}).get(ATTR_THEMES) setup_themes(hass, themes) + for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url) + return True @@ -362,7 +382,9 @@ class IndexView(HomeAssistantView): compatibility_url=compatibility_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT]) + dev_mode=request.app[KEY_DEVELOPMENT], + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL]) return web.Response(text=resp, content_type='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6420bb79739..70e7e777510 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -21,7 +21,7 @@ - +