diff --git a/.coveragerc b/.coveragerc index 8b31cca97b5..b47616973f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -170,6 +170,9 @@ omit = homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/skybell.py + homeassistant/components/*/skybell.py + homeassistant/components/tado.py homeassistant/components/*/tado.py @@ -187,6 +190,9 @@ omit = homeassistant/components/*/thinkingcleaner.py + homeassistant/components/toon.py + homeassistant/components/*/toon.py + homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py @@ -217,7 +223,7 @@ omit = homeassistant/components/wemo.py homeassistant/components/*/wemo.py - homeassistant/components/wink.py + homeassistant/components/wink/* homeassistant/components/*/wink.py homeassistant/components/xiaomi_aqara.py @@ -267,6 +273,7 @@ omit = homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/onvif.py homeassistant/components/camera/synology.py + homeassistant/components/camera/yi.py homeassistant/components/climate/eq3btsmart.py homeassistant/components/climate/flexit.py homeassistant/components/climate/heatmiser.py @@ -405,7 +412,7 @@ omit = homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py homeassistant/components/notify/clicksend.py - homeassistant/components/notify/clicksendaudio.py + homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py @@ -427,6 +434,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 @@ -529,6 +537,7 @@ omit = homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py + homeassistant/components/sensor/serial.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/shodan.py homeassistant/components/sensor/skybeacon.py @@ -549,6 +558,7 @@ omit = 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 @@ -585,6 +595,7 @@ omit = 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 diff --git a/CODEOWNERS b/CODEOWNERS index ad9345c3ab6..0560f5d5310 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,10 +39,28 @@ 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/camera/yi.py @bachya +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/media_player/kodi.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/media_player/monoprice.py @etsinko +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/climate/eq3btsmart.py @rytilahti + +homeassistant/components/*/axis.py @Kane610 +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 908e8481eee..3eadc8e7b03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,8 @@ MAINTAINER Paulus Schoutsen #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no #ENV INSTALL_PHANTOMJS no -#ENV INSTALL_COAP no #ENV INSTALL_SSOCR no - VOLUME /config RUN mkdir -p /usr/src/app @@ -26,10 +24,10 @@ 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+. +# Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython # Copy source COPY . . diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b62b86b30d2..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 diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6db147a5f59..b5ac57080d1 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -10,6 +10,7 @@ Component design guidelines: import asyncio import itertools as it import logging +import os import homeassistant.core as ha import homeassistant.config as conf_util @@ -110,6 +111,11 @@ def async_reload_core_config(hass): @asyncio.coroutine def async_setup(hass, config): """Set up general services related to Home Assistant.""" + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + @asyncio.coroutine def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" @@ -149,11 +155,14 @@ def async_setup(hass, config): yield from asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TURN_OFF]) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TURN_ON]) hass.services.async_register( - ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service, + descriptions[ha.DOMAIN][SERVICE_TOGGLE]) @asyncio.coroutine def async_handle_core_service(call): @@ -178,11 +187,14 @@ def async_setup(hass, config): hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE)) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_STOP]) hass.services.async_register( - ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service) + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_HOMEASSISTANT_RESTART]) hass.services.async_register( - ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service) + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service, + descriptions[ha.DOMAIN][SERVICE_CHECK_CONFIG]) @asyncio.coroutine def async_handle_reload_config(call): @@ -197,6 +209,7 @@ def async_setup(hass, config): hass, conf.get(ha.DOMAIN) or {}) hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config, + descriptions[ha.DOMAIN][SERVICE_RELOAD_CORE_CONFIG]) return True diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index d1c1a2b84c2..581045c3790 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -10,24 +10,23 @@ 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.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.11.9'] +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, CONF_LIGHTS, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from requests.exceptions import HTTPError, ConnectTimeout + +REQUIREMENTS = ['abodepy==0.12.1'] _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by goabode.com" -CONF_LIGHTS = "lights" -CONF_POLLING = "polling" +CONF_POLLING = 'polling' DOMAIN = 'abode' @@ -93,10 +92,9 @@ class AbodeSystem(object): 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.abode = abodepy.Abode( + username, password, auto_login=True, get_devices=True, + get_automations=True) self.name = name self.polling = polling self.exclude = exclude @@ -210,7 +208,7 @@ def setup_hass_services(hass): def setup_hass_events(hass): - """Home assistant start and stop callbacks.""" + """Home Assistant start and stop callbacks.""" def startup(event): """Listen for push events.""" hass.data[DOMAIN].abode.events.start() diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 005048ba8c1..1141e42f9ef 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -124,20 +124,13 @@ def async_setup(hass, config): method = "async_{}".format(SERVICE_TO_METHOD[service.service]) + update_tasks = [] for alarm in target_alarms: yield from getattr(alarm, method)(code) - update_tasks = [] - for alarm in target_alarms: if not alarm.should_poll: continue - - update_coro = hass.async_add_job( - alarm.async_update_ha_state(True)) - if hasattr(alarm, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(alarm.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) 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/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index 7e976296b16..7719ab884bc 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) -REQUIREMENTS = ['pythonegardia==1.0.21'] +REQUIREMENTS = ['pythonegardia==1.0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index dbf66a63901..61db142ac42 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -11,19 +11,18 @@ from homeassistant.util.decorator import Registry HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -ATTR_HEADER = 'header' -ATTR_NAME = 'name' -ATTR_NAMESPACE = 'namespace' -ATTR_MESSAGE_ID = 'messageId' -ATTR_PAYLOAD = 'payload' -ATTR_PAYLOAD_VERSION = 'payloadVersion' +API_DIRECTIVE = 'directive' +API_EVENT = 'event' +API_HEADER = 'header' +API_PAYLOAD = 'payload' +API_ENDPOINT = 'endpoint' MAPPING_COMPONENT = { - switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None], + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], light.DOMAIN: [ - 'LIGHT', ('turnOff', 'turnOn'), { - light.SUPPORT_BRIGHTNESS: 'setPercentage' + 'LIGHT', ('Alexa.PowerController',), { + light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController' } ], } @@ -32,51 +31,75 @@ MAPPING_COMPONENT = { @asyncio.coroutine def async_handle_message(hass, message): """Handle incoming API messages.""" - assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2 + 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(message[ATTR_HEADER][ATTR_NAME]) + funct_ref = HANDLERS.get((namespace, name)) if not funct_ref: _LOGGER.warning( - "Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME]) + "Unsupported API request %s/%s", namespace, name) return api_error(message) return (yield from funct_ref(hass, message)) -def api_message(name, namespace, payload=None): +def api_message(request, name='Response', namespace='Alexa', payload=None): """Create a API formatted response message. Async friendly. """ payload = payload or {} - return { - ATTR_HEADER: { - ATTR_MESSAGE_ID: str(uuid4()), - ATTR_NAME: name, - ATTR_NAMESPACE: namespace, - ATTR_PAYLOAD_VERSION: '2', - }, - ATTR_PAYLOAD: payload, + + 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 -def api_error(request, exc='DriverInternalError'): + # 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. """ - return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE]) + payload = { + 'type': error_type, + 'message': error_message, + } + + return api_message(request, name='ErrorResponse', payload=payload) -@HANDLERS.register('DiscoverAppliancesRequest') +@HANDLERS.register(('Alexa.Discovery', 'Discover')) @asyncio.coroutine def async_api_discovery(hass, request): """Create a API formatted discovery response. Async friendly. """ - discovered_appliances = [] + discovery_endpoints = [] for entity in hass.states.async_all(): class_data = MAPPING_COMPONENT.get(entity.domain) @@ -84,35 +107,42 @@ def async_api_discovery(hass, request): if not class_data: continue - appliance = { - 'actions': [], - 'applianceTypes': [class_data[0]], + endpoint = { + 'displayCategories': [class_data[0]], 'additionalApplianceDetails': {}, - 'applianceId': entity.entity_id.replace('.', '#'), - 'friendlyDescription': '', + 'endpointId': entity.entity_id.replace('.', '#'), 'friendlyName': entity.name, - 'isReachable': True, + 'description': '', 'manufacturerName': 'Unknown', - 'modelName': 'Unknown', - 'version': 'Unknown', } + actions = set() # static actions if class_data[1]: - appliance['actions'].extend(list(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: - appliance['actions'].append(action_name) + actions.add(action_name) - discovered_appliances.append(appliance) + # 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( - 'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery', - payload={'discoveredAppliances': discovered_appliances}) + request, name='Discover.Response', namespace='Alexa.Discovery', + payload={'endpoints': discovery_endpoints}) def extract_entity(funct): @@ -120,22 +150,21 @@ def extract_entity(funct): @asyncio.coroutine def async_api_entity_wrapper(hass, request): """Process a turn on request.""" - entity_id = \ - request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.') + 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[ATTR_HEADER][ATTR_NAME], entity_id) - return api_error(request) + 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('TurnOnRequest') +@HANDLERS.register(('Alexa.PowerController', 'TurnOn')) @extract_entity @asyncio.coroutine def async_api_turn_on(hass, request, entity): @@ -144,10 +173,10 @@ def async_api_turn_on(hass, request, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=True) - return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control') + return api_message(request) -@HANDLERS.register('TurnOffRequest') +@HANDLERS.register(('Alexa.PowerController', 'TurnOff')) @extract_entity @asyncio.coroutine def async_api_turn_off(hass, request, entity): @@ -156,22 +185,19 @@ def async_api_turn_off(hass, request, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=True) - return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control') + return api_message(request) -@HANDLERS.register('SetPercentageRequest') +@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) @extract_entity @asyncio.coroutine -def async_api_set_percentage(hass, request, entity): - """Process a set percentage request.""" - if entity.domain == light.DOMAIN: - brightness = request[ATTR_PAYLOAD]['percentageState']['value'] - yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - light.ATTR_BRIGHTNESS: brightness, - }, blocking=True) - else: - return api_error(request) +def async_api_set_brightness(hass, request, entity): + """Process a set brightness request.""" + brightness = request[API_PAYLOAD]['brightness'] - return api_message( - 'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control') + 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/arlo.py b/homeassistant/components/arlo.py index 0ab629cfbd4..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.6'] +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 51b2ea89f0f..571888038a6 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -99,8 +99,8 @@ def async_trigger(hass, config, action): return async_remove_track_same = async_track_same_state( - hass, True, time_delta, call_action, entity_ids=entity_id, - async_check_func=check_numeric_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) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index e7a01cb7115..7ed44761be8 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -65,7 +65,9 @@ def async_trigger(hass, config, action): return async_remove_track_same = async_track_same_state( - hass, to_s.state, time_delta, call_action, entity_ids=entity_id) + 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) 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/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 13b9fc1f005..e597f1d0bbe 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.netatmo import CameraData from homeassistant.loader import get_component -from homeassistant.const import CONF_TIMEOUT, CONF_OFFSET +from homeassistant.const import CONF_TIMEOUT from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -44,14 +44,12 @@ CONF_WELCOME_SENSORS = 'welcome_sensors' CONF_PRESENCE_SENSORS = 'presence_sensors' CONF_TAG_SENSORS = 'tag_sensors' -DEFAULT_TIMEOUT = 15 -DEFAULT_OFFSET = 90 +DEFAULT_TIMEOUT = 90 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.positive_int, vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES): vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, @@ -66,7 +64,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): netatmo = get_component('netatmo') home = config.get(CONF_HOME) timeout = config.get(CONF_TIMEOUT) - offset = config.get(CONF_OFFSET) + if timeout is None: + timeout = DEFAULT_TIMEOUT module_name = None @@ -94,7 +93,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in welcome_sensors: add_devices([NetatmoBinarySensor( data, camera_name, module_name, home, timeout, - offset, camera_type, variable)], True) + camera_type, variable)], True) if camera_type == 'NOC': if CONF_CAMERAS in config: if config[CONF_CAMERAS] != [] and \ @@ -102,14 +101,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): continue for variable in presence_sensors: add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) for module_name in data.get_module_names(camera_name): for variable in tag_sensors: camera_type = None add_devices([NetatmoBinarySensor( - data, camera_name, module_name, home, timeout, offset, + data, camera_name, module_name, home, timeout, camera_type, variable)], True) @@ -117,14 +116,13 @@ class NetatmoBinarySensor(BinarySensorDevice): """Represent a single binary sensor in a Netatmo Camera device.""" def __init__(self, data, camera_name, module_name, home, - timeout, offset, camera_type, sensor): + timeout, camera_type, sensor): """Set up for access to the Netatmo camera events.""" self._data = data self._camera_name = camera_name self._module_name = module_name self._home = home self._timeout = timeout - self._offset = offset if home: self._name = '{} / {}'.format(home, camera_name) else: @@ -173,40 +171,39 @@ class NetatmoBinarySensor(BinarySensorDevice): if self._sensor_name == "Someone known": self._state =\ self._data.camera_data.someoneKnownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Someone unknown": self._state =\ self._data.camera_data.someoneUnknownSeen( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Motion": self._state =\ self._data.camera_data.motionDetected( - self._home, self._camera_name, self._timeout*60) + self._home, self._camera_name, self._timeout) elif self._cameratype == 'NOC': if self._sensor_name == "Outdoor motion": self._state =\ self._data.camera_data.outdoormotionDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor human": self._state =\ self._data.camera_data.humanDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor animal": self._state =\ self._data.camera_data.animalDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) elif self._sensor_name == "Outdoor vehicle": self._state =\ self._data.camera_data.carDetected( - self._home, self._camera_name, self._offset) + self._home, self._camera_name, self._timeout) if self._sensor_name == "Tag Vibration": self._state =\ self._data.camera_data.moduleMotionDetected( self._home, self._module_name, self._camera_name, - self._timeout*60) + self._timeout) elif self._sensor_name == "Tag Open": self._state =\ self._data.camera_data.moduleOpened( - self._home, self._module_name, self._camera_name) - else: - return None + self._home, self._module_name, self._camera_name, + self._timeout) diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py index 874f7a81a17..f75f7644c4e 100644 --- a/homeassistant/components/binary_sensor/raincloud.py +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -59,6 +59,8 @@ class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice): """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): 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/template.py b/homeassistant/components/binary_sensor/template.py index 84afd01303f..16167a93b82 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -15,13 +15,12 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, - CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, STATE_ON) + CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START) 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, async_track_same_state) -from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -94,10 +93,6 @@ class BinarySensorTemplate(BinarySensorDevice): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" @@ -135,7 +130,7 @@ class BinarySensorTemplate(BinarySensorDevice): return False @callback - def _async_render(self, *args): + def _async_render(self): """Get the state of template.""" try: return self._template.async_render().lower() == 'true' @@ -171,5 +166,5 @@ class BinarySensorTemplate(BinarySensorDevice): period = self._delay_on if state else self._delay_off async_track_same_state( - self.hass, state, period, set_state, entity_ids=self._entities, - async_check_func=self._async_render) + 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 index af7e394b50e..a7cda90b3f6 100644 --- a/homeassistant/components/binary_sensor/tesla.py +++ b/homeassistant/components/binary_sensor/tesla.py @@ -30,7 +30,6 @@ class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): def __init__(self, tesla_device, controller, sensor_type): """Initialisation of binary sensor.""" super().__init__(tesla_device, controller) - self._name = self.tesla_device.name self._state = False self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._sensor_type = sensor_type diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py index 05de0b51aa8..e0bf23ecee2 100644 --- a/homeassistant/components/binary_sensor/wink.py +++ b/homeassistant/components/binary_sensor/wink.py @@ -9,7 +9,6 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.wink import WinkDevice, DOMAIN -from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -87,7 +86,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.info("Device isn't a sensor, skipping") -class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): +class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice): """Representation of a Wink binary sensor.""" def __init__(self, wink, hass): @@ -117,6 +116,11 @@ class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity): """Return the class of this sensor, from DEVICE_CLASSES.""" return SENSOR_TYPES.get(self.capability) + @property + def device_state_attributes(self): + """Return the state attributes.""" + return super().device_state_attributes + class WinkSmokeDetector(WinkBinarySensorDevice): """Representation of a Wink Smoke detector.""" @@ -124,9 +128,9 @@ class WinkSmokeDetector(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'test_activated': self.wink.test_activated() - } + _attributes = super().device_state_attributes + _attributes['test_activated'] = self.wink.test_activated() + return _attributes class WinkHub(WinkBinarySensorDevice): @@ -135,11 +139,11 @@ class WinkHub(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'update_needed': self.wink.update_needed(), - 'firmware_version': self.wink.firmware_version(), - 'pairing_mode': self.wink.pairing_mode() - } + _attributes = super().device_state_attributes + _attributes['update_needed'] = self.wink.update_needed() + _attributes['firmware_version'] = self.wink.firmware_version() + _attributes['pairing_mode'] = self.wink.pairing_mode() + return _attributes class WinkRemote(WinkBinarySensorDevice): @@ -148,12 +152,12 @@ class WinkRemote(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'button_on_pressed': self.wink.button_on_pressed(), - 'button_off_pressed': self.wink.button_off_pressed(), - 'button_up_pressed': self.wink.button_up_pressed(), - 'button_down_pressed': self.wink.button_down_pressed() - } + _attributes = super().device_state_attributes + _attributes['button_on_pressed'] = self.wink.button_on_pressed() + _attributes['button_off_pressed'] = self.wink.button_off_pressed() + _attributes['button_up_pressed'] = self.wink.button_up_pressed() + _attributes['button_down_pressed'] = self.wink.button_down_pressed() + return _attributes @property def device_class(self): @@ -167,10 +171,10 @@ class WinkButton(WinkBinarySensorDevice): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'pressed': self.wink.pressed(), - 'long_pressed': self.wink.long_pressed() - } + _attributes = super().device_state_attributes + _attributes['pressed'] = self.wink.pressed() + _attributes['long_pressed'] = self.wink.long_pressed() + return _attributes class WinkGang(WinkBinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index d60d265b849..a610269cedf 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -12,6 +12,7 @@ ATTR_OPEN_SINCE = 'Open since' MOTION = 'motion' NO_MOTION = 'no_motion' +ATTR_LAST_ACTION = 'last_action' ATTR_NO_MOTION_SINCE = 'No motion since' DENSITY = 'density' @@ -327,10 +328,18 @@ class XiaomiCube(XiaomiBinarySensor): def __init__(self, device, hass, xiaomi_hub): """Initialize the Xiaomi Cube.""" self._hass = hass + self._last_action = None self._state = False XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub, None, None) + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_LAST_ACTION: self._last_action} + attrs.update(super().device_state_attributes) + return attrs + def parse_data(self, data): """Parse data sent by gateway.""" if 'status' in data: @@ -338,6 +347,7 @@ class XiaomiCube(XiaomiBinarySensor): 'entity_id': self.entity_id, 'action_type': data['status'] }) + self._last_action = data['status'] if 'rotate' in data: self._hass.bus.fire('cube_action', { @@ -345,4 +355,6 @@ class XiaomiCube(XiaomiBinarySensor): 'action_type': 'rotate', 'action_value': float(data['rotate'].replace(",", ".")) }) - return False + self._last_action = 'rotate' + + return True diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index a7d778d99aa..c509d582e11 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -126,23 +126,16 @@ def async_setup(hass, config): """Handle calls to the camera services.""" target_cameras = component.async_extract_from_service(service) + update_tasks = [] for camera in target_cameras: if service.service == SERVICE_EN_MOTION: yield from camera.async_enable_motion_detection() elif service.service == SERVICE_DISEN_MOTION: yield from camera.async_disable_motion_detection() - update_tasks = [] - for camera in target_cameras: if not camera.should_poll: continue - - update_coro = hass.async_add_job( - camera.async_update_ha_state(True)) - if hasattr(camera, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(camera.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index d473fa42d9d..be58b61fb8c 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -1,37 +1,40 @@ """ -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 from homeassistant.const import ATTR_BATTERY_LEVEL - -DEPENDENCIES = ['arlo', 'ffmpeg'] +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=10) + +ARLO_MODE_ARMED = 'armed' +ARLO_MODE_DISARMED = 'disarmed' + ATTR_BRIGHTNESS = 'brightness' ATTR_FLIPPED = 'flipped' ATTR_MIRRORED = 'mirrored' -ATTR_MOTION_SENSITIVITY = 'motion_detection_sensitivity' -ATTR_POWER_SAVE_MODE = 'power_save_mode' +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' -ARLO_MODE_ARMED = 'armed' -ARLO_MODE_DISARMED = 'disarmed' +DEPENDENCIES = ['arlo', 'ffmpeg'] POWERSAVE_MODE_MAPPING = { 1: 'best_battery_life', @@ -40,7 +43,8 @@ POWERSAVE_MODE_MAPPING = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): + cv.string, }) @@ -69,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.""" @@ -100,32 +105,24 @@ class ArloCam(Camera): def device_state_attributes(self): """Return the state attributes.""" return { - ATTR_BATTERY_LEVEL: - self._camera.get_battery_level, - ATTR_BRIGHTNESS: - self._camera.get_brightness, - ATTR_FLIPPED: - self._camera.get_flip_state, - ATTR_MIRRORED: - self._camera.get_mirror_state, - ATTR_MOTION_SENSITIVITY: - self._camera.get_motion_detection_sensitivity, - ATTR_POWER_SAVE_MODE: - POWERSAVE_MODE_MAPPING[self._camera.get_powersave_mode], - ATTR_SIGNAL_STRENGTH: - self._camera.get_signal_strength, - ATTR_UNSEEN_VIDEOS: - self._camera.unseen_videos + 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 @@ -135,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): @@ -143,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 @@ -160,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/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 8ca72a09261..1bbd263e585 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -55,9 +55,9 @@ class FFmpegCamera(Camera): from haffmpeg import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - image = yield from ffmpeg.get_image( + image = yield from asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments) + extra_cmd=self._extra_arguments), loop=self.hass.loop) return image @asyncio.coroutine diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 711eb75a744..8f30d9c8b8f 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -78,9 +78,9 @@ class ONVIFCamera(Camera): ffmpeg = ImageFrame( self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop) - image = yield from ffmpeg.get_image( + image = yield from asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments) + extra_cmd=self._ffmpeg_arguments), loop=self.hass.loop) return image @asyncio.coroutine 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 0b97f55397c..fca9cbbc7a5 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -16,11 +16,11 @@ from homeassistant.const import ( from homeassistant.components.camera import ( Camera, PLATFORM_SCHEMA) from homeassistant.helpers.aiohttp_client import ( - async_create_clientsession, - async_aiohttp_proxy_web) + async_aiohttp_proxy_web, + async_get_clientsession) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['py-synology==0.1.3'] +REQUIREMENTS = ['py-synology==0.1.5'] _LOGGER = logging.getLogger(__name__) @@ -58,13 +58,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return False 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): - device = SynologyCamera(websession, surveillance, camera.camera_id) + device = SynologyCamera(surveillance, camera.camera_id, verify_ssl) devices.append(device) async_add_devices(devices) @@ -73,12 +72,12 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SynologyCamera(Camera): """An implementation of a Synology NAS based IP camera.""" - def __init__(self, websession, surveillance, camera_id): + def __init__(self, surveillance, camera_id, verify_ssl): """Initialize a Synology Surveillance Station camera.""" super().__init__() - self._websession = websession self._surveillance = surveillance self._camera_id = camera_id + self._verify_ssl = verify_ssl 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 @@ -91,7 +90,9 @@ class SynologyCamera(Camera): def handle_async_mjpeg_stream(self, request): """Return a MJPEG stream image response directly from the camera.""" streaming_url = self._camera.video_stream_url - stream_coro = self._websession.get(streaming_url) + + websession = async_get_clientsession(self.hass, self._verify_ssl) + stream_coro = websession.get(streaming_url) yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py new file mode 100644 index 00000000000..8e41429baea --- /dev/null +++ b/homeassistant/components/camera/yi.py @@ -0,0 +1,137 @@ +""" +This component provides support for Xiaomi Cameras (HiSilicon Hi3518e V200). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.yi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream + +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'YI Home Camera' +DEFAULT_PASSWORD = '' +DEFAULT_PATH = '/tmp/sd/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up a Yi Camera.""" + _LOGGER.debug('Received configuration: %s', config) + async_add_devices([YiCamera(hass, config)], True) + + +class YiCamera(Camera): + """Define an implementation of a Yi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config.get(CONF_NAME) + self.host = config.get(CONF_HOST) + self.port = config.get(CONF_PORT) + self.path = config.get(CONF_PATH) + self.user = config.get(CONF_USERNAME) + self.passwd = config.get(CONF_PASSWORD) + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def brand(self): + """Camera brand.""" + return DEFAULT_BRAND + + def get_latest_video_url(self): + """Retrieve the latest video file from the customized Yi FTP server.""" + from ftplib import FTP, error_perm + + ftp = FTP(self.host) + try: + ftp.login(self.user, self.passwd) + except error_perm as exc: + _LOGGER.error('There was an error while logging into the camera') + _LOGGER.debug(exc) + return False + + try: + ftp.cwd(self.path) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s', self.path) + _LOGGER.debug(exc) + return False + + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any uploaded videos") + return False + + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + videos = ftp.nlst() + if not videos: + _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + return False + + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( + self.user, self.passwd, self.host, self.port, self.path, + latest_dir, videos[-1]) + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = yield from self.hass.async_add_job(self.get_latest_video_url) + if url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = yield from asyncio.shield(ffmpeg.get_image( + url, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_url = url + + return self._last_image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + yield from stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + yield from async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + yield from stream.close() diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 53e60380a38..61f5773356f 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -236,24 +236,6 @@ def async_setup(hass, config): load_yaml_config_file, os.path.join(os.path.dirname(__file__), 'services.yaml')) - @asyncio.coroutine - def _async_update_climate(target_climate): - """Update climate entity after service stuff.""" - update_tasks = [] - for climate in target_climate: - if not climate.should_poll: - continue - - update_coro = hass.async_add_job( - climate.async_update_ha_state(True)) - if hasattr(climate, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro - - if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - @asyncio.coroutine def async_away_mode_set_service(service): """Set away mode on target climate devices.""" @@ -261,13 +243,19 @@ def async_setup(hass, config): away_mode = service.data.get(ATTR_AWAY_MODE) + update_tasks = [] for climate in target_climate: if away_mode: yield from climate.async_turn_away_mode_on() else: yield from climate.async_turn_away_mode_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, @@ -281,10 +269,16 @@ def async_setup(hass, config): hold_mode = service.data.get(ATTR_HOLD_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_hold_mode(hold_mode) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, @@ -298,13 +292,19 @@ def async_setup(hass, config): aux_heat = service.data.get(ATTR_AUX_HEAT) + update_tasks = [] for climate in target_climate: if aux_heat: yield from climate.async_turn_aux_heat_on() else: yield from climate.async_turn_aux_heat_off() - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, @@ -316,6 +316,7 @@ def async_setup(hass, config): """Set temperature on the target climate devices.""" target_climate = component.async_extract_from_service(service) + update_tasks = [] for climate in target_climate: kwargs = {} for value, temp in service.data.items(): @@ -330,7 +331,12 @@ def async_setup(hass, config): yield from climate.async_set_temperature(**kwargs) - yield from _async_update_climate(target_climate) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) + + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, @@ -344,10 +350,15 @@ def async_setup(hass, config): humidity = service.data.get(ATTR_HUMIDITY) + update_tasks = [] for climate in target_climate: yield from climate.async_set_humidity(humidity) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, @@ -361,10 +372,15 @@ def async_setup(hass, config): fan = service.data.get(ATTR_FAN_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_fan_mode(fan) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, @@ -378,10 +394,15 @@ def async_setup(hass, config): operation_mode = service.data.get(ATTR_OPERATION_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_operation_mode(operation_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, @@ -395,10 +416,15 @@ def async_setup(hass, config): swing_mode = service.data.get(ATTR_SWING_MODE) + update_tasks = [] for climate in target_climate: yield from climate.async_set_swing_mode(swing_mode) + if not climate.should_poll: + continue + update_tasks.append(climate.async_update_ha_state(True)) - yield from _async_update_climate(target_climate) + if update_tasks: + yield from asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, 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/knx.py b/homeassistant/components/climate/knx.py index 9bf44c9b9ab..784d8a4ed28 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -14,6 +14,8 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_SETPOINT_ADDRESS = 'setpoint_address' +CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address' +CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' @@ -33,6 +35,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, + vol.Optional(CONF_SETPOINT_SHIFT_STATE_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, @@ -82,6 +86,10 @@ def async_add_devices_config(hass, config, async_add_devices): CONF_TARGET_TEMPERATURE_ADDRESS), group_address_setpoint=config.get( CONF_SETPOINT_ADDRESS), + group_address_setpoint_shift=config.get( + CONF_SETPOINT_SHIFT_ADDRESS), + group_address_setpoint_shift_state=config.get( + CONF_SETPOINT_SHIFT_STATE_ADDRESS), group_address_operation_mode=config.get( CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( @@ -140,13 +148,29 @@ class KNXClimate(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self.device.temperature + return self.device.temperature.value @property def target_temperature(self): """Return the temperature we try to reach.""" - if self.device.supports_target_temperature: - return self.device.target_temperature + return self.device.target_temperature_comfort + + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + if self.device.target_temperature_comfort: + return max( + self.device.target_temperature_comfort, + self.device.target_temperature.value) + return None + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + if self.device.target_temperature_comfort: + return min( + self.device.target_temperature_comfort, + self.device.target_temperature.value) return None @asyncio.coroutine @@ -155,8 +179,8 @@ class KNXClimate(ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - if self.device.supports_target_temperature: - yield from self.device.set_target_temperature(temperature) + yield from self.device.set_target_temperature_comfort(temperature) + yield from self.async_update_ha_state() @property def current_operation(self): diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 2f7bba74185..de6ac7a0227 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -18,7 +18,8 @@ from homeassistant.components.climate import ( ATTR_OPERATION_MODE) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME) -from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN) +from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN, + MQTT_BASE_PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) @@ -57,7 +58,8 @@ CONF_SWING_MODE_LIST = 'swing_modes' CONF_INITIAL = 'initial' CONF_SEND_IF_OFF = 'send_if_off' -PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({ +SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) +PLATFORM_SCHEMA = SCHEMA_BASE.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, diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 92d821ebbaf..ecc5667f927 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -101,11 +101,11 @@ set_swing_mode: fields: entity_id: description: Name(s) of entities to change - example: '.nest' + example: 'climate.nest' swing_mode: - description: New value of swing mode - example: 1 + description: New value of swing mode + example: 1 ecobee_set_fan_min_on_time: description: Set the minimum fan on time diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py index 39d002e72d9..684d131d960 100644 --- a/homeassistant/components/climate/tesla.py +++ b/homeassistant/components/climate/tesla.py @@ -35,7 +35,6 @@ class TeslaThermostat(TeslaDevice, ClimateDevice): self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) self._target_temperature = None self._temperature = None - self._name = self.tesla_device.name @property def current_operation(self): diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py new file mode 100644 index 00000000000..c4021a97c91 --- /dev/null +++ b/homeassistant/components/climate/toon.py @@ -0,0 +1,95 @@ +""" +Toon van Eneco Thermostat Support. + +This provides a component for the rebranded Quby thermostat as provided by +Eneco. +""" + +from homeassistant.components.climate import (ClimateDevice, + ATTR_TEMPERATURE, + STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL) +from homeassistant.const import TEMP_CELSIUS + +import homeassistant.components.toon as toon_main + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup thermostat.""" + # Add toon + add_devices((ThermostatDevice(hass), ), True) + + +class ThermostatDevice(ClimateDevice): + """Interface class for the toon module and HA.""" + + def __init__(self, hass): + """Initialize the device.""" + self._name = 'Toon van Eneco' + self.hass = hass + self.thermos = hass.data[toon_main.TOON_HANDLE] + + # set up internal state vars + self._state = None + self._temperature = None + self._setpoint = None + self._operation_list = [STATE_PERFORMANCE, + STATE_HEAT, + STATE_ECO, + STATE_COOL] + + @property + def name(self): + """Name of this Thermostat.""" + return self._name + + @property + def should_poll(self): + """Polling is required.""" + return True + + @property + def temperature_unit(self): + """The unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation i.e. comfort, home, away.""" + state = self.thermos.get_data('state') + return state + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermos.get_data('temp') + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.thermos.get_data('setpoint') + + def set_temperature(self, **kwargs): + """Change the setpoint of the thermostat.""" + temp = kwargs.get(ATTR_TEMPERATURE) + self.thermos.set_temp(temp) + + def set_operation_mode(self, operation_mode): + """Set new operation mode as toonlib requires it.""" + toonlib_values = {STATE_PERFORMANCE: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep'} + + self.thermos.set_state(toonlib_values[operation_mode]) + + def update(self): + """Update local state.""" + self.thermos.update() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 44796f97166..c711b00fdd2 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,47 +1,147 @@ """Component to integrate the Home Assistant cloud.""" import asyncio +import json import logging +import os import voluptuous as vol -from . import http_api, auth_api -from .const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_START + +from . import http_api, iot +from .const import CONFIG_DIR, DOMAIN, SERVERS -REQUIREMENTS = ['warrant==0.2.0'] +REQUIREMENTS = ['warrant==0.5.0'] DEPENDENCIES = ['http'] CONF_MODE = 'mode' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_USER_POOL_ID = 'user_pool_id' +CONF_REGION = 'region' +CONF_RELAYER = 'relayer' MODE_DEV = 'development' -MODE_STAGING = 'staging' -MODE_PRODUCTION = 'production' DEFAULT_MODE = MODE_DEV +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + vol.In([MODE_DEV] + list(SERVERS)), + # Change to optional when we include real servers + vol.Required(CONF_COGNITO_CLIENT_ID): str, + vol.Required(CONF_USER_POOL_ID): str, + vol.Required(CONF_REGION): str, + vol.Required(CONF_RELAYER): str, }), }, 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) + kwargs = config[DOMAIN] + else: + kwargs = {CONF_MODE: DEFAULT_MODE} - if mode != 'development': - _LOGGER.error('Only development mode is currently allowed.') - return False + cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - data = hass.data[DOMAIN] = { - 'mode': mode - } + @asyncio.coroutine + def init_cloud(event): + """Initialize connection.""" + yield from cloud.initialize() - data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, init_cloud) yield from http_api.async_setup(hass) return True + + +class Cloud: + """Store the configuration of the cloud connection.""" + + def __init__(self, hass, mode, cognito_client_id=None, user_pool_id=None, + region=None, relayer=None): + """Create an instance of Cloud.""" + self.hass = hass + self.mode = mode + self.email = None + self.id_token = None + self.access_token = None + self.refresh_token = None + self.iot = iot.CloudIoT(self) + + if mode == MODE_DEV: + self.cognito_client_id = cognito_client_id + self.user_pool_id = user_pool_id + self.region = region + self.relayer = relayer + + else: + info = SERVERS[mode] + + self.cognito_client_id = info['cognito_client_id'] + self.user_pool_id = info['user_pool_id'] + self.region = info['region'] + self.relayer = info['relayer'] + + @property + def is_logged_in(self): + """Get if cloud is logged in.""" + return self.email is not None + + @property + def user_info_path(self): + """Get path to the stored auth.""" + return self.path('{}_auth.json'.format(self.mode)) + + @asyncio.coroutine + def initialize(self): + """Initialize and load cloud info.""" + def load_config(): + """Load the configuration.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if os.path.isfile(user_info): + with open(user_info, 'rt') as file: + info = json.loads(file.read()) + self.email = info['email'] + self.id_token = info['id_token'] + self.access_token = info['access_token'] + self.refresh_token = info['refresh_token'] + + yield from self.hass.async_add_job(load_config) + + if self.email is not None: + yield from self.iot.connect() + + def path(self, *parts): + """Get config path inside cloud dir.""" + return self.hass.config.path(CONFIG_DIR, *parts) + + @asyncio.coroutine + def logout(self): + """Close connection and remove all credentials.""" + yield from self.iot.disconnect() + + self.email = None + self.id_token = None + self.access_token = None + self.refresh_token = None + + yield from self.hass.async_add_job( + lambda: os.remove(self.user_info_path)) + + def write_user_info(self): + """Write user info to a file.""" + with open(self.user_info_path, 'wt') as file: + file.write(json.dumps({ + 'email': self.email, + 'id_token': self.id_token, + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + }, indent=4)) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 0baadeece46..50a88d4be4d 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,10 +1,7 @@ -"""Package to offer tools to authenticate with the cloud.""" -import json +"""Package to communicate with the authentication API.""" +import hashlib import logging -import os -from .const import AUTH_FILE, SERVERS -from .util import get_mode _LOGGER = logging.getLogger(__name__) @@ -61,210 +58,120 @@ def _map_aws_exception(err): 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 _generate_username(email): + """Generate a username from an email address.""" + return hashlib.sha512(email.encode('utf-8')).hexdigest() -def register(hass, email, password): +def register(cloud, email, password): """Register a new account.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud) try: - cognito.register(email, password) + cognito.register(_generate_username(email), password, email=email) except ClientError as err: raise _map_aws_exception(err) -def confirm_register(hass, confirmation_code, email): +def confirm_register(cloud, confirmation_code, email): """Confirm confirmation code after registration.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud) try: - cognito.confirm_sign_up(confirmation_code, email) + cognito.confirm_sign_up(confirmation_code, _generate_username(email)) except ClientError as err: raise _map_aws_exception(err) -def forgot_password(hass, email): +def forgot_password(cloud, email): """Initiate forgotten password flow.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud, username=_generate_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): +def confirm_forgot_password(cloud, confirmation_code, email, new_password): """Confirm forgotten password code and change password.""" from botocore.exceptions import ClientError - cognito = _cognito(hass, username=email) + cognito = _cognito(cloud, username=_generate_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 login(cloud, email, password): + """Log user in and fetch certificate.""" + cognito = _authenticate(cloud, email, password) + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.refresh_token = cognito.refresh_token + cloud.email = email + cloud.write_user_info() -def _read_info(hass): - """Read auth file.""" - path = hass.config.path(AUTH_FILE) +def check_token(cloud): + """Check that the token is valid and verify if needed.""" + from botocore.exceptions import ClientError - if not os.path.isfile(path): - return None + cognito = _cognito( + cloud, + access_token=cloud.access_token, + refresh_token=cloud.refresh_token) - with open(path) as file: - return json.load(file).get(get_mode(hass)) + try: + if cognito.check_token(): + cloud.id_token = cognito.id_token + cloud.access_token = cognito.access_token + cloud.write_user_info() + except ClientError as err: + raise _map_aws_exception(err) -def _write_info(hass, auth): - """Write auth info for specified mode. +def _authenticate(cloud, email, password): + """Log in and return an authenticated Cognito instance.""" + from botocore.exceptions import ClientError + from warrant.exceptions import ForceChangePasswordException - Pass in None for data to remove authentication for that mode. - """ - path = hass.config.path(AUTH_FILE) - mode = get_mode(hass) + assert not cloud.is_logged_in, 'Cannot login if already logged in.' - if os.path.isfile(path): - with open(path) as file: - content = json.load(file) - else: - content = {} + cognito = _cognito(cloud, username=email) - 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) + try: + cognito.authenticate(password=password) + return cognito - with open(path, 'wt') as file: - file.write(json.dumps(content, indent=4, sort_keys=True)) + except ForceChangePasswordException as err: + raise PasswordChangeRequired + + except ClientError as err: + raise _map_aws_exception(err) -def _cognito(hass, **kwargs): +def _cognito(cloud, **kwargs): """Get the client credentials.""" + import botocore + import boto3 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'], + user_pool_id=cloud.user_pool_id, + client_id=cloud.cognito_client_id, + user_pool_region=cloud.region, **kwargs ) - + cognito.client = boto3.client( + 'cognito-idp', + region_name=cloud.region, + config=botocore.config.Config( + signature_version=botocore.UNSIGNED + ) + ) return cognito diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 81beab1891b..334e522f81b 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,14 +1,14 @@ """Constants for the cloud component.""" DOMAIN = 'cloud' +CONFIG_DIR = '.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': 'lscdYBApxrLWL0HKuVqVXWv3ou8ZVXgG7rZBu/Sz' - } + # Example entry: + # 'production': { + # 'cognito_client_id': '', + # 'user_pool_id': '', + # 'region': '', + # 'relayer': '' + # } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 941df7648a6..aa91f5a45e7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -10,7 +10,7 @@ from homeassistant.components.http import ( HomeAssistantView, RequestDataValidator) from . import auth_api -from .const import REQUEST_TIMEOUT +from .const import DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -74,13 +74,14 @@ class CloudLoginView(HomeAssistantView): def post(self, request, data): """Handle login request.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth.login, data['email'], + yield from hass.async_add_job(auth_api.login, cloud, data['email'], data['password']) + hass.async_add_job(cloud.iot.connect) - return self.json(_auth_data(auth)) + return self.json(_account_data(cloud)) class CloudLogoutView(HomeAssistantView): @@ -94,10 +95,10 @@ class CloudLogoutView(HomeAssistantView): def post(self, request): """Handle logout request.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job(auth.logout) + yield from cloud.logout() return self.json_message('ok') @@ -112,12 +113,12 @@ class CloudAccountView(HomeAssistantView): def get(self, request): """Get account info.""" hass = request.app['hass'] - auth = hass.data['cloud']['auth'] + cloud = hass.data[DOMAIN] - if not auth.is_logged_in: + if not cloud.is_logged_in: return self.json_message('Not logged in', 400) - return self.json(_auth_data(auth)) + return self.json(_account_data(cloud)) class CloudRegisterView(HomeAssistantView): @@ -135,10 +136,11 @@ class CloudRegisterView(HomeAssistantView): def post(self, request, data): """Handle registration request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.register, hass, data['email'], data['password']) + auth_api.register, cloud, data['email'], data['password']) return self.json_message('ok') @@ -158,10 +160,11 @@ class CloudConfirmRegisterView(HomeAssistantView): def post(self, request, data): """Handle registration confirmation request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.confirm_register, hass, data['confirmation_code'], + auth_api.confirm_register, cloud, data['confirmation_code'], data['email']) return self.json_message('ok') @@ -181,10 +184,11 @@ class CloudForgotPasswordView(HomeAssistantView): def post(self, request, data): """Handle forgot password request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.forgot_password, hass, data['email']) + auth_api.forgot_password, cloud, data['email']) return self.json_message('ok') @@ -205,18 +209,19 @@ class CloudConfirmForgotPasswordView(HomeAssistantView): def post(self, request, data): """Handle forgot password confirm request.""" hass = request.app['hass'] + cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job( - auth_api.confirm_forgot_password, hass, + auth_api.confirm_forgot_password, cloud, data['confirmation_code'], data['email'], data['new_password']) return self.json_message('ok') -def _auth_data(auth): +def _account_data(cloud): """Generate the auth data JSON response.""" return { - 'email': auth.account.email + 'email': cloud.email } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py new file mode 100644 index 00000000000..92b517b570c --- /dev/null +++ b/homeassistant/components/cloud/iot.py @@ -0,0 +1,194 @@ +"""Module to handle messages from Home Assistant cloud.""" +import asyncio +import logging + +from aiohttp import hdrs, client_exceptions, WSMsgType + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.alexa import smart_home +from homeassistant.util.decorator import Registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import auth_api + + +HANDLERS = Registry() +_LOGGER = logging.getLogger(__name__) + + +class UnknownHandler(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class CloudIoT: + """Class to manage the IoT connection.""" + + def __init__(self, cloud): + """Initialize the CloudIoT class.""" + self.cloud = cloud + self.client = None + self.close_requested = False + self.tries = 0 + + @property + def is_connected(self): + """Return if connected to the cloud.""" + return self.client is not None + + @asyncio.coroutine + def connect(self): + """Connect to the IoT broker.""" + if self.client is not None: + raise RuntimeError('Cannot connect while already connected') + + self.close_requested = False + + hass = self.cloud.hass + remove_hass_stop_listener = None + + session = async_get_clientsession(self.cloud.hass) + + @asyncio.coroutine + def _handle_hass_stop(event): + """Handle Home Assistant shutting down.""" + nonlocal remove_hass_stop_listener + remove_hass_stop_listener = None + yield from self.disconnect() + + client = None + disconnect_warn = None + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + + self.client = client = yield from session.ws_connect( + self.cloud.relayer, headers={ + hdrs.AUTHORIZATION: + 'Bearer {}'.format(self.cloud.access_token) + }) + self.tries = 0 + + remove_hass_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + _LOGGER.info('Connected') + + while not client.closed: + msg = yield from client.receive() + + if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED, + WSMsgType.CLOSING): + disconnect_warn = 'Closed by server' + break + + elif msg.type != WSMsgType.TEXT: + disconnect_warn = 'Received non-Text message: {}'.format( + msg.type) + break + + try: + msg = msg.json() + except ValueError: + disconnect_warn = 'Received invalid JSON.' + break + + _LOGGER.debug('Received message: %s', msg) + + response = { + 'msgid': msg['msgid'], + } + try: + result = yield from async_handle_message( + hass, self.cloud, msg['handler'], msg['payload']) + + # No response from handler + if result is None: + continue + + response['payload'] = result + + except UnknownHandler: + response['error'] = 'unknown-handler' + + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling message') + response['error'] = 'exception' + + _LOGGER.debug('Publishing message: %s', response) + yield from client.send_json(response) + + except auth_api.CloudError: + _LOGGER.warning('Unable to connect: Unable to refresh token.') + + except client_exceptions.WSServerHandshakeError as err: + if err.code == 401: + disconnect_warn = 'Invalid auth.' + self.close_requested = True + # Should we notify user? + else: + _LOGGER.warning('Unable to connect: %s', err) + + except client_exceptions.ClientError as err: + _LOGGER.warning('Unable to connect: %s', err) + + except Exception: # pylint: disable=broad-except + if not self.close_requested: + _LOGGER.exception('Unexpected error') + + finally: + if disconnect_warn is not None: + _LOGGER.warning('Connection closed: %s', disconnect_warn) + + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + if client is not None: + self.client = None + yield from client.close() + + if not self.close_requested: + self.tries += 1 + + # Sleep 0, 5, 10, 15 … up to 30 seconds between retries + yield from asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop) + + hass.async_add_job(self.connect()) + + @asyncio.coroutine + def disconnect(self): + """Disconnect the client.""" + self.close_requested = True + yield from self.client.close() + + +@asyncio.coroutine +def async_handle_message(hass, cloud, handler_name, payload): + """Handle incoming IoT message.""" + handler = HANDLERS.get(handler_name) + + if handler is None: + raise UnknownHandler() + + return (yield from handler(hass, cloud, payload)) + + +@HANDLERS.register('alexa') +@asyncio.coroutine +def async_handle_alexa(hass, cloud, payload): + """Handle an incoming IoT message for Alexa.""" + return (yield from smart_home.async_handle_message(hass, payload)) + + +@HANDLERS.register('cloud') +@asyncio.coroutine +def async_handle_cloud(hass, cloud, payload): + """Handle an incoming IoT message for cloud component.""" + action = payload['action'] + + if action == 'logout': + yield from cloud.logout() + _LOGGER.error('You have been logged out from Home Assistant cloud: %s', + payload['reason']) + else: + _LOGGER.warning('Received unknown cloud action: %s', action) + + return None diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py deleted file mode 100644 index ec5445f0638..00000000000 --- a/homeassistant/components/cloud/util.py +++ /dev/null @@ -1,10 +0,0 @@ -"""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/cover/__init__.py b/homeassistant/components/cover/__init__.py index 23c0be1a43e..ba60382ae64 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -169,21 +169,12 @@ def async_setup(hass, config): params.pop(ATTR_ENTITY_ID, None) # call method + update_tasks = [] for cover in covers: yield from getattr(cover, method['method'])(**params) - - update_tasks = [] - - for cover in covers: if not cover.should_poll: continue - - update_coro = hass.async_add_job( - cover.async_update_ha_state(True)) - if hasattr(cover, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(cover.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 2e3ad7fff16..4c79d19d38d 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -24,7 +24,6 @@ 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.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) @@ -134,7 +133,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No covers added") return False - async_add_devices(covers, True) + async_add_devices(covers) return True @@ -190,10 +189,6 @@ class CoverTemplate(CoverDevice): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._position = 100 if state.state == STATE_OPEN else 0 - @callback def template_cover_state_listener(entity, old_state, new_state): """Handle target device state changes.""" 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/fritz.py b/homeassistant/components/device_tracker/fritz.py index 5210329179f..58c23cb7d76 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['fritzconnection==0.6.3'] +REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 4e43b6ac10d..b445de116b9 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==1.0.2'] +REQUIREMENTS = ['librouteros==1.0.4'] MTK_DEFAULT_API_PORT = '8728' @@ -83,6 +83,15 @@ class MikrotikScanner(DeviceScanner): routerboard_info[0].get('model', 'Router'), self.host) self.connected = True + self.capsman_exist = self.client( + cmd='/capsman/interface/getall' + ) + if not self.capsman_exist: + _LOGGER.info( + 'Mikrotik %s: Not a CAPSman controller. Trying ' + 'local interfaces ', + self.host + ) self.wireless_exist = self.client( cmd='/interface/wireless/getall' ) @@ -111,7 +120,9 @@ class MikrotikScanner(DeviceScanner): def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - if self.wireless_exist: + if self.capsman_exist: + devices_tracker = 'capsman' + elif self.wireless_exist: devices_tracker = 'wireless' else: devices_tracker = 'ip' @@ -123,7 +134,11 @@ class MikrotikScanner(DeviceScanner): ) device_names = self.client(cmd='/ip/dhcp-server/lease/getall') - if self.wireless_exist: + if devices_tracker == 'capsman': + devices = self.client( + cmd='/caps-man/registration-table/getall' + ) + elif devices_tracker == 'wireless': devices = self.client( cmd='/interface/wireless/registration-table/getall' ) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index f301b2f454e..ace6a251747 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -5,23 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ """ import asyncio +import base64 import json import logging -import base64 from collections import defaultdict import voluptuous as vol -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 slugify, decorator +import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import STATE_HOME +from homeassistant.core import callback +from homeassistant.util import slugify, decorator -DEPENDENCIES = ['mqtt'] -REQUIREMENTS = ['libnacl==1.5.2'] +REQUIREMENTS = ['libnacl==1.6.0'] _LOGGER = logging.getLogger(__name__) @@ -34,6 +33,8 @@ CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +DEPENDENCIES = ['mqtt'] + OWNTRACKS_TOPIC = 'owntracks/#' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -74,6 +75,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return message['topic'] = topic @@ -90,7 +92,11 @@ def _parse_topic(topic): Async friendly. """ - _, user, device, *_ = topic.split('/', 3) + try: + _, user, device, *_ = topic.split('/', 3) + except ValueError: + _LOGGER.error("Can't parse topic: '%s'", topic) + raise return user, device @@ -399,6 +405,13 @@ def async_handle_encrypted_message(hass, context, message): yield from async_handle_message(hass, context, decrypted) +@HANDLERS.register('lwt') +@asyncio.coroutine +def async_handle_lwt_message(hass, context, message): + """Handle an lwt message.""" + _LOGGER.debug('Not handling lwt message: %s', message) + + @asyncio.coroutine def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 25176cd82d0..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 diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index a471ca5c96a..a3e81b3ef51 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.unifi/ """ import logging +from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -12,16 +13,19 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.const import CONF_VERIFY_SSL +import homeassistant.util.dt as dt_util REQUIREMENTS = ['pyunifi==2.13'] _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' +CONF_DETECTION_TIME = 'detection_time' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 DEFAULT_VERIFY_SSL = True +DEFAULT_DETECTION_TIME = timedelta(seconds=300) NOTIFICATION_ID = 'unifi_notification' NOTIFICATION_TITLE = 'Unifi Device Tracker Setup' @@ -32,7 +36,10 @@ 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), + vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( + cv.time_period, cv.positive_timedelta) }) @@ -46,6 +53,7 @@ def get_scanner(hass, config): site_id = config[DOMAIN].get(CONF_SITE_ID) port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) + detection_time = config[DOMAIN].get(CONF_DETECTION_TIME) try: ctrl = Controller(host, username, password, port, version='v4', @@ -61,14 +69,15 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl) + return UnifiScanner(ctrl, detection_time) class UnifiScanner(DeviceScanner): """Provide device_tracker support from Unifi WAP client data.""" - def __init__(self, controller): + def __init__(self, controller, detection_time: timedelta): """Initialize the scanner.""" + self._detection_time = detection_time self._controller = controller self._update() @@ -81,7 +90,11 @@ class UnifiScanner(DeviceScanner): _LOGGER.error("Failed to scan clients: %s", ex) clients = [] - self._clients = {client['mac']: client for client in clients} + self._clients = { + client['mac']: client + for client in clients + if (dt_util.utcnow() - dt_util.utc_from_timestamp(float( + client['last_seen']))) < self._detection_time} def scan_devices(self): """Scan for devices.""" @@ -96,5 +109,5 @@ class UnifiScanner(DeviceScanner): """ client = self._clients.get(mac, {}) name = client.get('name') or client.get('hostname') - _LOGGER.debug("Device %s name %s", mac, name) + _LOGGER.debug("Device mac %s name %s", mac, name) return name diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index a6646c8d0a1..338ce34048e 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/device_tracker.upc_connect/ """ import asyncio import logging -import xml.etree.ElementTree as ET import aiohttp import async_timeout @@ -19,6 +18,8 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession +REQUIREMENTS = ['defusedxml==0.5.0'] + _LOGGER = logging.getLogger(__name__) DEFAULT_IP = '192.168.0.1' @@ -63,6 +64,8 @@ class UPCDeviceScanner(DeviceScanner): @asyncio.coroutine def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" + import defusedxml.ElementTree as ET + if self.token is None: token_initialized = yield from self.async_initialize_token() if not token_initialized: diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 79c2e3dce8d..3c3eefe54cc 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['enocean==0.31'] +REQUIREMENTS = ['enocean==0.40'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index fd12529cb48..7710040ae99 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -215,20 +215,12 @@ def async_setup(hass, config: dict): target_fans = component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) + update_tasks = [] for fan in target_fans: yield from getattr(fan, method['method'])(**params) - - update_tasks = [] - - for fan in target_fans: if not fan.should_poll: continue - - update_coro = hass.async_add_job(fan.async_update_ha_state(True)) - if hasattr(fan, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(fan.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 90cd161fa20..a49952569a8 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) import homeassistant.components.isy994 as isy -from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF +from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -73,19 +73,16 @@ class ISYFanDevice(isy.ISYDevice, FanEntity): @property def speed(self) -> str: """Return the current speed.""" - return self.state + return VALUE_TO_STATE.get(self.value) @property - def state(self) -> str: - """Get the state of the ISY994 fan device.""" - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + def is_on(self) -> str: + """Get if the fan is on.""" + return self.value != 0 def set_speed(self, speed: str) -> None: """Send the set speed command to the ISY994 fan device.""" - if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.on(val=STATE_TO_VALUE.get(speed, 255)) def turn_on(self, speed: str=None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" @@ -93,10 +90,7 @@ class ISYFanDevice(isy.ISYDevice, FanEntity): def turn_off(self, **kwargs) -> None: """Send the turn off command to the ISY994 fan device.""" - if not self._node.off(): - _LOGGER.debug("Unable to set fan speed") - else: - self.speed = self.state + self._node.off() @property def speed_list(self) -> list: diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index f5efa1ef623..dc0439b8b32 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['ha-ffmpeg==1.7'] +REQUIREMENTS = ['ha-ffmpeg==1.9'] DOMAIN = 'ffmpeg' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1820dcdc3f6..941de4574cf 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -225,8 +225,6 @@ def setup(hass, config): 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', 'dev-template', 'dev-mqtt', 'kiosk'): register_built_in_panel(hass, panel) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py new file mode 100644 index 00000000000..53de8764a12 --- /dev/null +++ b/homeassistant/components/google_assistant/__init__.py @@ -0,0 +1,52 @@ +""" +Support for Actions on Google Assistant Smart Home Control. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/google_assistant/ +""" +import asyncio +import logging + +import voluptuous as vol + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from homeassistant.core import HomeAssistant # NOQA +from typing import Dict, Any # NOQA + +from homeassistant.helpers import config_validation as cv + +from .const import ( + DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, + CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS +) +from .auth import GoogleAssistantAuthView +from .http import GoogleAssistantView + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: { + vol.Required(CONF_PROJECT_ID): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, + vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, + } + }, + extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): + """Activate Google Actions component.""" + config = yaml_config.get(DOMAIN, {}) + + hass.http.register_view(GoogleAssistantAuthView(hass, config)) + hass.http.register_view(GoogleAssistantView(hass, config)) + + return True diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py new file mode 100644 index 00000000000..4ef30ff53c8 --- /dev/null +++ b/homeassistant/components/google_assistant/auth.py @@ -0,0 +1,86 @@ +"""Google Assistant OAuth View.""" + +import asyncio +import logging + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from homeassistant.core import HomeAssistant # NOQA +from aiohttp.web import Request, Response # NOQA +from typing import Dict, Any # NOQA + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + HTTP_BAD_REQUEST, + HTTP_UNAUTHORIZED, + HTTP_MOVED_PERMANENTLY, +) + +from .const import ( + GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN +) + +BASE_OAUTH_URL = 'https://oauth-redirect.googleusercontent.com' +REDIRECT_TEMPLATE_URL = \ + '{}/r/{}#access_token={}&token_type=bearer&state={}' + +_LOGGER = logging.getLogger(__name__) + + +class GoogleAssistantAuthView(HomeAssistantView): + """Handle Google Actions auth requests.""" + + url = GOOGLE_ASSISTANT_API_ENDPOINT + '/auth' + name = 'api:google_assistant:auth' + requires_auth = False + + def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None: + """Initialize instance of the view.""" + super().__init__() + + self.project_id = cfg.get(CONF_PROJECT_ID) + self.client_id = cfg.get(CONF_CLIENT_ID) + self.access_token = cfg.get(CONF_ACCESS_TOKEN) + + @asyncio.coroutine + def get(self, request: Request) -> Response: + """Handle oauth token request.""" + query = request.query + redirect_uri = query.get('redirect_uri') + if not redirect_uri: + msg = 'missing redirect_uri field' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + if self.project_id not in redirect_uri: + msg = 'missing project_id in redirect_uri' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + state = query.get('state') + if not state: + msg = 'oauth request missing state' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_BAD_REQUEST) + + client_id = query.get('client_id') + if self.client_id != client_id: + msg = 'invalid client id' + _LOGGER.warning(msg) + return self.json_message(msg, status_code=HTTP_UNAUTHORIZED) + + generated_url = redirect_url(self.project_id, self.access_token, state) + + _LOGGER.info('user login in from Google Assistant') + return self.json_message( + 'redirect success', + status_code=HTTP_MOVED_PERMANENTLY, + headers={'Location': generated_url}) + + +def redirect_url(project_id: str, access_token: str, state: str) -> str: + """Generate the redirect format for the oauth request.""" + return REDIRECT_TEMPLATE_URL.format(BASE_OAUTH_URL, project_id, + access_token, state) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py new file mode 100644 index 00000000000..5cb66d882fe --- /dev/null +++ b/homeassistant/components/google_assistant/const.py @@ -0,0 +1,37 @@ +"""Constants for Google Assistant.""" +DOMAIN = 'google_assistant' + +GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' + +ATTR_GOOGLE_ASSISTANT = 'google_assistant' +ATTR_GOOGLE_ASSISTANT_NAME = 'google_assistant_name' + +CONF_EXPOSE_BY_DEFAULT = 'expose_by_default' +CONF_EXPOSED_DOMAINS = 'exposed_domains' +CONF_PROJECT_ID = 'project_id' +CONF_ACCESS_TOKEN = 'access_token' +CONF_CLIENT_ID = 'client_id' +CONF_ALIASES = 'aliases' + +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + 'switch', 'light', 'group', 'media_player', 'fan', 'cover' +] + +PREFIX_TRAITS = 'action.devices.traits.' +TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' +TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' +TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum' +TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' +TRAIT_SCENE = PREFIX_TRAITS + 'Scene' + +PREFIX_COMMANDS = 'action.devices.commands.' +COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' +COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute' +COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute' +COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene' + +PREFIX_TYPES = 'action.devices.types.' +TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' +TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' +TYPE_SCENE = PREFIX_TYPES + 'SCENE' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py new file mode 100644 index 00000000000..adc626f73b7 --- /dev/null +++ b/homeassistant/components/google_assistant/http.py @@ -0,0 +1,180 @@ +""" +Support for Google Actions Smart Home Control. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/google_assistant/ +""" +import asyncio +import logging + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from homeassistant.core import HomeAssistant # NOQA +from aiohttp.web import Request, Response # NOQA +from typing import Dict, Tuple, Any # NOQA +from homeassistant.helpers.entity import Entity # NOQA + +from homeassistant.components.http import HomeAssistantView + +from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) + +from .const import ( + GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ACCESS_TOKEN, + DEFAULT_EXPOSE_BY_DEFAULT, + DEFAULT_EXPOSED_DOMAINS, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + ATTR_GOOGLE_ASSISTANT) +from .smart_home import entity_to_device, query_device, determine_service + +_LOGGER = logging.getLogger(__name__) + + +class GoogleAssistantView(HomeAssistantView): + """Handle Google Assistant requests.""" + + url = GOOGLE_ASSISTANT_API_ENDPOINT + name = 'api:google_assistant' + requires_auth = False # Uses access token from oauth flow + + def __init__(self, hass: HomeAssistant, cfg: Dict[str, Any]) -> None: + """Initialize Google Assistant view.""" + super().__init__() + + self.access_token = cfg.get(CONF_ACCESS_TOKEN) + self.expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT, + DEFAULT_EXPOSE_BY_DEFAULT) + self.exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS, + DEFAULT_EXPOSED_DOMAINS) + + def is_entity_exposed(self, entity) -> bool: + """Determine if an entity should be exposed to Google Assistant.""" + if entity.attributes.get('view') is not None: + # Ignore entities that are views + return False + + domain = entity.domain.lower() + explicit_expose = entity.attributes.get(ATTR_GOOGLE_ASSISTANT, None) + + domain_exposed_by_default = \ + self.expose_by_default and domain in self.exposed_domains + + # Expose an entity if the entity's domain is exposed by default and + # 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 + + return is_default_exposed or explicit_expose + + @asyncio.coroutine + def handle_sync(self, hass: HomeAssistant, request_id: str): + """Handle SYNC action.""" + devices = [] + for entity in hass.states.async_all(): + if not self.is_entity_exposed(entity): + continue + + device = entity_to_device(entity) + if device is None: + _LOGGER.warning("No mapping for %s domain", entity.domain) + continue + + devices.append(device) + + return self.json( + make_actions_response(request_id, {'devices': devices})) + + @asyncio.coroutine + def handle_query(self, + hass: HomeAssistant, + request_id: str, + requested_devices: list): + """Handle the QUERY action.""" + devices = {} + for device in requested_devices: + devid = device.get('id') + # In theory this should never happpen + if not devid: + _LOGGER.error('Device missing ID: %s', device) + continue + + state = hass.states.get(devid) + if not state: + # If we can't find a state, the device is offline + devices[devid] = {'online': False} + + devices[devid] = query_device(state) + + return self.json( + make_actions_response(request_id, {'devices': devices})) + + @asyncio.coroutine + def handle_execute(self, + hass: HomeAssistant, + request_id: str, + requested_commands: list): + """Handle the EXECUTE action.""" + commands = [] + for command in requested_commands: + ent_ids = [ent.get('id') for ent in command.get('devices', [])] + execution = command.get('execution')[0] + for eid in ent_ids: + domain = eid.split('.')[0] + (service, service_data) = determine_service( + eid, execution.get('command'), execution.get('params')) + success = yield from hass.services.async_call( + domain, service, service_data, blocking=True) + result = {"ids": [eid], "states": {}} + if success: + result['status'] = 'SUCCESS' + else: + result['status'] = 'ERROR' + commands.append(result) + + return self.json( + make_actions_response(request_id, {'commands': commands})) + + @asyncio.coroutine + def post(self, request: Request) -> Response: + """Handle Google Assistant requests.""" + auth = request.headers.get('Authorization', None) + if 'Bearer {}'.format(self.access_token) != auth: + return self.json_message( + "missing authorization", status_code=HTTP_UNAUTHORIZED) + + data = yield from request.json() # type: dict + + inputs = data.get('inputs') # type: list + if len(inputs) != 1: + _LOGGER.error('Too many inputs in request %d', len(inputs)) + return self.json_message( + "too many inputs", status_code=HTTP_BAD_REQUEST) + + request_id = data.get('requestId') # type: str + intent = inputs[0].get('intent') + payload = inputs[0].get('payload') + + hass = request.app['hass'] # type: HomeAssistant + res = None + if intent == 'action.devices.SYNC': + res = yield from self.handle_sync(hass, request_id) + elif intent == 'action.devices.QUERY': + res = yield from self.handle_query(hass, request_id, + payload.get('devices', [])) + elif intent == 'action.devices.EXECUTE': + res = yield from self.handle_execute(hass, request_id, + payload.get('commands', [])) + + if res: + return res + + return self.json_message( + "invalid intent", status_code=HTTP_BAD_REQUEST) + + +def make_actions_response(request_id: str, payload: dict) -> dict: + """Helper to simplify format for response.""" + return {'requestId': request_id, 'payload': payload} diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py new file mode 100644 index 00000000000..8d25a02cc95 --- /dev/null +++ b/homeassistant/components/google_assistant/smart_home.py @@ -0,0 +1,161 @@ +"""Support for Google Assistant Smart Home API.""" +import logging + +# Typing imports +# pylint: disable=using-constant-test,unused-import,ungrouped-imports +# if False: +from aiohttp.web import Request, Response # NOQA +from typing import Dict, Tuple, Any # NOQA +from homeassistant.helpers.entity import Entity # NOQA +from homeassistant.core import HomeAssistant # NOQA + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, + CONF_FRIENDLY_NAME, STATE_OFF, + SERVICE_TURN_OFF, SERVICE_TURN_ON +) +from homeassistant.components import ( + switch, light, cover, media_player, group, fan, scene +) + +from .const import ( + ATTR_GOOGLE_ASSISTANT_NAME, + COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, + TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, + TRAIT_RGB_COLOR, TRAIT_SCENE, + TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, + CONF_ALIASES, +) + +_LOGGER = logging.getLogger(__name__) + +# Mapping is [actions schema, primary trait, optional features] +# optional is SUPPORT_* = (trait, command) +MAPPING_COMPONENT = { + group.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], + scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], + switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], + fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], + light.DOMAIN: [ + TYPE_LIGHT, TRAIT_ONOFF, { + light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS, + light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR, + light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP, + } + ], + cover.DOMAIN: [ + TYPE_LIGHT, TRAIT_ONOFF, { + cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS + } + ], + media_player.DOMAIN: [ + TYPE_LIGHT, TRAIT_ONOFF, { + media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS + } + ], +} # type: Dict[str, list] + + +def make_actions_response(request_id: str, payload: dict) -> dict: + """Helper to simplify format for response.""" + return {'requestId': request_id, 'payload': payload} + + +def entity_to_device(entity: Entity): + """Convert a hass entity into an google actions device.""" + class_data = MAPPING_COMPONENT.get(entity.domain) + if class_data is None: + return None + + device = { + 'id': entity.entity_id, + 'name': {}, + 'traits': [], + 'willReportState': False, + } + device['type'] = class_data[0] + device['traits'].append(class_data[1]) + + # handle custom names + device['name']['name'] = \ + entity.attributes.get(ATTR_GOOGLE_ASSISTANT_NAME) or \ + entity.attributes.get(CONF_FRIENDLY_NAME) + + # use aliases + aliases = entity.attributes.get(CONF_ALIASES) + if isinstance(aliases, list): + device['name']['nicknames'] = aliases + else: + _LOGGER.warning("%s must be a list", CONF_ALIASES) + + # add trait if entity supports feature + if class_data[2]: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature, trait in class_data[2].items(): + if feature & supported > 0: + device['traits'].append(trait) + + return device + + +def query_device(entity: Entity) -> dict: + """Take an entity and return a properly formatted device object.""" + final_state = entity.state != STATE_OFF + final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255 + if final_state else 0) + + if entity.domain == media_player.DOMAIN: + level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL, 1.0 + if final_state else 0.0) + # Convert 0.0-1.0 to 0-255 + final_brightness = round(min(1.0, level) * 255) + + if final_brightness is None: + final_brightness = 255 if final_state else 0 + + final_brightness = 100 * (final_brightness / 255) + + return { + "on": final_state, + "online": True, + "brightness": int(final_brightness) + } + + +# erroneous bug on old pythons and pylint +# https://github.com/PyCQA/pylint/issues/1212 +# pylint: disable=invalid-sequence-index +def determine_service(entity_id: str, command: str, + params: dict) -> Tuple[str, dict]: + """ + Determine service and service_data. + + Attempt to return a tuple of service and service_data based on the entity + and action requested. + """ + domain = entity_id.split('.')[0] + service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any] + # special media_player handling + if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS: + brightness = params.get('brightness', 0) + service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100 + return (media_player.SERVICE_VOLUME_SET, service_data) + + # special cover handling + if domain == cover.DOMAIN: + if command == COMMAND_BRIGHTNESS: + service_data['position'] = params.get('brightness', 0) + return (cover.SERVICE_SET_COVER_POSITION, service_data) + if command == COMMAND_ONOFF and params.get('on') is True: + return (cover.SERVICE_OPEN_COVER, service_data) + return (cover.SERVICE_CLOSE_COVER, service_data) + + if command == COMMAND_BRIGHTNESS: + brightness = params.get('brightness') + service_data['brightness'] = int(brightness / 100 * 255) + return (SERVICE_TURN_ON, service_data) + + if command == COMMAND_ACTIVATESCENE or (COMMAND_ONOFF == command and + params.get('on') is True): + return (SERVICE_TURN_ON, service_data) + return (SERVICE_TURN_OFF, service_data) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 1be8ebcf5dd..0527bdbf2be 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -17,7 +17,8 @@ import async_timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT +from homeassistant.const import ( + CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE) from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE) @@ -33,6 +34,8 @@ SERVICE_ADDON_START = 'addon_start' SERVICE_ADDON_STOP = 'addon_stop' SERVICE_ADDON_RESTART = 'addon_restart' SERVICE_ADDON_STDIN = 'addon_stdin' +SERVICE_HOST_SHUTDOWN = 'host_shutdown' +SERVICE_HOST_REBOOT = 'host_reboot' ATTR_ADDON = 'addon' ATTR_INPUT = 'input' @@ -63,6 +66,8 @@ MAP_SERVICE_API = { SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON), SERVICE_ADDON_RESTART: ('/addons/{addon}/restart', SCHEMA_ADDON), SERVICE_ADDON_STDIN: ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN), + SERVICE_HOST_SHUTDOWN: ('/host/shutdown', None), + SERVICE_HOST_REBOOT: ('/host/reboot', None), } @@ -89,13 +94,16 @@ def async_setup(hass, config): 'mdi:access-point-network') if 'http' in config: - yield from hassio.update_hass_api(config.get('http')) + yield from hassio.update_hass_api(config['http']) + + if 'homeassistant' in config: + yield from hassio.update_hass_timezone(config['homeassistant']) @asyncio.coroutine def async_service_handler(service): """Handle service calls for HassIO.""" api_command = MAP_SERVICE_API[service.service][0] - addon = service.data[ATTR_ADDON] + addon = service.data.get(ATTR_ADDON) data = service.data[ATTR_INPUT] if ATTR_INPUT in service.data else None yield from hassio.send_command( @@ -138,6 +146,15 @@ class HassIO(object): return self.send_command("/homeassistant/options", payload=options) + def update_hass_timezone(self, core_config): + """Update Home-Assistant timezone data on HassIO. + + This method return a coroutine. + """ + return self.send_command("/supervisor/options", payload={ + 'timezone': core_config.get(CONF_TIME_ZONE) + }) + @asyncio.coroutine def send_command(self, command, method="post", payload=None, timeout=10): """Send API command to HassIO. diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index e2d34ca897e..901b54c8525 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.32'] +REQUIREMENTS = ['pyhomematic==0.1.34'] DOMAIN = 'homematic' @@ -69,7 +69,8 @@ HM_DEVICE_TYPES = { 'IPSmoke'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', - 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall'], + 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', + 'ThermostatGroup'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', @@ -129,6 +130,7 @@ CONF_LOCAL_IP = 'local_ip' CONF_LOCAL_PORT = 'local_port' CONF_IP = 'ip' CONF_PORT = 'port' +CONF_PATH = 'path' CONF_CALLBACK_IP = 'callback_ip' CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' @@ -140,6 +142,7 @@ DEFAULT_LOCAL_IP = '0.0.0.0' DEFAULT_LOCAL_PORT = 0 DEFAULT_RESOLVENAMES = False DEFAULT_PORT = 2001 +DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' DEFAULT_VARIABLES = False @@ -160,8 +163,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOSTS): {cv.match_all: { vol.Required(CONF_IP): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): - cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): @@ -258,6 +261,7 @@ def setup(hass, config): remotes[rname] = {} remotes[rname][CONF_IP] = server remotes[rname][CONF_PORT] = rconfig.get(CONF_PORT) + remotes[rname][CONF_PATH] = rconfig.get(CONF_PATH) remotes[rname][CONF_RESOLVENAMES] = rconfig.get(CONF_RESOLVENAMES) remotes[rname][CONF_USERNAME] = rconfig.get(CONF_USERNAME) remotes[rname][CONF_PASSWORD] = rconfig.get(CONF_PASSWORD) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c444cf1abbf..c9de284067f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -358,19 +358,21 @@ class HomeAssistantView(object): requires_auth = True # Views inheriting from this class can override this # pylint: disable=no-self-use - def json(self, result, status_code=200): + def json(self, result, status_code=200, headers=None): """Return a JSON response.""" msg = json.dumps( result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') return web.Response( - body=msg, content_type=CONTENT_TYPE_JSON, status=status_code) + body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, + headers=headers) - def json_message(self, message, status_code=200, message_code=None): + def json_message(self, message, status_code=200, message_code=None, + headers=None): """Return a JSON message response.""" data = {'message': message} if message_code is not None: data['code'] = message_code - return self.json(data, status_code) + return self.json(data, status_code, headers=headers) @asyncio.coroutine # pylint: disable=no-self-use diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 4b976e6ca3f..b86574c1d2e 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -26,6 +26,7 @@ CONF_KNX_TUNNELING = "tunneling" CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" +CONF_KNX_STATE_UPDATER = "state_updater" SERVICE_KNX_SEND = "send" SERVICE_KNX_ATTR_ADDRESS = "address" @@ -35,7 +36,7 @@ ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['xknx==0.7.14'] +REQUIREMENTS = ['xknx==0.7.16'] TUNNELING_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, @@ -58,7 +59,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): vol.All( cv.ensure_list, - [cv.string]) + [cv.string]), + vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, }) }, extra=vol.ALLOW_EXTRA) @@ -134,7 +136,7 @@ class KNXModule(object): """Start KNX object. Connect to tunneling or Routing device.""" connection_config = self.connection_config() yield from self.xknx.start( - state_updater=True, + state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER], connection_config=connection_config) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) self.initialized = True diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 4e9fbbf81ab..d69d6991ff0 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -274,6 +274,7 @@ def async_setup(hass, config): preprocess_turn_on_alternatives(params) + update_tasks = [] for light in target_lights: if service.service == SERVICE_TURN_ON: yield from light.async_turn_on(**params) @@ -282,18 +283,9 @@ def async_setup(hass, config): else: yield from light.async_toggle(**params) - update_tasks = [] - - for light in target_lights: if not light.should_poll: continue - - update_coro = hass.async_add_job( - light.async_update_ha_state(True)) - if hasattr(light, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(light.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index d4e650f2ba5..84917ead37a 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -263,7 +263,7 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, # create a service for calling run_scene directly on the bridge, # used to simplify automation rules. def hue_activate_scene(call): - """Service to call directly directly into bridge to set scenes.""" + """Service to call directly into bridge to set scenes.""" group_name = call.data[ATTR_GROUP_NAME] scene_name = call.data[ATTR_SCENE_NAME] bridge.run_scene(group_name, scene_name) diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 5663e1fc50d..e3e3f7dafde 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -213,7 +213,7 @@ class MqttJson(Light): except KeyError: pass except ValueError: - _LOGGER.warning("Invalid white value value received") + _LOGGER.warning("Invalid white value received") if self._xy is not None: try: diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index cef9f508952..b5dbe7ebb4c 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -269,7 +269,7 @@ class OsramLightifyGroup(Luminary): def _get_state(self): """Get state of group. - The group is on, if any of the lights in on. + The group is on, if any of the lights is on. """ lights = self._bridge.lights() return any(lights[light_id].on() for light_id in self._light_ids) diff --git a/homeassistant/components/light/skybell.py b/homeassistant/components/light/skybell.py new file mode 100644 index 00000000000..012190023fa --- /dev/null +++ b/homeassistant/components/light/skybell.py @@ -0,0 +1,87 @@ +""" +Light/LED support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.skybell/ +""" +import logging + + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, Light) +from homeassistant.components.skybell import ( + DOMAIN as SKYBELL_DOMAIN, SkybellDevice) + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + + +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(SkybellLight(device)) + + add_devices(sensors, True) + + +def _to_skybell_level(level): + """Convert the given HASS light level (0-255) to Skybell (0-100).""" + return int((level * 100) / 255) + + +def _to_hass_level(level): + """Convert the given Skybell (0-100) light level to HASS (0-255).""" + return int((level * 255) / 100) + + +class SkybellLight(SkybellDevice, Light): + """A binary sensor implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a light for a Skybell device.""" + super().__init__(device) + self._name = self._device.name + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def turn_on(self, **kwargs): + """Turn on the light.""" + if ATTR_RGB_COLOR in kwargs: + self._device.led_rgb = kwargs[ATTR_RGB_COLOR] + elif ATTR_BRIGHTNESS in kwargs: + self._device.led_intensity = _to_skybell_level( + kwargs[ATTR_BRIGHTNESS]) + else: + self._device.led_intensity = _to_skybell_level(255) + + def turn_off(self, **kwargs): + """Turn off the light.""" + self._device.led_intensity = 0 + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.led_intensity > 0 + + @property + def brightness(self): + """Return the brightness of the light.""" + return _to_hass_level(self._device.led_intensity) + + @property + def rgb_color(self): + """Return the color of the light.""" + return self._device.led_rgb + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 26ae0517955..b2a9e97f11e 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -14,26 +14,22 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS) from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, - STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL -) + STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_LIGHTS) from homeassistant.helpers.config_validation import PLATFORM_SCHEMA 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.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] -CONF_LIGHTS = 'lights' CONF_ON_ACTION = 'turn_on' CONF_OFF_ACTION = 'turn_off' CONF_LEVEL_ACTION = 'set_level' CONF_LEVEL_TEMPLATE = 'level_template' - LIGHT_SCHEMA = vol.Schema({ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, @@ -51,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up Template Lights.""" + """Set up the Template Lights.""" lights = [] for device, device_config in config[CONF_LIGHTS].items(): @@ -90,7 +86,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No lights added") return False - async_add_devices(lights, True) + async_add_devices(lights) return True @@ -153,10 +149,6 @@ class LightTemplate(Light): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_light_state_listener(entity, old_state, new_state): """Handle target device state changes.""" @@ -210,6 +202,7 @@ class LightTemplate(Light): @asyncio.coroutine def async_update(self): """Update the state from the template.""" + print("ASYNC UPDATE") if self._template is not None: try: state = self._template.async_render().lower() diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 14288b8848d..f6a544950c0 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -6,6 +6,8 @@ https://home-assistant.io/components/light.tplink/ """ import logging import colorsys +import time + from homeassistant.const import (CONF_HOST, CONF_NAME) from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, ATTR_RGB_COLOR, @@ -17,11 +19,13 @@ from homeassistant.util.color import ( from typing import Tuple -REQUIREMENTS = ['pyHS100==0.2.4.2'] +REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) -SUPPORT_TPLINK = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) +ATTR_CURRENT_CONSUMPTION = 'current_consumption' +ATTR_DAILY_CONSUMPTION = 'daily_consumption' +ATTR_MONTHLY_CONSUMPTION = 'monthly_consumption' def setup_platform(hass, config, add_devices, discovery_info=None): @@ -64,24 +68,26 @@ class TPLinkSmartBulb(Light): def __init__(self, smartbulb: 'SmartBulb', name): """Initialize the bulb.""" self.smartbulb = smartbulb - - # Use the name set on the device if not set - if name is None: - self._name = self.smartbulb.alias - else: + self._name = None + if name is not None: self._name = name - self._state = None self._color_temp = None self._brightness = None self._rgb = None - _LOGGER.debug("Setting up TP-Link Smart Bulb") + self._supported_features = 0 + self._emeter_params = {} @property def name(self): """Return the name of the Smart Bulb, if any.""" return self._name + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._emeter_params + def turn_on(self, **kwargs): """Turn the light on.""" self.smartbulb.state = self.smartbulb.BULB_STATE_ON @@ -119,30 +125,57 @@ class TPLinkSmartBulb(Light): @property def is_on(self): - """True if device is on.""" + """Return True if device is on.""" return self._state def update(self): """Update the TP-Link Bulb's state.""" - from pyHS100 import SmartPlugException + from pyHS100 import SmartDeviceException try: + if self._supported_features == 0: + self.get_features() self._state = ( self.smartbulb.state == self.smartbulb.BULB_STATE_ON) - self._brightness = brightness_from_percentage( - self.smartbulb.brightness) - if self.smartbulb.is_color: + if self._name is None: + self._name = self.smartbulb.alias + if self._supported_features & SUPPORT_BRIGHTNESS: + self._brightness = brightness_from_percentage( + self.smartbulb.brightness) + if self._supported_features & SUPPORT_COLOR_TEMP: if (self.smartbulb.color_temp is not None and self.smartbulb.color_temp != 0): self._color_temp = kelvin_to_mired( self.smartbulb.color_temp) + if self._supported_features & SUPPORT_RGB_COLOR: self._rgb = hsv_to_rgb(self.smartbulb.hsv) - except (SmartPlugException, OSError) as ex: - _LOGGER.warning('Could not read state for %s: %s', self.name, ex) + if self.smartbulb.has_emeter: + self._emeter_params[ATTR_CURRENT_CONSUMPTION] \ + = "%.1f W" % self.smartbulb.current_consumption() + daily_statistics = self.smartbulb.get_emeter_daily() + monthly_statistics = self.smartbulb.get_emeter_monthly() + try: + self._emeter_params[ATTR_DAILY_CONSUMPTION] \ + = "%.2f kW" % daily_statistics[int( + time.strftime("%d"))] + self._emeter_params[ATTR_MONTHLY_CONSUMPTION] \ + = "%.2f kW" % monthly_statistics[int( + time.strftime("%m"))] + except KeyError: + # device returned no daily/monthly history + pass + except (SmartDeviceException, OSError) as ex: + _LOGGER.warning('Could not read state for %s: %s', self._name, ex) @property def supported_features(self): """Flag supported features.""" - supported_features = SUPPORT_TPLINK + return self._supported_features + + def get_features(self): + """Determine all supported features in one go.""" + if self.smartbulb.is_dimmable: + self._supported_features += SUPPORT_BRIGHTNESS + if self.smartbulb.is_variable_color_temp: + self._supported_features += SUPPORT_COLOR_TEMP if self.smartbulb.is_color: - supported_features += SUPPORT_RGB_COLOR - return supported_features + self._supported_features += SUPPORT_RGB_COLOR diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index f4feb4b7adf..ff9201d49b9 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -40,7 +40,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices_command = gateway.get_devices() devices_commands = yield from api(devices_command) - devices = yield from api(*devices_commands) + devices = yield from api(devices_commands) lights = [dev for dev in devices if dev.has_light_control] if lights: async_add_devices(TradfriLight(light, api) for light in lights) @@ -49,7 +49,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if allow_tradfri_groups: groups_command = gateway.get_groups() groups_commands = yield from api(groups_command) - groups = yield from api(*groups_commands) + groups = yield from api(groups_commands) if groups: async_add_devices(TradfriGroup(group, api) for group in groups) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 96d51984568..4c472a0a78f 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -54,6 +54,10 @@ SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT | SUPPORT_EFFECT | SUPPORT_COLOR_TEMP) +YEELIGHT_MIN_KELVIN = YEELIGHT_MAX_KELVIN = 2700 +YEELIGHT_RGB_MIN_KELVIN = 1700 +YEELIGHT_RGB_MAX_KELVIN = 6500 + EFFECT_DISCO = "Disco" EFFECT_TEMP = "Slow Temp" EFFECT_STROBE = "Strobe epilepsy!" @@ -191,6 +195,20 @@ class YeelightLight(Light): """Return the brightness of this light between 1..255.""" return self._brightness + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + if self.supported_features & SUPPORT_COLOR_TEMP: + return kelvin_to_mired(YEELIGHT_RGB_MAX_KELVIN) + return kelvin_to_mired(YEELIGHT_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximum supported color temperature.""" + if self.supported_features & SUPPORT_COLOR_TEMP: + return kelvin_to_mired(YEELIGHT_RGB_MIN_KELVIN) + return kelvin_to_mired(YEELIGHT_MIN_KELVIN) + def _get_rgb_from_properties(self): rgb = self._properties.get('rgb', None) color_mode = self._properties.get('color_mode', None) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c64f77b3bd6..a1ad3a83b50 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -90,24 +90,16 @@ def async_setup(hass, config): code = service.data.get(ATTR_CODE) + update_tasks = [] for entity in target_locks: if service.service == SERVICE_LOCK: yield from entity.async_lock(code=code) else: yield from entity.async_unlock(code=code) - update_tasks = [] - - for entity in target_locks: if not entity.should_poll: continue - - update_coro = hass.async_add_job( - entity.async_update_ha_state(True)) - if hasattr(entity, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(entity.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py index 3e93e4787a0..80a35adb5fb 100644 --- a/homeassistant/components/lock/tesla.py +++ b/homeassistant/components/lock/tesla.py @@ -29,20 +29,17 @@ class TeslaLock(TeslaDevice, LockDevice): """Initialisation of the lock.""" self._state = None super().__init__(tesla_device, controller) - self._name = self.tesla_device.name self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) def lock(self, **kwargs): """Send the lock command.""" _LOGGER.debug("Locking doors for: %s", self._name) self.tesla_device.lock() - self._state = STATE_LOCKED def unlock(self, **kwargs): """Send the unlock command.""" _LOGGER.debug("Unlocking doors for: %s", self._name) self.tesla_device.unlock() - self._state = STATE_UNLOCKED @property def is_locked(self): diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py new file mode 100644 index 00000000000..a1b8f4cfdf3 --- /dev/null +++ b/homeassistant/components/map.py @@ -0,0 +1,18 @@ +""" +Provides a map panel for showing device locations. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/map/ +""" +import asyncio + +from homeassistant.components.frontend import register_built_in_panel + +DOMAIN = 'map' + + +@asyncio.coroutine +def async_setup(hass, config): + """Register the built-in map panel.""" + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') + return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 2b9bcc30d4c..20b754f7560 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config import load_yaml_config_file from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.10.01'] +REQUIREMENTS = ['youtube_dl==2017.10.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2ff957186ba..d12c634884f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -406,16 +406,9 @@ def async_setup(hass, config): update_tasks = [] for player in target_players: yield from getattr(player, method['method'])(**params) - - for player in target_players: if not player.should_poll: continue - - update_coro = player.async_update_ha_state(True) - if hasattr(player, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(player.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py index 68fb629e5ea..8260fb94509 100755 --- a/homeassistant/components/media_player/denon.py +++ b/homeassistant/components/media_player/denon.py @@ -225,7 +225,7 @@ class DenonDevice(MediaPlayerDevice): self.telnet_command('MU' + ('ON' if mute else 'OFF')) def media_play(self): - """Play media media player.""" + """Play media player.""" self.telnet_command('NS9A') def media_pause(self): diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 94339514712..7fffc09696c 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.3'] +REQUIREMENTS = ['denonavr==0.5.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index a10b5cd8a25..fae18f03cde 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_DEVICE, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['directpy==0.1'] +REQUIREMENTS = ['directpy==0.2'] DEFAULT_DEVICE = '0' DEFAULT_NAME = 'DirecTV Receiver' diff --git a/homeassistant/components/media_player/dunehd.py b/homeassistant/components/media_player/dunehd.py index 76c15e97824..efa5e7e6079 100644 --- a/homeassistant/components/media_player/dunehd.py +++ b/homeassistant/components/media_player/dunehd.py @@ -124,7 +124,7 @@ class DuneHDPlayerEntity(MediaPlayerDevice): self.schedule_update_ha_state() def media_play(self): - """Play media media player.""" + """Play media player.""" self._state = self._player.play() self.schedule_update_ha_state() diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py new file mode 100644 index 00000000000..b9a25367660 --- /dev/null +++ b/homeassistant/components/media_player/monoprice.py @@ -0,0 +1,185 @@ +""" +Support for interfacing with Monoprice 6 zone home audio controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.monoprice/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + + +REQUIREMENTS = ['pymonoprice==0.2'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_MONOPRICE = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +SOURCE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +CONF_ZONES = 'zones' +CONF_SOURCES = 'sources' + +# Valid zone ids: 11-16 or 21-26 or 31-36 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Any(vol.Range(min=11, max=16), + vol.Range(min=21, max=26), + vol.Range(min=31, max=36))) + +# Valid source ids: 1-6 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6)) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORT): cv.string, + vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}), + vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Monoprice 6-zone amplifier platform.""" + port = config.get(CONF_PORT) + + from serial import SerialException + from pymonoprice import Monoprice + try: + monoprice = Monoprice(port) + except SerialException: + _LOGGER.error('Error connecting to Monoprice controller.') + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + add_devices([MonopriceZone(monoprice, sources, + zone_id, extra[CONF_NAME])], True) + + +class MonopriceZone(MediaPlayerDevice): + """Representation of a a Monoprice amplifier zone.""" + + # pylint: disable=too-many-public-methods + + def __init__(self, monoprice, sources, zone_id, zone_name): + """Initialize new zone.""" + self._monoprice = monoprice + # dict source_id -> source name + self._source_id_name = sources + # dict source name -> source_id + self._source_name_id = {v: k for k, v in sources.items()} + # ordered list of all source names + self._source_names = sorted(self._source_name_id.keys(), + key=lambda v: self._source_name_id[v]) + self._zone_id = zone_id + self._name = zone_name + + self._state = None + self._volume = None + self._source = None + self._mute = None + + def update(self): + """Retrieve latest state.""" + state = self._monoprice.zone_status(self._zone_id) + if not state: + return False + self._state = STATE_ON if state.power else STATE_OFF + self._volume = state.volume + self._mute = state.mute + idx = state.source + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + return True + + @property + def name(self): + """Return the name of the zone.""" + return self._name + + @property + def state(self): + """Return the state of the zone.""" + return self._state + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume is None: + return None + return self._volume / 38.0 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_MONOPRICE + + @property + def source(self): + """"Return the current input source of the device.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + self._monoprice.set_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + self._monoprice.set_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + self._monoprice.set_power(self._zone_id, False) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + self._monoprice.set_mute(self._zone_id, mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._monoprice.set_volume(self._zone_id, int(volume * 38)) + + def volume_up(self): + """Volume up the media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, + min(self._volume + 1, 38)) + + def volume_down(self): + """Volume down media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, + max(self._volume - 1, 0)) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 54ec61b50f8..2bf35666873 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -287,12 +287,6 @@ class PlexClient(MediaPlayerDevice): self._is_player_available = False self._machine_identifier = None self._make = '' - self._media_content_id = None - self._media_content_rating = None - self._media_content_type = None - self._media_duration = None - self._media_image_url = None - self._media_title = None self._name = None self._player_state = 'idle' self._previous_volume_level = 1 # Used in fake muting @@ -308,16 +302,7 @@ class PlexClient(MediaPlayerDevice): self.update_devices = update_devices self.update_sessions = update_sessions - # Music - self._media_album_artist = None - self._media_album_name = None - self._media_artist = None - self._media_track = None - - # TV Show - self._media_episode = None - self._media_season = None - self._media_series_title = None + self._clear_media() self.refresh(device, session) @@ -339,10 +324,32 @@ class PlexClient(MediaPlayerDevice): 'media_player', prefix, self.name.lower().replace('-', '_')) + def _clear_media(self): + """Set all Media Items to None.""" + # General + self._media_content_id = None + self._media_content_rating = None + self._media_content_type = None + self._media_duration = None + self._media_image_url = None + self._media_title = None + self._media_position = None + # Music + self._media_album_artist = None + self._media_album_name = None + self._media_artist = None + self._media_track = None + # TV Show + self._media_episode = None + self._media_season = None + self._media_series_title = None + def refresh(self, device, session): """Refresh key device data.""" # new data refresh - if session: + self._clear_media() + + if session: # Not being triggered by Chrome or FireTablet Plex App self._session = session if device: self._device = device @@ -369,9 +376,6 @@ class PlexClient(MediaPlayerDevice): self._session.ratingKey) self._media_content_rating = self._convert_na_to_none( self._session.contentRating) - else: - self._media_position = None - self._media_content_id = None # player dependent data if self._session and self._session.player: @@ -405,7 +409,6 @@ class PlexClient(MediaPlayerDevice): self._session.duration) else: self._session_type = None - self._media_duration = None # media type if self._session_type == 'clip': @@ -418,11 +421,9 @@ class PlexClient(MediaPlayerDevice): self._media_content_type = MEDIA_TYPE_VIDEO elif self._session_type == 'track': self._media_content_type = MEDIA_TYPE_MUSIC - else: - self._media_content_type = None # title (movie name, tv episode name, music song name) - if self._session: + if self._session and self._is_player_active: self._media_title = self._convert_na_to_none(self._session.title) # Movies @@ -431,9 +432,7 @@ class PlexClient(MediaPlayerDevice): self._media_title += ' (' + str(self._session.year) + ')' # TV Show - if (self._is_player_active and - self._media_content_type is MEDIA_TYPE_TVSHOW): - + if self._media_content_type is MEDIA_TYPE_TVSHOW: # season number (00) if callable(self._convert_na_to_none(self._session.seasons)): self._media_season = self._convert_na_to_none( @@ -443,23 +442,15 @@ class PlexClient(MediaPlayerDevice): self._media_season = self._session.parentIndex.zfill(2) else: self._media_season = None - # show name self._media_series_title = self._convert_na_to_none( self._session.grandparentTitle) - # episode number (00) - if self._convert_na_to_none( - self._session.index) is not None: + if self._convert_na_to_none(self._session.index) is not None: self._media_episode = str(self._session.index).zfill(2) - else: - self._media_season = None - self._media_series_title = None - self._media_episode = None # Music - if (self._is_player_active and - self._media_content_type == MEDIA_TYPE_MUSIC): + if self._media_content_type == MEDIA_TYPE_MUSIC: self._media_album_name = self._convert_na_to_none( self._session.parentTitle) self._media_album_artist = self._convert_na_to_none( @@ -469,14 +460,9 @@ class PlexClient(MediaPlayerDevice): self._session.originalTitle) # use album artist if track artist is missing if self._media_artist is None: - _LOGGER.debug("Using album artist because track artist was " - "not found: %s", self.entity_id) + _LOGGER.debug("Using album artist because track artist " + "was not found: %s", self.entity_id) self._media_artist = self._media_album_artist - else: - self._media_album_name = None - self._media_album_artist = None - self._media_track = None - self._media_artist = None # set app name to library name if (self._session is not None @@ -501,8 +487,6 @@ class PlexClient(MediaPlayerDevice): thumb_url = self._get_thumbnail_url(self._session.art) self._media_image_url = thumb_url - else: - self._media_image_url = None def _get_thumbnail_url(self, property_value): """Return full URL (if exists) for a thumbnail property.""" @@ -521,6 +505,7 @@ class PlexClient(MediaPlayerDevice): """Force client to idle.""" self._state = STATE_IDLE self._session = None + self._clear_media() @property def unique_id(self): diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index c413bfd3357..e5aeaa6b9c1 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -18,7 +18,7 @@ from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_OFF, STATE_ON, STATE_PLAYING, STATE_IDLE) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['rxv==0.4.0'] +REQUIREMENTS = ['rxv==0.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index 9075eab2cdd..001c8d1188a 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - CONF_HOST, CONF_METHOD, CONF_PORT, ATTR_STATE) + CONF_HOST, CONF_METHOD, CONF_PORT, CONF_TYPE, CONF_TIMEOUT, ATTR_STATE) DOMAIN = 'modbus' @@ -24,7 +24,6 @@ REQUIREMENTS = ['pymodbus==1.3.1'] CONF_BAUDRATE = 'baudrate' CONF_BYTESIZE = 'bytesize' CONF_STOPBITS = 'stopbits' -CONF_TYPE = 'type' CONF_PARITY = 'parity' SERIAL_SCHEMA = { @@ -35,12 +34,14 @@ SERIAL_SCHEMA = { vol.Required(CONF_PARITY): vol.Any('E', 'O', 'N'), vol.Required(CONF_STOPBITS): vol.Any(1, 2), vol.Required(CONF_TYPE): 'serial', + vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } ETHERNET_SCHEMA = { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.positive_int, vol.Required(CONF_TYPE): vol.Any('tcp', 'udp'), + vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, } @@ -89,15 +90,18 @@ def setup(hass, config): baudrate=config[DOMAIN][CONF_BAUDRATE], stopbits=config[DOMAIN][CONF_STOPBITS], bytesize=config[DOMAIN][CONF_BYTESIZE], - parity=config[DOMAIN][CONF_PARITY]) + parity=config[DOMAIN][CONF_PARITY], + timeout=config[DOMAIN][CONF_TIMEOUT]) elif client_type == 'tcp': from pymodbus.client.sync import ModbusTcpClient as ModbusClient client = ModbusClient(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + port=config[DOMAIN][CONF_PORT], + timeout=config[DOMAIN][CONF_TIMEOUT]) elif client_type == 'udp': from pymodbus.client.sync import ModbusUdpClient as ModbusClient client = ModbusClient(host=config[DOMAIN][CONF_HOST], - port=config[DOMAIN][CONF_PORT]) + port=config[DOMAIN][CONF_PORT], + timeout=config[DOMAIN][CONF_TIMEOUT]) else: return False diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 929ae0fc455..9decc9a14aa 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) from homeassistant.components.mqtt.server import HBMQTT_CONFIG_SCHEMA -REQUIREMENTS = ['paho-mqtt==1.3.0'] +REQUIREMENTS = ['paho-mqtt==1.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/namecheapdns.py b/homeassistant/components/namecheapdns.py new file mode 100644 index 00000000000..bfad10b4f76 --- /dev/null +++ b/homeassistant/components/namecheapdns.py @@ -0,0 +1,70 @@ +"""Integrate with NamecheapDNS.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_ACCESS_TOKEN, CONF_DOMAIN +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 = 'namecheapdns' +UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update' +INTERVAL = timedelta(minutes=5) +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the NamecheapDNS component.""" + host = config[DOMAIN][CONF_HOST] + domain = config[DOMAIN][CONF_DOMAIN] + token = config[DOMAIN][CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) + + result = yield from _update_namecheapdns(session, host, domain, token) + + if not result: + return False + + @asyncio.coroutine + def update_domain_interval(now): + """Update the NamecheapDNS entry.""" + yield from _update_namecheapdns(session, host, domain, token) + + async_track_time_interval(hass, update_domain_interval, INTERVAL) + + return result + + +@asyncio.coroutine +def _update_namecheapdns(session, host, domain, token): + """Update NamecheapDNS.""" + import xml.etree.ElementTree as ET + + params = { + 'host': host, + 'domain': domain, + 'password': token, + } + + resp = yield from session.get(UPDATE_URL, params=params) + xml_string = yield from resp.text() + root = ET.fromstring(xml_string) + err_count = root.find('ErrCount').text + + if int(err_count) != 0: + _LOGGER.warning('Updating Namecheap domain %s failed', domain) + return False + + return True diff --git a/homeassistant/components/notify/clicksendaudio.py b/homeassistant/components/notify/clicksend_tts.py similarity index 96% rename from homeassistant/components/notify/clicksendaudio.py rename to homeassistant/components/notify/clicksend_tts.py index b8f346c9478..f951dd00307 100644 --- a/homeassistant/components/notify/clicksendaudio.py +++ b/homeassistant/components/notify/clicksend_tts.py @@ -1,10 +1,10 @@ """ -Clicksend audio platform for notify component. +clicksend_tts platform for notify component. This platform sends text to speech audio messages through clicksend For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.clicksendaudio/ +https://home-assistant.io/components/notify.clicksend_tts/ """ import json import logging diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 6b1cdf814fa..1b44ec60722 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -94,8 +94,8 @@ NOTIFY_CALLBACK_EVENT = 'html5_notification' # Badge and timestamp are Chrome specific (not in official spec) HTML5_SHOWNOTIFICATION_PARAMETERS = ( - 'actions', 'badge', 'body', 'dir', 'icon', 'lang', 'renotify', - 'requireInteraction', 'tag', 'timestamp', 'vibrate') + 'actions', 'badge', 'body', 'dir', 'icon', 'image', 'lang', + 'renotify', 'requireInteraction', 'tag', 'timestamp', 'vibrate') def get_service(hass, config, discovery_info=None): diff --git a/homeassistant/components/notify/rocketchat.py b/homeassistant/components/notify/rocketchat.py new file mode 100644 index 00000000000..f2898c8b998 --- /dev/null +++ b/homeassistant/components/notify/rocketchat.py @@ -0,0 +1,76 @@ +""" +Rocket.Chat notification service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.rocketchat/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_URL, CONF_USERNAME, CONF_PASSWORD) +from homeassistant.components.notify import ( + ATTR_DATA, PLATFORM_SCHEMA, + BaseNotificationService) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['rocketchat-API==0.6.1'] + +CONF_ROOM = 'room' + +_LOGGER = logging.getLogger(__name__) + +# pylint: disable=no-value-for-parameter +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): vol.Url(), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_ROOM): cv.string, +}) + + +def get_service(hass, config, discovery_info=None): + """Return the notify service.""" + from rocketchat_API.APIExceptions.RocketExceptions import ( + RocketConnectionException, RocketAuthenticationException) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + url = config.get(CONF_URL) + room = config.get(CONF_ROOM) + + try: + return RocketChatNotificationService(url, username, password, room) + except RocketConnectionException: + _LOGGER.warning( + "Unable to connect to Rocket.Chat server at %s.", url) + except RocketAuthenticationException: + _LOGGER.warning( + "Rocket.Chat authentication failed for user %s.", username) + _LOGGER.info("Please check your username/password.") + + return None + + +class RocketChatNotificationService(BaseNotificationService): + """Implement the notification service for Rocket.Chat.""" + + def __init__(self, url, username, password, room): + """Initialize the service.""" + from rocketchat_API.rocketchat import RocketChat + self._room = room + self._server = RocketChat(username, password, server_url=url) + + def send_message(self, message="", **kwargs): + """Send a message to Rocket.Chat.""" + data = kwargs.get(ATTR_DATA) or {} + resp = self._server.chat_post_message(message, channel=self._room, + **data) + if resp.status_code == 200: + success = resp.json()["success"] + if not success: + _LOGGER.error("Unable to post Rocket.Chat message") + else: + _LOGGER.error("Incorrect status code when posting message: %d", + resp.status_code) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index fe19da49cb2..455bab039f6 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,13 +15,14 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.6', - 'pyasn1-modules==0.1.4'] + 'pyasn1==0.3.7', + 'pyasn1-modules==0.1.5'] _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' CONF_VERIFY = 'verify' +CONF_ROOM = 'room' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, @@ -29,6 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, + vol.Optional(CONF_ROOM, default=''): cv.string, }) @@ -37,31 +39,33 @@ def get_service(hass, config, discovery_info=None): return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), config.get(CONF_TLS), - config.get(CONF_VERIFY)) + config.get(CONF_VERIFY), config.get(CONF_ROOM)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls, verify): + def __init__(self, sender, password, recipient, tls, verify, room): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls self._verify = verify + self._room = room def send_message(self, message="", **kwargs): """Send a message to a user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = '{}: {}'.format(title, message) if title else message - send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, self._verify, data) + send_message('{}/home-assistant'.format(self._sender), + self._password, self._recipient, self._tls, + self._verify, self._room, data) def send_message(sender, password, recipient, use_tls, - verify_certificate, message): + verify_certificate, room, message): """Send a message over XMPP.""" import sleekxmpp @@ -78,6 +82,8 @@ def send_message(sender, password, recipient, use_tls, self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + if room: + self.register_plugin('xep_0045') # MUC if not verify_certificate: self.add_event_handler('ssl_invalid_cert', self.discard_ssl_invalid_cert) @@ -89,7 +95,13 @@ def send_message(sender, password, recipient, use_tls, """Start the communication and sends the message.""" self.send_presence() self.get_roster() - self.send_message(mto=recipient, mbody=message, mtype='chat') + + if room: + _LOGGER.debug("Joining room %s.", room) + self.plugin['xep_0045'].joinMUC(room, sender, wait=True) + self.send_message(mto=room, mbody=message, mtype='groupchat') + else: + self.send_message(mto=recipient, mbody=message, mtype='chat') self.disconnect(wait=True) def check_credentials(self, event): diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index b33766d84db..6bf677b9645 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -1,8 +1,9 @@ """Component to allow running Python scripts.""" -import glob -import os -import logging import datetime +import glob +import logging +import os +import time import voluptuous as vol @@ -10,6 +11,7 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename +import homeassistant.util.dt as dt_util DOMAIN = 'python_script' REQUIREMENTS = ['restrictedpython==4.0a3'] @@ -25,6 +27,13 @@ ALLOWED_EVENTBUS = set(['fire']) ALLOWED_STATEMACHINE = set(['entity_ids', 'all', 'get', 'is_state', 'is_state_attr', 'remove', 'set']) ALLOWED_SERVICEREGISTRY = set(['services', 'has_service', 'call']) +ALLOWED_TIME = set(['sleep', 'strftime', 'strptime', 'gmtime', 'localtime', + 'ctime', 'time', 'mktime']) +ALLOWED_DATETIME = set(['date', 'time', 'datetime', 'timedelta', 'tzinfo']) +ALLOWED_DT_UTIL = set([ + 'utcnow', 'now', 'as_utc', 'as_timestamp', 'as_local', + 'utc_from_timestamp', 'start_of_local_day', 'parse_datetime', 'parse_date', + 'get_age']) class ScriptError(HomeAssistantError): @@ -111,7 +120,10 @@ def execute(hass, filename, source, data=None): elif (obj is hass and name not in ALLOWED_HASS or obj is hass.bus and name not in ALLOWED_EVENTBUS or obj is hass.states and name not in ALLOWED_STATEMACHINE or - obj is hass.services and name not in ALLOWED_SERVICEREGISTRY): + obj is hass.services and name not in ALLOWED_SERVICEREGISTRY or + obj is dt_util and name not in ALLOWED_DT_UTIL or + obj is datetime and name not in ALLOWED_DATETIME or + isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME): raise ScriptError('Not allowed to access {}.{}'.format( obj.__class__.__name__, name)) @@ -120,6 +132,8 @@ def execute(hass, filename, source, data=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) builtins['datetime'] = datetime + builtins['time'] = TimeWrapper() + builtins['dt_util'] = dt_util restricted_globals = { '__builtins__': builtins, '_print_': StubPrinter, @@ -159,3 +173,24 @@ class StubPrinter: # pylint: disable=no-self-use _LOGGER.warning( "Don't use print() inside scripts. Use logger.info() instead.") + + +class TimeWrapper: + """Wrapper of the time module.""" + + # Class variable, only going to warn once per Home Assistant run + warned = False + + # pylint: disable=no-self-use + def sleep(self, *args, **kwargs): + """Sleep method that warns once.""" + if not TimeWrapper.warned: + TimeWrapper.warned = True + _LOGGER.warning('Using time.sleep can reduce the performance of ' + 'Home Assistant') + + time.sleep(*args, **kwargs) + + def __getattr__(self, attr): + """Fetch an attribute from Time module.""" + return getattr(time, attr) diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py index 0cc91576dae..bed23674d32 100644 --- a/homeassistant/components/raincloud.py +++ b/homeassistant/components/raincloud.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['raincloudy==0.0.1'] +REQUIREMENTS = ['raincloudy==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5959165779b..eb92f345a07 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -351,6 +351,7 @@ class Recorder(threading.Thread): from sqlalchemy.engine import Engine from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker + from sqlite3 import Connection from . import models @@ -360,7 +361,7 @@ class Recorder(threading.Thread): @event.listens_for(Engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): """Set sqlite's WAL mode.""" - if self.db_url.startswith("sqlite://"): + if isinstance(dbapi_connection, Connection): old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None cursor = dbapi_connection.cursor() diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index e975460be58..41dbec851b5 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -148,6 +148,7 @@ def async_setup(hass, config): num_repeats = service.data.get(ATTR_NUM_REPEATS) delay_secs = service.data.get(ATTR_DELAY_SECS) + update_tasks = [] for remote in target_remotes: if service.service == SERVICE_TURN_ON: yield from remote.async_turn_on(activity=activity_id) @@ -160,17 +161,9 @@ def async_setup(hass, config): else: yield from remote.async_turn_off(activity=activity_id) - update_tasks = [] - for remote in target_remotes: if not remote.should_poll: continue - - update_coro = hass.async_add_job( - remote.async_update_ha_state(True)) - if hasattr(remote, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(remote.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/sensor/abode.py b/homeassistant/components/sensor/abode.py new file mode 100644 index 00000000000..3a465db4488 --- /dev/null +++ b/homeassistant/components/sensor/abode.py @@ -0,0 +1,81 @@ +""" +Support for Abode Security System sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['abode'] + +# Sensor types: Name, icon +SENSOR_TYPES = { + 'temp': ['Temperature', 'thermometer'], + 'humidity': ['Humidity', 'water-percent'], + 'lux': ['Lux', 'lightbulb'], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for an Abode device.""" + import abodepy.helpers.constants as CONST + + data = hass.data[ABODE_DOMAIN] + + devices = [] + for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR): + if data.is_excluded(device): + continue + + for sensor_type in SENSOR_TYPES: + devices.append(AbodeSensor(data, device, sensor_type)) + + data.devices.extend(devices) + + add_devices(devices) + + +class AbodeSensor(AbodeDevice): + """A sensor implementation for Abode devices.""" + + def __init__(self, data, device, sensor_type): + """Initialize a sensor for an Abode device.""" + super().__init__(data, device) + self._sensor_type = sensor_type + self._icon = 'mdi:{}'.format(SENSOR_TYPES[self._sensor_type][1]) + self._name = '{0} {1}'.format(self._device.name, + SENSOR_TYPES[self._sensor_type][0]) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + if self._sensor_type == 'temp': + return self._device.temp + elif self._sensor_type == 'humidity': + return self._device.humidity + elif self._sensor_type == 'lux': + return self._device.lux + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + if self._sensor_type == 'temp': + return self._device.temp_unit + elif self._sensor_type == 'humidity': + return self._device.humidity_unit + elif self._sensor_type == 'lux': + return self._device.lux_unit diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 7e14ec6eff4..82486a63d31 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -4,8 +4,6 @@ Support for AirVisual air quality sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.airvisual/ """ - -import asyncio from logging import getLogger from datetime import timedelta @@ -15,13 +13,15 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, - CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE) + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE, + CONF_SHOW_ON_MAP) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -_LOGGER = getLogger(__name__) REQUIREMENTS = ['pyairvisual==1.0.0'] +_LOGGER = getLogger(__name__) + ATTR_CITY = 'city' ATTR_COUNTRY = 'country' ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' @@ -32,6 +32,7 @@ ATTR_TIMESTAMP = 'timestamp' CONF_CITY = 'city' CONF_COUNTRY = 'country' CONF_RADIUS = 'radius' +CONF_ATTRIBUTION = "Data provided by AirVisual" MASS_PARTS_PER_MILLION = 'ppm' MASS_PARTS_PER_BILLION = 'ppb' @@ -39,56 +40,22 @@ VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -POLLUTANT_LEVEL_MAPPING = [{ - 'label': 'Good', - 'minimum': 0, - 'maximum': 50 -}, { - 'label': 'Moderate', - 'minimum': 51, - 'maximum': 100 -}, { - 'label': 'Unhealthy for Sensitive Groups', - 'minimum': 101, - 'maximum': 150 -}, { - 'label': 'Unhealthy', - 'minimum': 151, - 'maximum': 200 -}, { - 'label': 'Very Unhealthy', - 'minimum': 201, - 'maximum': 300 -}, { - 'label': 'Hazardous', - 'minimum': 301, - 'maximum': 10000 -}] +POLLUTANT_LEVEL_MAPPING = [ + {'label': 'Good', 'minimum': 0, 'maximum': 50}, + {'label': 'Moderate', 'minimum': 51, 'maximum': 100}, + {'label': 'Unhealthy for sensitive group', 'minimum': 101, 'maximum': 150}, + {'label': 'Unhealthy', 'minimum': 151, 'maximum': 200}, + {'label': 'Very Unhealthy', 'minimum': 201, 'maximum': 300}, + {'label': 'Hazardous', 'minimum': 301, 'maximum': 10000} +] + POLLUTANT_MAPPING = { - 'co': { - 'label': 'Carbon Monoxide', - 'unit': MASS_PARTS_PER_MILLION - }, - 'n2': { - 'label': 'Nitrogen Dioxide', - 'unit': MASS_PARTS_PER_BILLION - }, - 'o3': { - 'label': 'Ozone', - 'unit': MASS_PARTS_PER_BILLION - }, - 'p1': { - 'label': 'PM10', - 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER - }, - 'p2': { - 'label': 'PM2.5', - 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER - }, - 's2': { - 'label': 'Sulfur Dioxide', - 'unit': MASS_PARTS_PER_BILLION - } + 'co': {'label': 'Carbon Monoxide', 'unit': MASS_PARTS_PER_MILLION}, + 'n2': {'label': 'Nitrogen Dioxide', 'unit': MASS_PARTS_PER_BILLION}, + 'o3': {'label': 'Ozone', 'unit': MASS_PARTS_PER_BILLION}, + 'p1': {'label': 'PM10', 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER}, + 'p2': {'label': 'PM2.5', 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER}, + 's2': {'label': 'Sulfur Dioxide', 'unit': MASS_PARTS_PER_BILLION}, } SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} @@ -99,32 +66,23 @@ SENSOR_TYPES = [ ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): - cv.string, + vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), - vol.Optional(CONF_LATITUDE): - cv.latitude, - vol.Optional(CONF_LONGITUDE): - cv.longitude, - vol.Optional(CONF_RADIUS, default=1000): - cv.positive_int, - vol.Optional(CONF_CITY): - cv.string, - vol.Optional(CONF_STATE): - cv.string, - vol.Optional(CONF_COUNTRY): - cv.string + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_CITY): cv.string, + vol.Optional(CONF_COUNTRY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): cv.positive_int, + vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, + vol.Optional(CONF_STATE): cv.string, }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Configure the platform and add the sensors.""" import pyairvisual as pav - _LOGGER.debug('Received configuration: %s', config) - api_key = config.get(CONF_API_KEY) monitored_locales = config.get(CONF_MONITORED_CONDITIONS) latitude = config.get(CONF_LATITUDE, hass.config.latitude) @@ -133,27 +91,28 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): city = config.get(CONF_CITY) state = config.get(CONF_STATE) country = config.get(CONF_COUNTRY) + show_on_map = config.get(CONF_SHOW_ON_MAP) if city and state and country: - _LOGGER.debug('Using city, state, and country: %s, %s, %s', city, - state, country) + _LOGGER.debug( + "Using city, state, and country: %s, %s, %s", city, state, country) data = AirVisualData( - pav.Client(api_key), city=city, state=state, country=country) + pav.Client(api_key), city=city, state=state, country=country, + show_on_map=show_on_map) else: - _LOGGER.debug('Using latitude and longitude: %s, %s', latitude, - longitude) + _LOGGER.debug( + "Using latitude and longitude: %s, %s", latitude, longitude) data = AirVisualData( - pav.Client(api_key), - latitude=latitude, - longitude=longitude, - radius=radius) + pav.Client(api_key), latitude=latitude, longitude=longitude, + radius=radius, show_on_map=show_on_map) + data.update() sensors = [] for locale in monitored_locales: for sensor_class, name, icon in SENSOR_TYPES: sensors.append(globals()[sensor_class](data, name, icon, locale)) - async_add_devices(sensors, True) + add_devices(sensors, True) def merge_two_dicts(dict1, dict2): @@ -167,7 +126,7 @@ class AirVisualBaseSensor(Entity): """Define a base class for all of our sensors.""" def __init__(self, data, name, icon, locale): - """Initialize.""" + """Initialize the sensor.""" self._data = data self._icon = icon self._locale = locale @@ -177,17 +136,24 @@ class AirVisualBaseSensor(Entity): @property def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: 'AirVisual©', + """Return the device state attributes.""" + attrs = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_CITY: self._data.city, ATTR_COUNTRY: self._data.country, ATTR_REGION: self._data.state, - ATTR_LATITUDE: self._data.latitude, - ATTR_LONGITUDE: self._data.longitude, ATTR_TIMESTAMP: self._data.pollution_info.get('ts') } + if self._data.show_on_map: + attrs[ATTR_LATITUDE] = self._data.latitude + attrs[ATTR_LONGITUDE] = self._data.longitude + else: + attrs['lati'] = self._data.latitude + attrs['long'] = self._data.longitude + + return attrs + @property def icon(self): """Return the icon.""" @@ -203,20 +169,14 @@ class AirVisualBaseSensor(Entity): """Return the state.""" return self._state - @asyncio.coroutine - def async_update(self): - """Update the status of the sensor.""" - _LOGGER.debug('Updating sensor: %s', self._name) - self._data.update() - class AirPollutionLevelSensor(AirVisualBaseSensor): """Define a sensor to measure air pollution level.""" - @asyncio.coroutine - def async_update(self): + def update(self): """Update the status of the sensor.""" - yield from super().async_update() + self._data.update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) try: [level] = [ @@ -238,10 +198,9 @@ class AirQualityIndexSensor(AirVisualBaseSensor): """Return the unit the value is expressed in.""" return 'PSI' - @asyncio.coroutine - def async_update(self): + def update(self): """Update the status of the sensor.""" - yield from super().async_update() + self._data.update() self._state = self._data.pollution_info.get( 'aqi{0}'.format(self._locale)) @@ -251,23 +210,23 @@ class MainPollutantSensor(AirVisualBaseSensor): """Define a sensor to the main pollutant of an area.""" def __init__(self, data, name, icon, locale): - """Initialize.""" + """Initialize the sensor.""" super().__init__(data, name, icon, locale) self._symbol = None self._unit = None @property def device_state_attributes(self): - """Return the state attributes.""" + """Return the device state attributes.""" return merge_two_dicts(super().device_state_attributes, { ATTR_POLLUTANT_SYMBOL: self._symbol, ATTR_POLLUTANT_UNIT: self._unit }) - @asyncio.coroutine - def async_update(self): + def update(self): """Update the status of the sensor.""" - yield from super().async_update() + self._data.update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) pollution_info = POLLUTANT_MAPPING.get(symbol, {}) self._state = pollution_info.get('label') @@ -279,7 +238,7 @@ class AirVisualData(object): """Define an object to hold sensor data.""" def __init__(self, client, **kwargs): - """Initialize.""" + """Initialize the AirVisual data element.""" self._client = client self.pollution_info = None @@ -291,6 +250,8 @@ class AirVisualData(object): self.longitude = kwargs.get(CONF_LONGITUDE) self._radius = kwargs.get(CONF_RADIUS) + self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update with new AirVisual data.""" @@ -298,21 +259,21 @@ class AirVisualData(object): try: if self.city and self.state and self.country: - resp = self._client.city(self.city, self.state, - self.country).get('data') - self.longitude, self.latitude = resp.get('location').get( - 'coordinates') + resp = self._client.city( + self.city, self.state, self.country).get('data') else: - resp = self._client.nearest_city(self.latitude, self.longitude, - self._radius).get('data') - _LOGGER.debug('New data retrieved: %s', resp) + resp = self._client.nearest_city( + self.latitude, self.longitude, self._radius).get('data') + _LOGGER.debug("New data retrieved: %s", resp) self.city = resp.get('city') self.state = resp.get('state') self.country = resp.get('country') + self.longitude, self.latitude = resp.get('location').get( + 'coordinates') self.pollution_info = resp.get('current', {}).get('pollution', {}) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to retrieve data on this location: %s', + _LOGGER.error("Unable to retrieve data on this location: %s", self.__dict__) _LOGGER.debug(exc_info) self.pollution_info = {} diff --git a/homeassistant/components/sensor/android_ip_webcam.py b/homeassistant/components/sensor/android_ip_webcam.py index c9e1238d9a3..f25056d5a0f 100644 --- a/homeassistant/components/sensor/android_ip_webcam.py +++ b/homeassistant/components/sensor/android_ip_webcam.py @@ -9,6 +9,7 @@ import asyncio from homeassistant.components.android_ip_webcam import ( KEY_MAP, ICON_MAP, DATA_IP_WEBCAM, AndroidIPCamEntity, CONF_HOST, CONF_NAME, CONF_SENSORS) +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['android_ip_webcam'] @@ -75,14 +76,5 @@ class IPWebcamSensor(AndroidIPCamEntity): def icon(self): """Return the icon for the sensor.""" if self._sensor == 'battery_level' and self._state is not None: - rounded_level = round(int(self._state), -1) - returning_icon = 'mdi:battery' - if rounded_level < 10: - returning_icon = 'mdi:battery-outline' - elif self._state == 100: - returning_icon = 'mdi:battery' - else: - returning_icon = 'mdi:battery-{}'.format(str(rounded_level)) - - return returning_icon + return icon_for_battery_level(int(self._state)) return ICON_MAP.get(self._sensor, 'mdi:eye') diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index 5e1f1274160..f665d8e70ab 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -7,20 +7,22 @@ https://home-assistant.io/components/sensor.arlo/ import asyncio import logging from datetime import timedelta + import voluptuous as vol -from homeassistant.helpers import config_validation as cv +import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import ( CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO) - -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['arlo'] -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=90) # sensor_type [ description, unit, icon ] SENSOR_TYPES = { @@ -35,8 +37,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) -SCAN_INTERVAL = timedelta(seconds=90) - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -48,18 +48,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): if sensor_type == 'total_cameras': - sensors.append(ArloSensor(hass, - SENSOR_TYPES[sensor_type][0], - arlo, - sensor_type)) + sensors.append(ArloSensor( + hass, SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) else: for camera in arlo.cameras: - name = '{0} {1}'.format(SENSOR_TYPES[sensor_type][0], - camera.name) + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], camera.name) sensors.append(ArloSensor(hass, name, camera, sensor_type)) async_add_devices(sensors, True) - return True class ArloSensor(Entity): @@ -88,6 +85,9 @@ class ArloSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" + if self._sensor_type == 'battery_level' and self._state is not None: + return icon_for_battery_level(battery_level=int(self._state), + charging=False) return self._icon @property @@ -120,7 +120,7 @@ class ArloSensor(Entity): @property def device_state_attributes(self): - """Return the state attributes.""" + """Return the device state attributes.""" attrs = {} attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 6fd09874651..7308cd4f791 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -42,7 +42,7 @@ def discover_sensors(topic, payload): name = parts[2] + " Moisture" return ArwnSensor(name, 'moisture', unit, "mdi:water-percent") if domain == "rain": - if len(parts) >= 2 and parts[2] == "today": + if len(parts) >= 3 and parts[2] == "today": return ArwnSensor("Rain Since Midnight", 'since_midnight', "in", "mdi:water") if domain == 'barometer': diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index a10a276bf0f..39a258c5e6a 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -14,7 +14,7 @@ from requests.exceptions import ConnectionError as ConnectError, \ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_API_KEY, CONF_NAME, CONF_MONITORED_CONDITIONS, ATTR_ATTRIBUTION, - CONF_LATITUDE, CONF_LONGITUDE) + CONF_LATITUDE, CONF_LONGITUDE, UNIT_UV_INDEX) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -62,7 +62,7 @@ SENSOR_TYPES = { 'apparent_temperature': ['Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly']], - 'dew_point': ['Dew point', '°C', '°F', '°C', '°C', '°C', + 'dew_point': ['Dew Point', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly', 'daily']], 'wind_speed': ['Wind Speed', 'm/s', 'mph', 'km/h', 'mph', 'mph', 'mdi:weather-windy', ['currently', 'hourly', 'daily']], @@ -96,6 +96,10 @@ SENSOR_TYPES = { 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm', 'in', 'mm', 'mm', 'mm', 'mdi:thermometer', ['currently', 'hourly', 'daily']], + 'uv_index': ['UV Index', + UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, + UNIT_UV_INDEX, UNIT_UV_INDEX, 'mdi:weather-sunny', + ['currently', 'hourly', 'daily']], } CONDITION_PICTURES = { @@ -305,7 +309,7 @@ class DarkSkySensor(Entity): 'temperature_min', 'temperature_max', 'apparent_temperature_min', 'apparent_temperature_max', - 'pressure', 'ozone']): + 'pressure', 'ozone', 'uvIndex']): return round(state, 1) return state diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 1bb6383ecbb..5f33874c412 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -17,7 +17,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['fitbit==0.3.0'] diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index 063a4808915..ea6382ce795 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['fritzconnection==0.6.3'] +REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index 1b7d2398efc..4e35bd85799 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -17,7 +17,7 @@ from homeassistant.util import Throttle from requests.exceptions import RequestException -REQUIREMENTS = ['fritzconnection==0.6.3'] +REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 58ac363b98e..2d1edbd1bb1 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -28,16 +28,16 @@ DEFAULT_PORT = '61208' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'disk_use_percent': ['Disk Use', '%'], - 'disk_use': ['Disk Use', 'GiB'], - 'disk_free': ['Disk Free', 'GiB'], - 'memory_use_percent': ['RAM Use', '%'], - 'memory_use': ['RAM Use', 'MiB'], - 'memory_free': ['RAM Free', 'MiB'], - 'swap_use_percent': ['Swap Use', '%'], - 'swap_use': ['Swap Use', 'GiB'], - 'swap_free': ['Swap Free', 'GiB'], - 'processor_load': ['CPU Load', '15 min'], + 'disk_use_percent': ['Disk used', '%'], + 'disk_use': ['Disk used', 'GiB'], + 'disk_free': ['Disk free', 'GiB'], + 'memory_use_percent': ['RAM used', '%'], + 'memory_use': ['RAM used', 'MiB'], + 'memory_free': ['RAM free', 'MiB'], + 'swap_use_percent': ['Swap used', '%'], + 'swap_use': ['Swap used', 'GiB'], + 'swap_free': ['Swap free', 'GiB'], + 'processor_load': ['CPU load', '15 min'], 'process_running': ['Running', 'Count'], 'process_total': ['Total', 'Count'], 'process_thread': ['Thread', 'Count'], diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 9d66537079f..6f64f479dec 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aioimaplib==0.7.12'] +REQUIREMENTS = ['aioimaplib==0.7.13'] CONF_SERVER = 'server' diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py index 72377e07c7c..9a23da48a6b 100644 --- a/homeassistant/components/sensor/ios.py +++ b/homeassistant/components/sensor/ios.py @@ -6,7 +6,7 @@ https://home-assistant.io/ecosystem/ios/ """ from homeassistant.components import ios from homeassistant.helpers.entity import Entity -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['ios'] diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index 57995a831f3..f64fa6191e2 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -9,7 +9,7 @@ from typing import Callable # noqa import homeassistant.components.isy994 as isy from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_OFF, STATE_ON) + TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_OFF, STATE_ON, UNIT_UV_INDEX) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -77,7 +77,7 @@ UOM_FRIENDLY_NAME = { '64': 'shindo', '65': 'SML', '69': 'gal', - '71': 'UV index', + '71': UNIT_UV_INDEX, '72': 'V', '73': 'W', '74': 'W/m²', diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index e14922a1579..21198fa940b 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -15,7 +15,7 @@ from homeassistant.core import callback import homeassistant.components.mqtt as mqtt from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_TIMEOUT) + CONF_NAME, CONF_TIMEOUT, STATE_NOT_HOME) from homeassistant.components.mqtt import CONF_STATE_TOPIC import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -39,8 +39,6 @@ DEFAULT_TIMEOUT = 5 DEFAULT_AWAY_TIMEOUT = 0 DEFAULT_TOPIC = 'room_presence' -STATE_AWAY = 'away' - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(CONF_STATE_TOPIC, default=DEFAULT_TOPIC): cv.string, @@ -73,7 +71,7 @@ class MQTTRoomSensor(Entity): def __init__(self, name, state_topic, device_id, timeout, consider_home): """Initialize the sensor.""" - self._state = STATE_AWAY + self._state = STATE_NOT_HOME self._name = name self._state_topic = '{}{}'.format(state_topic, '/+') self._device_id = slugify(device_id).upper() @@ -148,7 +146,7 @@ class MQTTRoomSensor(Entity): if self._updated \ and self._consider_home \ and dt.utcnow() - self._updated > self._consider_home: - self._state = STATE_AWAY + self._state = STATE_NOT_HOME def _parse_update_data(topic, data): diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index df6ff0b0649..0d2a542c7bb 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -44,6 +44,18 @@ SENSOR_TYPES = { 'ipv4_in': ['IPv4 In', 'kb/s', 'system.ipv4', 'received', 0], 'ipv4_out': ['IPv4 Out', 'kb/s', 'system.ipv4', 'sent', 0], 'disk_free': ['Disk Free', 'GiB', 'disk_space._', 'avail', 2], + 'cpu_iowait': ['CPU IOWait', '%', 'system.cpu', 'iowait', 1], + 'cpu_user': ['CPU User', '%', 'system.cpu', 'user', 1], + 'cpu_system': ['CPU System', '%', 'system.cpu', 'system', 1], + 'cpu_softirq': ['CPU SoftIRQ', '%', 'system.cpu', 'softirq', 1], + 'cpu_guest': ['CPU Guest', '%', 'system.cpu', 'guest', 1], + 'uptime': ['Uptime', 's', 'system.uptime', 'uptime', 0], + 'packets_received': ['Packets Received', 'packets/s', 'ipv4.packets', + 'received', 0], + 'packets_sent': ['Packets Sent', 'packets/s', 'ipv4.packets', + 'sent', 0], + 'connections': ['Active Connections', 'Count', + 'netfilter.conntrack_sockets', 'connections', 0] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 944ee101d13..2072251c205 100755 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -125,7 +125,14 @@ class OpenWeatherMapSensor(Entity): def update(self): """Get the latest data from OWM and updates the states.""" - self.owa_client.update() + from pyowm.exceptions.api_call_error import APICallError + + try: + self.owa_client.update() + except APICallError: + _LOGGER.error("Exception when calling OWM web API to update data") + return + data = self.owa_client.data fc_data = self.owa_client.fc_data @@ -185,10 +192,15 @@ class WeatherData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from OpenWeatherMap.""" + from pyowm.exceptions.api_call_error import APICallError + try: obs = self.owm.weather_at_coords(self.latitude, self.longitude) - except TypeError: + except (APICallError, TypeError): + _LOGGER.error("Exception when calling OWM web API " + "to get weather at coords") obs = None + if obs is None: _LOGGER.warning("Failed to fetch data") return @@ -200,5 +212,5 @@ class WeatherData(object): obs = self.owm.three_hours_forecast_at_coords( self.latitude, self.longitude) self.fc_data = obs.get_forecast() - except TypeError: + except (ConnectionResetError, TypeError): _LOGGER.warning("Failed to fetch forecast") diff --git a/homeassistant/components/sensor/raincloud.py b/homeassistant/components/sensor/raincloud.py index ab073917e8e..d3b8b7207e3 100644 --- a/homeassistant/components/sensor/raincloud.py +++ b/homeassistant/components/sensor/raincloud.py @@ -13,7 +13,7 @@ from homeassistant.components.raincloud import ( DATA_RAINCLOUD, ICON_MAP, RainCloudEntity, SENSORS) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['raincloud'] @@ -56,7 +56,7 @@ class RainCloudSensor(RainCloudEntity): """Get the latest data and updates the states.""" _LOGGER.debug("Updating RainCloud sensor: %s", self._name) if self._sensor_type == 'battery': - self._state = self.data.battery.strip('%') + self._state = self.data.battery else: self._state = getattr(self.data, self._sensor_type) diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py index bfe8b2ec1cd..606b049b7e4 100644 --- a/homeassistant/components/sensor/ring.py +++ b/homeassistant/components/sensor/ring.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN, ATTR_ATTRIBUTION) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['ring'] @@ -108,6 +109,9 @@ class RingSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" + if self._sensor_type == 'battery' and self._state is not STATE_UNKNOWN: + return icon_for_battery_level(battery_level=int(self._state), + charging=False) return self._icon @property diff --git a/homeassistant/components/sensor/serial.py b/homeassistant/components/sensor/serial.py new file mode 100644 index 00000000000..ffa8bcc3070 --- /dev/null +++ b/homeassistant/components/sensor/serial.py @@ -0,0 +1,90 @@ +""" +Support for reading data from a serial port. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.serial/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pyserial-asyncio==0.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_SERIAL_PORT = 'serial_port' + +DEFAULT_NAME = "Serial Sensor" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Serial sensor platform.""" + name = config.get(CONF_NAME) + port = config.get(CONF_SERIAL_PORT) + + sensor = SerialSensor(name, port) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read()) + async_add_devices([sensor], True) + + +class SerialSensor(Entity): + """Representation of a Serial sensor.""" + + def __init__(self, name, port): + """Initialize the Serial sensor.""" + self._name = name + self._state = None + self._port = port + self._serial_loop_task = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + self._serial_loop_task = self.hass.loop.create_task( + self.serial_read(self._port)) + + @asyncio.coroutine + def serial_read(self, device, **kwargs): + """Read the data from the port.""" + import serial_asyncio + reader, _ = yield from serial_asyncio.open_serial_connection( + url=device, **kwargs) + while True: + line = yield from reader.readline() + self._state = line.decode('utf-8').strip() + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def stop_serial_read(self): + """Close resources.""" + if self._serial_loop_task: + self._serial_loop_task.cancel() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state diff --git a/homeassistant/components/sensor/skybell.py b/homeassistant/components/sensor/skybell.py new file mode 100644 index 00000000000..dc7295f463a --- /dev/null +++ b/homeassistant/components/sensor/skybell.py @@ -0,0 +1,82 @@ +""" +Sensor support for Skybell Doorbells. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.skybell/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import 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=30) + +# Sensor types: Name, icon +SENSOR_TYPES = { + 'chime_level': ['Chime Level', 'bell-ring'], +} + +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(SkybellSensor(device, sensor_type)) + + add_devices(sensors, True) + + +class SkybellSensor(SkybellDevice): + """A sensor implementation for Skybell devices.""" + + def __init__(self, device, sensor_type): + """Initialize a sensor for a Skybell device.""" + super().__init__(device) + self._sensor_type = sensor_type + self._icon = 'mdi:{}'.format(SENSOR_TYPES[self._sensor_type][1]) + self._name = "{0} {1}".format(self._device.name, + SENSOR_TYPES[self._sensor_type][0]) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + def update(self): + """Get the latest data and updates the state.""" + super().update() + + if self._sensor_type == 'chime_level': + self._state = self._device.outdoor_chime_level diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index aeb4587f3df..370b560a892 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.3.9'] +REQUIREMENTS = ['pysnmp==4.3.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 34d3cabf26b..a6932e2aebb 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -19,6 +19,7 @@ from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change from homeassistant.util import dt as dt_util +from homeassistant.components.recorder.util import session_scope, execute _LOGGER = logging.getLogger(__name__) @@ -88,6 +89,10 @@ class StatisticsSensor(Entity): self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 + if 'recorder' in self._hass.config.components: + # only use the database if it's configured + hass.async_add_job(self._initzialize_from_database) + @callback # pylint: disable=invalid-name def async_stats_sensor_state_listener(entity, old_state, new_state): @@ -95,20 +100,23 @@ class StatisticsSensor(Entity): self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) - try: - self.states.append(float(new_state.state)) - if self._max_age is not None: - now = dt_util.utcnow() - self.ages.append(now) - self.count = self.count + 1 - except ValueError: - self.count = self.count + 1 + self._add_state_to_queue(new_state) hass.async_add_job(self.async_update_ha_state, True) async_track_state_change( hass, entity_id, async_stats_sensor_state_listener) + def _add_state_to_queue(self, new_state): + try: + self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) + self.count = self.count + 1 + except ValueError: + self.count = self.count + 1 + @property def name(self): """Return the name of the sensor.""" @@ -187,3 +195,27 @@ class StatisticsSensor(Entity): else: self.min = self.max = self.total = STATE_UNKNOWN self.average_change = self.change = STATE_UNKNOWN + + @asyncio.coroutine + def _initzialize_from_database(self): + """Initialize the list of states from the database. + + The query will get the list of states in DESCENDING order so that we + can limit the result to self._sample_size. Afterwards reverse the + list so that we get it in the right order again. + """ + from homeassistant.components.recorder.models import States + _LOGGER.debug("initializing values for %s from the database", + self.entity_id) + + with session_scope(hass=self._hass) as session: + query = session.query(States)\ + .filter(States.entity_id == self._entity_id.lower())\ + .order_by(States.last_updated.desc())\ + .limit(self._sampling_size) + states = execute(query) + + for state in reversed(states): + self._add_state_to_queue(state) + + _LOGGER.debug("initializing from database completed") diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index 27dd2fad56f..8645d4ee7c6 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -96,7 +96,7 @@ class SteamSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - return {'Game': self._game} + return {'game': self._game} @property def entity_picture(self): diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 5fe1518a315..0c9a21447a8 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,35 +16,35 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.3.1'] +REQUIREMENTS = ['psutil==5.4.0'] _LOGGER = logging.getLogger(__name__) CONF_ARG = 'arg' SENSOR_TYPES = { - 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], - 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], - 'disk_use_percent': ['Disk Use', '%', 'mdi:harddisk'], + 'disk_free': ['Disk free', 'GiB', 'mdi:harddisk'], + 'disk_use': ['Disk used', 'GiB', 'mdi:harddisk'], + 'disk_use_percent': ['Disk used', '%', 'mdi:harddisk'], 'ipv4_address': ['IPv4 address', '', 'mdi:server-network'], 'ipv6_address': ['IPv6 address', '', 'mdi:server-network'], - 'last_boot': ['Last Boot', '', 'mdi:clock'], - 'load_15m': ['Average Load (15m)', '', 'mdi:memory'], - 'load_1m': ['Average Load (1m)', '', 'mdi:memory'], - 'load_5m': ['Average Load (5m)', '', 'mdi:memory'], - 'memory_free': ['RAM Free', 'MiB', 'mdi:memory'], - 'memory_use': ['RAM Use', 'MiB', 'mdi:memory'], - 'memory_use_percent': ['RAM Use', '%', 'mdi:memory'], + 'last_boot': ['Last boot', '', 'mdi:clock'], + 'load_15m': ['Average load (15m)', '', 'mdi:memory'], + 'load_1m': ['Average load (1m)', '', 'mdi:memory'], + 'load_5m': ['Average load (5m)', '', 'mdi:memory'], + 'memory_free': ['RAM available', 'MiB', 'mdi:memory'], + 'memory_use': ['RAM used', 'MiB', 'mdi:memory'], + 'memory_use_percent': ['RAM used', '%', 'mdi:memory'], 'network_in': ['Received', 'MiB', 'mdi:server-network'], 'network_out': ['Sent', 'MiB', 'mdi:server-network'], 'packets_in': ['Packets received', ' ', 'mdi:server-network'], 'packets_out': ['Packets sent', ' ', 'mdi:server-network'], 'process': ['Process', ' ', 'mdi:memory'], - 'processor_use': ['CPU Use', '%', 'mdi:memory'], - 'since_last_boot': ['Since Last Boot', '', 'mdi:clock'], - 'swap_free': ['Swap Free', 'GiB', 'mdi:harddisk'], - 'swap_use': ['Swap Use', 'GiB', 'mdi:harddisk'], - 'swap_use_percent': ['Swap Use', '%', 'mdi:harddisk'], + 'processor_use': ['CPU used', '%', 'mdi:memory'], + 'since_last_boot': ['Since last boot', '', 'mdi:clock'], + 'swap_free': ['Swap free', 'GiB', 'mdi:harddisk'], + 'swap_use': ['Swap used', 'GiB', 'mdi:harddisk'], + 'swap_use_percent': ['Swap used', '%', 'mdi:harddisk'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -126,8 +126,9 @@ class SystemMonitorSensor(Entity): elif self.type == 'memory_use_percent': self._state = psutil.virtual_memory().percent elif self.type == 'memory_use': - self._state = round((psutil.virtual_memory().total - - psutil.virtual_memory().available) / + virtual_memory = psutil.virtual_memory() + self._state = round((virtual_memory.total - + virtual_memory.available) / 1024**2, 1) elif self.type == 'memory_free': self._state = round(psutil.virtual_memory().available / 1024**2, 1) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index e59864dea2b..ff426951d3f 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -19,7 +19,6 @@ from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -93,10 +92,6 @@ class SensorTemplate(Entity): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state - @callback def template_sensor_state_listener(entity, old_state, new_state): """Handle device state changes.""" diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index fc31a5543e2..824fec41580 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -49,7 +49,6 @@ class TeslaSensor(TeslaDevice, Entity): self.entity_id = ENTITY_ID_FORMAT.format( '{}_{}'.format(self.tesla_id, self.type)) else: - self._name = self.tesla_device.name self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) @property diff --git a/homeassistant/components/sensor/toon.py b/homeassistant/components/sensor/toon.py new file mode 100644 index 00000000000..ee5ae9ca51e --- /dev/null +++ b/homeassistant/components/sensor/toon.py @@ -0,0 +1,256 @@ +""" +Toon van Eneco Utility Gages. + +This provides a component for the rebranded Quby thermostat as provided by +Eneco. +""" +import logging +import datetime as datetime + +from homeassistant.helpers.entity import Entity +import homeassistant.components.toon as toon_main + +_LOGGER = logging.getLogger(__name__) + +STATE_ATTR_DEVICE_TYPE = "device_type" +STATE_ATTR_LAST_CONNECTED_CHANGE = "last_connected_change" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup sensors.""" + _toon_main = hass.data[toon_main.TOON_HANDLE] + + sensor_items = [] + sensor_items.extend([ToonSensor(hass, + 'Power_current', + 'power-plug', + 'Watt'), + ToonSensor(hass, + 'Power_today', + 'power-plug', + 'kWh')]) + + if _toon_main.gas: + sensor_items.extend([ToonSensor(hass, + 'Gas_current', + 'gas-cylinder', + 'CM3'), + ToonSensor(hass, + 'Gas_today', + 'gas-cylinder', + 'M3')]) + + for plug in _toon_main.toon.smartplugs: + sensor_items.extend([ + FibaroSensor(hass, + '{}_current_power'.format(plug.name), + plug.name, + 'power-socket-eu', + 'Watt'), + FibaroSensor(hass, + '{}_today_energy'.format(plug.name), + plug.name, + 'power-socket-eu', + 'kWh')]) + + if _toon_main.toon.solar.produced or _toon_main.solar: + sensor_items.extend([ + SolarSensor(hass, 'Solar_maximum', 'kWh'), + SolarSensor(hass, 'Solar_produced', 'kWh'), + SolarSensor(hass, 'Solar_value', 'Watt'), + SolarSensor(hass, 'Solar_average_produced', 'kWh'), + SolarSensor(hass, 'Solar_meter_reading_low_produced', 'kWh'), + SolarSensor(hass, 'Solar_meter_reading_produced', 'kWh'), + SolarSensor(hass, 'Solar_daily_cost_produced', 'Euro') + ]) + + for smokedetector in _toon_main.toon.smokedetectors: + sensor_items.append( + FibaroSmokeDetector(hass, + '{}_smoke_detector'.format(smokedetector.name), + smokedetector.device_uuid, + 'alarm-bell', + '%')) + + add_devices(sensor_items) + + +class ToonSensor(Entity): + """Representation of a sensor.""" + + def __init__(self, hass, name, icon, unit_of_measurement): + """Initialize the sensor.""" + self._name = name + self._state = None + self._icon = "mdi:" + icon + self._unit_of_measurement = unit_of_measurement + self.thermos = hass.data[toon_main.TOON_HANDLE] + + @property + def should_poll(self): + """Polling required.""" + return True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the mdi icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self.thermos.get_data(self.name.lower()) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from the sensor.""" + self.thermos.update() + + +class FibaroSensor(Entity): + """Representation of a sensor.""" + + def __init__(self, hass, name, plug_name, icon, unit_of_measurement): + """Initialize the sensor.""" + self._name = name + self._plug_name = plug_name + self._state = None + self._icon = "mdi:" + icon + self._unit_of_measurement = unit_of_measurement + self.toon = hass.data[toon_main.TOON_HANDLE] + + @property + def should_poll(self): + """Polling required.""" + return True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the mdi icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + value = '_'.join(self.name.lower().split('_')[1:]) + return self.toon.get_data(value, self._plug_name) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from the sensor.""" + self.toon.update() + + +class SolarSensor(Entity): + """Representation of a sensor.""" + + def __init__(self, hass, name, unit_of_measurement): + """Initialize the sensor.""" + self._name = name + self._state = None + self._icon = "mdi:weather-sunny" + self._unit_of_measurement = unit_of_measurement + self.toon = hass.data[toon_main.TOON_HANDLE] + + @property + def should_poll(self): + """Polling required.""" + return True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the mdi icon of the sensor.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self.toon.get_data(self.name.lower()) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from the sensor.""" + self.toon.update() + + +class FibaroSmokeDetector(Entity): + """Representation of a smoke detector.""" + + def __init__(self, hass, name, uid, icon, unit_of_measurement): + """Initialize the sensor.""" + self._name = name + self._uid = uid + self._state = None + self._icon = "mdi:" + icon + self._unit_of_measurement = unit_of_measurement + self.toon = hass.data[toon_main.TOON_HANDLE] + + @property + def should_poll(self): + """Polling required.""" + return True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Return the mdi icon of the sensor.""" + return self._icon + + @property + def state_attributes(self): + """Return the state attributes of the smoke detectors.""" + value = datetime.datetime.fromtimestamp( + int(self.toon.get_data('last_connected_change', self.name)) + ).strftime('%Y-%m-%d %H:%M:%S') + + return { + STATE_ATTR_DEVICE_TYPE: self.toon.get_data('device_type', + self.name), + STATE_ATTR_LAST_CONNECTED_CHANGE: value + } + + @property + def state(self): + """Return the state of the sensor.""" + value = self.name.lower().split('_', 1)[1] + return self.toon.get_data(value, self.name) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + def update(self): + """Get the latest data from the sensor.""" + self.toon.update() diff --git a/homeassistant/components/sensor/tradfri.py b/homeassistant/components/sensor/tradfri.py index 314c18b7636..88a33cb2f8a 100644 --- a/homeassistant/components/sensor/tradfri.py +++ b/homeassistant/components/sensor/tradfri.py @@ -32,7 +32,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices_command = gateway.get_devices() devices_commands = yield from api(devices_command) - all_devices = yield from api(*devices_commands) + all_devices = yield from api(devices_commands) devices = [dev for dev in all_devices if not dev.has_light_control] async_add_devices(TradfriDevice(device, api) for device in devices) diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index add9cb1aca6..1eda9cb58fd 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -9,13 +9,13 @@ from datetime import timedelta import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_MONITORED_VARIABLES, STATE_UNKNOWN, STATE_IDLE) + CONF_MONITORED_VARIABLES, STATE_IDLE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['transmissionrpc==0.11'] @@ -26,9 +26,10 @@ DEFAULT_NAME = 'Transmission' DEFAULT_PORT = 9091 SENSOR_TYPES = { + 'active_torrents': ['Active Torrents', None], 'current_status': ['Status', None], 'download_speed': ['Down Speed', 'MB/s'], - 'upload_speed': ['Up Speed', 'MB/s'] + 'upload_speed': ['Up Speed', 'MB/s'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -80,7 +81,7 @@ class TransmissionSensor(Entity): def __init__(self, sensor_type, transmission_client, client_name): """Initialize the sensor.""" self._name = SENSOR_TYPES[sensor_type][0] - self.transmission_client = transmission_client + self.tm_client = transmission_client self.type = sensor_type self.client_name = client_name self._state = None @@ -117,9 +118,9 @@ class TransmissionSensor(Entity): self.refresh_transmission_data() if self.type == 'current_status': - if self.transmission_client.session: - upload = self.transmission_client.session.uploadSpeed - download = self.transmission_client.session.downloadSpeed + if self.tm_client.session: + upload = self.tm_client.session.uploadSpeed + download = self.tm_client.session.downloadSpeed if upload > 0 and download > 0: self._state = 'Up/Down' elif upload > 0 and download == 0: @@ -129,14 +130,16 @@ class TransmissionSensor(Entity): else: self._state = STATE_IDLE else: - self._state = STATE_UNKNOWN + self._state = None - if self.transmission_client.session: + if self.tm_client.session: if self.type == 'download_speed': - mb_spd = float(self.transmission_client.session.downloadSpeed) + mb_spd = float(self.tm_client.session.downloadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) elif self.type == 'upload_speed': - mb_spd = float(self.transmission_client.session.uploadSpeed) + mb_spd = float(self.tm_client.session.uploadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + elif self.type == 'active_torrents': + self._state = self.tm_client.session.activeTorrentCount diff --git a/homeassistant/components/sensor/travisci.py b/homeassistant/components/sensor/travisci.py new file mode 100644 index 00000000000..5f341760bb6 --- /dev/null +++ b/homeassistant/components/sensor/travisci.py @@ -0,0 +1,168 @@ +""" +This component provides HA sensor support for Travis CI framework. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.travisci/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_SCAN_INTERVAL, + CONF_MONITORED_CONDITIONS, STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['TravisPy==0.3.5'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Information provided by https://travis-ci.org/" +CONF_BRANCH = 'branch' +CONF_REPOSITORY = 'repository' + +DEFAULT_BRANCH_NAME = 'master' + +SCAN_INTERVAL = timedelta(seconds=30) + +# sensor_type [ description, unit, icon ] +SENSOR_TYPES = { + 'last_build_id': ['Last Build ID', '', 'mdi:account-card-details'], + 'last_build_duration': ['Last Build Duration', 'sec', 'mdi:timelapse'], + 'last_build_finished_at': ['Last Build Finished At', '', 'mdi:timetable'], + 'last_build_started_at': ['Last Build Started At', '', 'mdi:timetable'], + 'last_build_state': ['Last Build State', '', 'mdi:github-circle'], + 'state': ['State', '', 'mdi:github-circle'], + +} + +NOTIFICATION_ID = 'travisci' +NOTIFICATION_TITLE = 'Travis CI Sensor Setup' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_BRANCH, default=DEFAULT_BRANCH_NAME): cv.string, + vol.Optional(CONF_REPOSITORY, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Travis CI sensor.""" + from travispy import TravisPy + from travispy.errors import TravisError + + token = config.get(CONF_API_KEY) + repositories = config.get(CONF_REPOSITORY) + branch = config.get(CONF_BRANCH) + + try: + travis = TravisPy.github_auth(token) + user = travis.user() + + except TravisError as ex: + _LOGGER.error("Unable to connect to Travis CI service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + sensors = [] + + # non specificy repository selected, then show all associated + if not repositories: + all_repos = travis.repos(member=user.login) + repositories = [repo.slug for repo in all_repos] + + for repo in repositories: + if '/' not in repo: + repo = "{0}/{1}".format(user.login, repo) + + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + sensors.append( + TravisCISensor(travis, repo, user, branch, sensor_type)) + + add_devices(sensors, True) + return True + + +class TravisCISensor(Entity): + """Representation of a Travis CI sensor.""" + + def __init__(self, data, repo_name, user, branch, sensor_type): + """Initialize the sensor.""" + self._build = None + self._sensor_type = sensor_type + self._data = data + self._repo_name = repo_name + self._user = user + self._branch = branch + self._state = STATE_UNKNOWN + self._name = "{0} {1}".format(self._repo_name, + SENSOR_TYPES[self._sensor_type][0]) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + + if self._build and self._state is not STATE_UNKNOWN: + if self._user and self._sensor_type == 'state': + attrs['Owner Name'] = self._user.name + attrs['Owner Email'] = self._user.email + else: + attrs['Committer Name'] = self._build.commit.committer_name + attrs['Committer Email'] = self._build.commit.committer_email + attrs['Commit Branch'] = self._build.commit.branch + attrs['Committed Date'] = self._build.commit.committed_at + attrs['Commit SHA'] = self._build.commit.sha + + return attrs + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return SENSOR_TYPES[self._sensor_type][2] + + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug("Updating sensor %s", self._name) + + repo = self._data.repo(self._repo_name) + self._build = self._data.build(repo.last_build_id) + + if self._build: + if self._sensor_type == 'state': + branch_stats = \ + self._data.branch(self._branch, self._repo_name) + self._state = branch_stats.state + + else: + param = self._sensor_type.replace('last_build_', '') + self._state = getattr(self._build, param) diff --git a/homeassistant/components/sensor/uptime.py b/homeassistant/components/sensor/uptime.py new file mode 100644 index 00000000000..89c0fbffd8e --- /dev/null +++ b/homeassistant/components/sensor/uptime.py @@ -0,0 +1,78 @@ +""" +Component to retrieve uptime for Home Assistant. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uptime/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Uptime' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='days'): + vol.All(cv.string, vol.In(['hours', 'days'])) +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the uptime sensor platform.""" + name = config.get(CONF_NAME) + units = config.get(CONF_UNIT_OF_MEASUREMENT) + async_add_devices([UptimeSensor(name, units)], True) + + +class UptimeSensor(Entity): + """Representation of an uptime sensor.""" + + def __init__(self, name, units): + """Initialize the uptime sensor.""" + self._name = name + self._icon = 'mdi:clock' + self._units = units + self.initial = dt_util.now() + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to display in the front end.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement the value is expressed in.""" + return self._units + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the state of the sensor.""" + delta = dt_util.now() - self.initial + div_factor = 3600 + if self.unit_of_measurement == 'days': + div_factor *= 24 + delta = delta.total_seconds() / div_factor + self._state = round(delta, 2) + _LOGGER.debug("New value: %s", delta) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index aba889fcffd..f901bd27dca 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -53,6 +53,8 @@ class VeraSensor(VeraDevice, Entity): return self._temperature_units elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: return 'lux' + elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + return 'level' elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: return '%' elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: @@ -74,6 +76,8 @@ class VeraSensor(VeraDevice, Entity): elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: self.current_value = self.vera_device.light + elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + self.current_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: self.current_value = self.vera_device.humidity elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER: diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index e439691fd63..92b4e5b80b9 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -67,9 +67,9 @@ class XiaomiSensor(XiaomiDevice): if value is None: return False value = float(value) - if self._data_key == 'temperature' and value == 10000: + if self._data_key == 'temperature' and (value < -20 or value > 60): return False - elif self._data_key == 'humidity' and value == 0: + elif self._data_key == 'humidity' and (value <= 0 or value > 100): return False elif self._data_key == 'illumination' and value == 0: return False diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index ca4ab6bbff3..cd2847c1fa6 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -78,7 +78,7 @@ class TemperatureSensor(Sensor): @property def unit_of_measurement(self): - """Return the unit of measurement of this entityy.""" + """Return the unit of measurement of this entity.""" return self.hass.config.units.temperature_unit @property diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 9fd47d84fa0..1c7b3123c64 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -1,4 +1,5 @@ + foursquare: checkin: description: Check a user into a Foursquare venue @@ -542,41 +543,30 @@ input_boolean: description: Entity id of the input boolean to turn on example: 'input_boolean.notify_alerts' -wink: - pair_new_device: - description: Pair a new device to a Wink Hub. - - fields: - hub_name: - description: The name of the hub to pair a new device to. - example: 'My hub' - pairing_mode: - description: One of ["zigbee", "zwave", "zwave_exclusion", "zwave_network_rediscovery", "lutron", "bluetooth", "kidde"] - example: 'zigbee' - kidde_radio_code: - description: A string of 8 1s and 0s one for each dip switch on the kidde device left --> right = 1 --> 8 - example: '10101010' - - rename_wink_device: - description: Rename the provided device. - +homeassistant: + check_config: + description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log. + reload_core_config: + description: Reload the core configuration. + restart: + description: Restart the Home Assistant service. It is normal to get a "Failed to call service homeassistant/restart" message. + stop: + description: Stop the Home Assistant service. It is normal to get a "Failed to call service homeassistant/stop" message. + toggle: + description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: - description: The entity_id of the device to rename. - example: binary_sensor.front_door_opened - name: - description: The name to change it to. - example: back_door - - delete_wink_device: - description: Remove/unpair device from Wink. - + description: The entity_id of the device to toggle on/off + example: light.living_room + turn_on: + description: Generic service to turn devices on under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. fields: entity_id: - description: The entity_id of the device to delete. - - pull_newly_added_devices_from_wink: - description: Pull newly pair devices from Wink. - - refresh_state_from_wink: - description: Pull the latest states for every device. + description: The entity_id of the device to turn on + example: light.living_room + turn_off: + description: Generic service to turn devices off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services. + fields: + entity_id: + description: The entity_id of the device to turn off + example: light.living_room diff --git a/homeassistant/components/shiftr.py b/homeassistant/components/shiftr.py index 3fc25de5a16..67baa045b18 100644 --- a/homeassistant/components/shiftr.py +++ b/homeassistant/components/shiftr.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import state as state_helper -REQUIREMENTS = ['paho-mqtt==1.3.0'] +REQUIREMENTS = ['paho-mqtt==1.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py new file mode 100644 index 00000000000..854abdda7bc --- /dev/null +++ b/homeassistant/components/skybell.py @@ -0,0 +1,93 @@ +""" +Support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/skybell/ +""" +import logging + +from requests.exceptions import HTTPError, ConnectTimeout +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['skybellpy==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Data provided by Skybell.com" + +NOTIFICATION_ID = 'skybell_notification' +NOTIFICATION_TITLE = 'Skybell Sensor Setup' + +DOMAIN = 'skybell' +DEFAULT_CACHEDB = './skybell_cache.pickle' +DEFAULT_ENTITY_NAMESPACE = 'skybell' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Skybell component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + try: + from skybellpy import Skybell + + cache = hass.config.path(DEFAULT_CACHEDB) + skybell = Skybell(username=username, password=password, + get_devices=True, cache_path=cache) + + hass.data[DOMAIN] = skybell + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Skybell service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + return True + + +class SkybellDevice(Entity): + """A HA implementation for Skybell devices.""" + + def __init__(self, device): + """Initialize a sensor for Skybell device.""" + self._device = device + + @property + def should_poll(self): + """Return the polling state.""" + return True + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'device_id': self._device.device_id, + 'status': self._device.status, + 'location': self._device.location, + 'wifi_ssid': self._device.wifi_ssid, + 'wifi_status': self._device.wifi_status, + 'last_check_in': self._device.last_check_in, + 'motion_threshold': self._device.motion_threshold, + 'video_profile': self._device.video_profile, + } diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a53c6c5c01f..5bfea4eff0e 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -107,6 +107,7 @@ def async_setup(hass, config): """Handle calls to the switch services.""" target_switches = component.async_extract_from_service(service) + update_tasks = [] for switch in target_switches: if service.service == SERVICE_TURN_ON: yield from switch.async_turn_on() @@ -115,17 +116,9 @@ def async_setup(hass, config): else: yield from switch.async_turn_off() - update_tasks = [] - for switch in target_switches: if not switch.should_poll: continue - - update_coro = hass.async_add_job( - switch.async_update_ha_state(True)) - if hasattr(switch, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(switch.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index e8bd592cee8..4df8f792a4b 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.light import is_on, turn_on from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_LIGHTS from homeassistant.helpers.event import track_time_change from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify @@ -27,7 +27,6 @@ DEPENDENCIES = ['light'] _LOGGER = logging.getLogger(__name__) -CONF_LIGHTS = 'lights' CONF_START_TIME = 'start_time' CONF_STOP_TIME = 'stop_time' CONF_START_CT = 'start_colortemp' diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 5893b3419d5..962a56e4bb7 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE, STATE_UNKNOWN -REQUIREMENTS = ['fritzhome==1.0.2'] +REQUIREMENTS = ['fritzhome==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py index 74d3a2429eb..acb9af3cacb 100644 --- a/homeassistant/components/switch/hikvisioncam.py +++ b/homeassistant/components/switch/hikvisioncam.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hikvision==0.4'] +REQUIREMENTS = ['hikvision==1.2'] _LOGGING = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 94ac98c1737..9425b61f0e5 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -1,6 +1,5 @@ """Implements a RainMachine sprinkler controller for Home Assistant.""" -import asyncio from datetime import timedelta from logging import getLogger @@ -53,8 +52,7 @@ PLATFORM_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set this component up under its platform.""" import regenmaschine as rm @@ -114,7 +112,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): zone_run_time, device_name=rainmachine_device_name, )) - async_add_devices(entities) + add_devices(entities) except rm.exceptions.HTTPError as exc_info: _LOGGER.error('An HTTP error occurred while talking with RainMachine') _LOGGER.debug(exc_info) diff --git a/homeassistant/components/switch/skybell.py b/homeassistant/components/switch/skybell.py new file mode 100644 index 00000000000..726a5e7446e --- /dev/null +++ b/homeassistant/components/switch/skybell.py @@ -0,0 +1,75 @@ +""" +Switch support for the Skybell HD Doorbell. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.skybell/ +""" +import logging + +import voluptuous as vol + + +from homeassistant.components.skybell import ( + DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['skybell'] + +_LOGGER = logging.getLogger(__name__) + +# Switch types: Name +SWITCH_TYPES = { + 'do_not_disturb': ['Do Not Disturb'], + 'motion_sensor': ['Motion Sensor'], +} + +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(SWITCH_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 switch_type in config.get(CONF_MONITORED_CONDITIONS): + for device in skybell.get_devices(): + sensors.append(SkybellSwitch(device, switch_type)) + + add_devices(sensors, True) + + +class SkybellSwitch(SkybellDevice, SwitchDevice): + """A switch implementation for Skybell devices.""" + + def __init__(self, device, switch_type): + """Initialize a light for a Skybell device.""" + super().__init__(device) + self._switch_type = switch_type + self._name = "{0} {1}".format(self._device.name, + SWITCH_TYPES[self._switch_type][0]) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def turn_on(self, **kwargs): + """Turn on the switch.""" + setattr(self._device, self._switch_type, True) + + def turn_off(self, **kwargs): + """Turn on the switch.""" + setattr(self._device, self._switch_type, False) + + @property + def is_on(self): + """Return true if device is on.""" + return getattr(self._device, self._switch_type) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 9b73d668c8c..2d50363bb2b 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -19,7 +19,6 @@ 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.restore_state import async_get_last_state from homeassistant.helpers.script import Script _LOGGER = logging.getLogger(__name__) @@ -71,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No switches added") return False - async_add_devices(switches, True) + async_add_devices(switches) return True @@ -96,10 +95,6 @@ class SwitchTemplate(SwitchDevice): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - state = yield from async_get_last_state(self.hass, self.entity_id) - if state: - self._state = state.state == STATE_ON - @callback def template_switch_state_listener(entity, old_state, new_state): """Handle target device state changes.""" diff --git a/homeassistant/components/switch/tesla.py b/homeassistant/components/switch/tesla.py new file mode 100644 index 00000000000..7de0c417d56 --- /dev/null +++ b/homeassistant/components/switch/tesla.py @@ -0,0 +1,53 @@ +""" +Support for Tesla charger switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tesla/ +""" +import logging + +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_ON, STATE_OFF + +_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla switch platform.""" + devices = [ChargerSwitch(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['switch']] + add_devices(devices, True) + + +class ChargerSwitch(TeslaDevice, SwitchDevice): + """Representation of a Tesla charger switch.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the switch.""" + self._state = None + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def turn_on(self, **kwargs): + """Send the on command.""" + _LOGGER.debug("Enable charging: %s", self._name) + self.tesla_device.start_charge() + + def turn_off(self, **kwargs): + """Send the off command.""" + _LOGGER.debug("Disable charging for: %s", self._name) + self.tesla_device.stop_charge() + + @property + def is_on(self): + """Get whether the switch is in on state.""" + return self._state == STATE_ON + + def update(self): + """Updating state of the switch.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_ON if self.tesla_device.is_charging() \ + else STATE_OFF diff --git a/homeassistant/components/switch/toon.py b/homeassistant/components/switch/toon.py new file mode 100644 index 00000000000..656d175ff3a --- /dev/null +++ b/homeassistant/components/switch/toon.py @@ -0,0 +1,77 @@ +""" +Support for Eneco Slimmer stekkers (Smart Plugs). + +This provides controlls for the z-wave smart plugs Toon can control. +""" +import logging + +from homeassistant.components.switch import SwitchDevice +import homeassistant.components.toon as toon_main + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup discovered Smart Plugs.""" + _toon_main = hass.data[toon_main.TOON_HANDLE] + switch_items = [] + for plug in _toon_main.toon.smartplugs: + switch_items.append(EnecoSmartPlug(hass, plug)) + + add_devices_callback(switch_items) + + +class EnecoSmartPlug(SwitchDevice): + """Representation of a Smart Plug.""" + + def __init__(self, hass, plug): + """Initialize the Smart Plug.""" + self.smartplug = plug + self.toon_data_store = hass.data[toon_main.TOON_HANDLE] + + @property + def should_poll(self): + """No polling needed with subscriptions.""" + return True + + @property + def unique_id(self): + """Return the ID of this switch.""" + return self.smartplug.device_uuid + + @property + def name(self): + """Return the name of the switch if any.""" + return self.smartplug.name + + @property + def current_power_w(self): + """Current power usage in W.""" + return self.toon_data_store.get_data('current_power', self.name) + + @property + def today_energy_kwh(self): + """Today total energy usage in kWh.""" + return self.toon_data_store.get_data('today_energy', self.name) + + @property + def is_on(self): + """Return true if switch is on. Standby is on.""" + return self.toon_data_store.get_data('current_state', self.name) + + @property + def available(self): + """True if switch is available.""" + return self.smartplug.can_toggle + + def turn_on(self, **kwargs): + """Turn the switch on.""" + return self.smartplug.turn_on() + + def turn_off(self): + """Turn the switch off.""" + return self.smartplug.turn_off() + + def update(self): + """Update state.""" + self.toon_data_store.update() diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 4b83cedc4c1..df0050ff979 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_VOLTAGE) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyHS100==0.2.4.2'] +REQUIREMENTS = ['pyHS100==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -45,15 +45,8 @@ class SmartPlugSwitch(SwitchDevice): def __init__(self, smartplug, name): """Initialize the switch.""" self.smartplug = smartplug - - # Use the name set on the device if not set - if name is None: - self._name = self.smartplug.alias - else: - self._name = name - + self._name = None self._state = None - _LOGGER.debug("Setting up TP-Link Smart Plug") # Set up emeter cache self._emeter_params = {} @@ -82,11 +75,14 @@ class SmartPlugSwitch(SwitchDevice): def update(self): """Update the TP-Link switch's state.""" - from pyHS100 import SmartPlugException + from pyHS100 import SmartDeviceException try: self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON + if self._name is None: + self._name = self.smartplug.alias + if self.smartplug.has_emeter: emeter_readings = self.smartplug.get_emeter_realtime() @@ -107,5 +103,5 @@ class SmartPlugSwitch(SwitchDevice): # device returned no daily history pass - except (SmartPlugException, OSError) as ex: + except (SmartDeviceException, OSError) as ex: _LOGGER.warning('Could not read state for %s: %s', self.name, ex) diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index aa33c2f7132..8bd4c9fa53b 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -5,12 +5,15 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.wink/ """ import asyncio +import logging from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.helpers.entity import ToggleEntity DEPENDENCIES = ['wink'] +_LOGGER = logging.getLogger(__name__) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -24,10 +27,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _id = switch.object_id() + switch.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkToggleDevice(switch, hass)]) - for switch in pywink.get_sirens(): - _id = switch.object_id() + switch.name() - if _id not in hass.data[DOMAIN]['unique_ids']: - add_devices([WinkToggleDevice(switch, hass)]) for sprinkler in pywink.get_sprinklers(): _id = sprinkler.object_id() + sprinkler.name() if _id not in hass.data[DOMAIN]['unique_ids']: diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py new file mode 100644 index 00000000000..a7cb8681791 --- /dev/null +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -0,0 +1,168 @@ +""" +Support for Xiaomi Smart WiFi Socket and Smart Power Strip. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/switch.xiaomi_miio/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA, ) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Miio Switch' +PLATFORM = 'xiaomi_miio' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-mirobo==0.2.0'] + +ATTR_POWER = 'power' +ATTR_TEMPERATURE = 'temperature' +ATTR_LOAD_POWER = 'load_power' +ATTR_MODEL = 'model' +SUCCESS = ['ok'] + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the switch from config.""" + from mirobo import Plug, DeviceException + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + plug = Plug(host, token) + device_info = plug.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) + + xiaomi_plug_switch = XiaomiPlugSwitch(name, plug, device_info) + except DeviceException: + raise PlatformNotReady + + async_add_devices([xiaomi_plug_switch], update_before_add=True) + + +class XiaomiPlugSwitch(SwitchDevice): + """Representation of a Xiaomi Plug.""" + + def __init__(self, name, plug, device_info): + """Initialize the plug switch.""" + self._name = name + self._icon = 'mdi:power-socket' + self._device_info = device_info + + self._plug = plug + self._state = None + self._state_attrs = { + ATTR_TEMPERATURE: None, + ATTR_LOAD_POWER: None, + ATTR_MODEL: self._device_info.raw['model'], + } + self._skip_update = False + + @property + def should_poll(self): + """Poll the plug.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device if any.""" + return self._icon + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a plug command handling error messages.""" + from mirobo import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from plug: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the plug on.""" + result = yield from self._try_command( + "Turning the plug on failed.", self._plug.on) + + if result: + self._state = True + self._skip_update = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the plug off.""" + result = yield from self._try_command( + "Turning the plug off failed.", self._plug.off) + + if result: + self._state = False + self._skip_update = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = yield from self.hass.async_add_job(self._plug.status) + _LOGGER.debug("Got new state: %s", state) + + self._state = state.is_on + self._state_attrs.update({ + ATTR_TEMPERATURE: state.temperature, + ATTR_LOAD_POWER: state.load_power, + }) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index de9c0f4ede3..896dbdc4399 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==8.0.0'] +REQUIREMENTS = ['python-telegram-bot==8.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py index 08006310dc7..915ebb6d4c3 100644 --- a/homeassistant/components/tesla.py +++ b/homeassistant/components/tesla.py @@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -REQUIREMENTS = ['teslajsonpy==0.0.11'] +REQUIREMENTS = ['teslajsonpy==0.0.17'] DOMAIN = 'tesla' @@ -39,7 +39,7 @@ NOTIFICATION_ID = 'tesla_integration_notification' NOTIFICATION_TITLE = 'Tesla integration setup' TESLA_COMPONENTS = [ - 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker', 'switch' ] @@ -55,7 +55,8 @@ def setup(hass, base_config): if hass.data.get(DOMAIN) is None: try: hass.data[DOMAIN] = { - 'controller': teslaApi(email, password, update_interval), + 'controller': teslaApi( + email, password, update_interval), 'devices': defaultdict(list) } _LOGGER.debug("Connected to the Tesla API.") diff --git a/homeassistant/components/toon.py b/homeassistant/components/toon.py new file mode 100644 index 00000000000..d873c42e815 --- /dev/null +++ b/homeassistant/components/toon.py @@ -0,0 +1,149 @@ +""" +Toon van Eneco Support. + +This provides a component for the rebranded Quby thermostat as provided by +Eneco. +""" +import logging +from datetime import datetime, timedelta +import voluptuous as vol + +# Import the device class from the component that you want to support +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +# Home Assistant depends on 3rd party packages for API specific code. +REQUIREMENTS = ['toonlib==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +DOMAIN = 'toon' +TOON_HANDLE = 'toon_handle' +CONF_GAS = 'gas' +DEFAULT_GAS = True +CONF_SOLAR = 'solar' +DEFAULT_SOLAR = False + +# Validation of the user's configuration +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_GAS, default=DEFAULT_GAS): cv.boolean, + vol.Optional(CONF_SOLAR, default=DEFAULT_SOLAR): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Setup toon.""" + from toonlib import InvalidCredentials + gas = config['toon']['gas'] + solar = config['toon']['solar'] + + try: + hass.data[TOON_HANDLE] = ToonDataStore(config['toon']['username'], + config['toon']['password'], + gas, + solar) + except InvalidCredentials: + return False + + # Load all platforms + for platform in ('climate', 'sensor', 'switch'): + load_platform(hass, platform, DOMAIN, {}, config) + + # Initialization successfull + return True + + +class ToonDataStore: + """An object to store the toon data.""" + + def __init__(self, username, password, gas=DEFAULT_GAS, + solar=DEFAULT_SOLAR): + """Initialize toon.""" + from toonlib import Toon + + # Creating the class + + toon = Toon(username, password) + + self.toon = toon + self.gas = gas + self.solar = solar + self.data = {} + + self.last_update = datetime.min + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update toon data.""" + self.last_update = datetime.now() + + self.data['power_current'] = self.toon.power.value + self.data['power_today'] = round( + (float(self.toon.power.daily_usage) + + float(self.toon.power.daily_usage_low)) / 1000, 2) + self.data['temp'] = self.toon.temperature + + if self.toon.thermostat_state: + self.data['state'] = self.toon.thermostat_state.name + else: + self.data['state'] = 'Manual' + + self.data['setpoint'] = float( + self.toon.thermostat_info.current_set_point) / 100 + self.data['gas_current'] = self.toon.gas.value + self.data['gas_today'] = round(float(self.toon.gas.daily_usage) / + 1000, 2) + + for plug in self.toon.smartplugs: + self.data[plug.name] = {'current_power': plug.current_usage, + 'today_energy': round( + float(plug.daily_usage) / 1000, 2), + 'current_state': plug.current_state, + 'is_connected': plug.is_connected} + + self.data['solar_maximum'] = self.toon.solar.maximum + self.data['solar_produced'] = self.toon.solar.produced + self.data['solar_value'] = self.toon.solar.value + self.data['solar_average_produced'] = self.toon.solar.average_produced + self.data['solar_meter_reading_low_produced'] = \ + self.toon.solar.meter_reading_low_produced + self.data['solar_meter_reading_produced'] = \ + self.toon.solar.meter_reading_produced + self.data['solar_daily_cost_produced'] = \ + self.toon.solar.daily_cost_produced + + for detector in self.toon.smokedetectors: + value = '{}_smoke_detector'.format(detector.name) + self.data[value] = {'smoke_detector': detector.battery_level, + 'device_type': detector.device_type, + 'is_connected': detector.is_connected, + 'last_connected_change': + detector.last_connected_change} + + def set_state(self, state): + """Push a new state to the Toon unit.""" + self.toon.thermostat_state = state + + def set_temp(self, temp): + """Push a new temperature to the Toon unit.""" + self.toon.thermostat = temp + + def get_data(self, data_id, plug_name=None): + """Get the cached data.""" + data = {'error': 'no data'} + if plug_name: + if data_id in self.data[plug_name]: + data = self.data[plug_name][data_id] + else: + if data_id in self.data: + data = self.data[data_id] + return data diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index ef4d7fceed8..a24305c7fd4 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,7 +16,11 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==2.2.2'] +REQUIREMENTS = ['pytradfri==3.0', + 'DTLSSocket==0.1.3', + 'https://github.com/chrysn/aiocoap/archive/' + '3286f48f0b949901c8b5c04c0719dc54ab63d431.zip' + '#aiocoap==0.3'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index a3d96362433..32839c08115 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level _LOGGER = logging.getLogger(__name__) @@ -200,13 +200,7 @@ def async_setup(hass, config): yield from getattr(vacuum, method['method'])(**params) if not vacuum.should_poll: continue - - update_coro = hass.async_add_job( - vacuum.async_update_ha_state(True)) - if hasattr(vacuum, 'async_update'): - update_tasks.append(update_coro) - else: - yield from update_coro + update_tasks.append(vacuum.async_update_ha_state(True)) if update_tasks: yield from asyncio.wait(update_tasks, loop=hass.loop) diff --git a/homeassistant/components/vacuum/dyson.py b/homeassistant/components/vacuum/dyson.py index a784b161d1c..476e347055a 100644 --- a/homeassistant/components/vacuum/dyson.py +++ b/homeassistant/components/vacuum/dyson.py @@ -14,7 +14,7 @@ from homeassistant.components.vacuum import (SUPPORT_BATTERY, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level ATTR_FULL_CLEAN_TYPE = "full_clean_type" ATTR_CLEAN_ID = "clean_id" diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index 67ee6fb15c7..9929ae46e09 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -18,7 +18,7 @@ from homeassistant.components.vacuum import ( VacuumDevice) from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import callback -from homeassistant.util.icon import icon_for_battery_level +from homeassistant.helpers.icon import icon_for_battery_level _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 5747dd1dc9e..37d7be38f9d 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -363,23 +363,17 @@ class MiroboVacuum(VacuumDevice): self._vacuum.manual_control_once, velocity=velocity, rotation=rotation, duration=duration) - @asyncio.coroutine - def async_update(self): + def update(self): """Fetch state from the device.""" from mirobo import DeviceException try: - state = yield from self.hass.async_add_job(self._vacuum.status) - _LOGGER.debug("Got new state from the vacuum: %s", state.data) + state = self._vacuum.status() self.vacuum_state = state - self.consumable_state = yield from self.hass.async_add_job( - self._vacuum.consumable_status) - self.clean_history = yield from self.hass.async_add_job( - self._vacuum.clean_history) + self.consumable_state = self._vacuum.consumable_status() + self.clean_history = self._vacuum.clean_history() self._is_on = state.is_on self._available = True except OSError as exc: _LOGGER.error("Got OSError while fetching the state: %s", exc) - # self._available = False except DeviceException as exc: _LOGGER.warning("Got exception while fetching the state: %s", exc) - # self._available = False diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 7a018a6502d..a26e1efb553 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -8,16 +8,15 @@ import logging from collections import defaultdict import voluptuous as vol - from requests.exceptions import RequestException from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util import (convert, slugify) +from homeassistant.util import convert, slugify from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.const import ( ATTR_ARMED, ATTR_BATTERY_LEVEL, ATTR_LAST_TRIP_TIME, ATTR_TRIPPED, - EVENT_HOMEASSISTANT_STOP) + EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['pyvera==0.2.37'] @@ -29,8 +28,6 @@ DOMAIN = 'vera' VERA_CONTROLLER = None CONF_CONTROLLER = 'vera_controller_url' -CONF_EXCLUDE = 'exclude' -CONF_LIGHTS = 'lights' VERA_ID_FORMAT = '{}_{}' diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index 6442a0342a5..a50e160cddb 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -142,8 +142,15 @@ class OpenWeatherMapWeather(WeatherEntity): def update(self): """Get the latest data from OWM and updates the states.""" - self._owm.update() - self._owm.update_forecast() + from pyowm.exceptions.api_call_error import APICallError + + try: + self._owm.update() + self._owm.update_forecast() + except APICallError: + _LOGGER.error("Exception when calling OWM web API to update data") + return + self.data = self._owm.data self.forecast_data = self._owm.forecast_data @@ -172,8 +179,15 @@ class WeatherData(object): @Throttle(MIN_TIME_BETWEEN_FORECAST_UPDATES) def update_forecast(self): """Get the lastest forecast from OpenWeatherMap.""" - fcd = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude) + from pyowm.exceptions.api_call_error import APICallError + + try: + fcd = self.owm.three_hours_forecast_at_coords( + self.latitude, self.longitude) + except APICallError: + _LOGGER.error("Exception when calling OWM web API " + "to update forecast") + return if fcd is None: _LOGGER.warning("Failed to fetch forecast data from OWM") diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink/__init__.py similarity index 73% rename from homeassistant/components/wink.py rename to homeassistant/components/wink/__init__.py index 0b3a006a8d2..2defe73a6bf 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink/__init__.py @@ -4,6 +4,7 @@ Support for Wink hubs. For more details about this component, please refer to the documentation at https://home-assistant.io/components/wink/ """ +import asyncio import logging import time import json @@ -20,12 +21,14 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.const import ( ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, __version__, ATTR_ENTITY_ID) + EVENT_HOMEASSISTANT_STOP, __version__, ATTR_ENTITY_ID, + STATE_ON, STATE_OFF) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['python-wink==1.6.0', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.7.0', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -66,7 +69,26 @@ SERVICE_REFRESH_STATES = 'refresh_state_from_wink' SERVICE_RENAME_DEVICE = 'rename_wink_device' SERVICE_DELETE_DEVICE = 'delete_wink_device' SERVICE_SET_PAIRING_MODE = 'pair_new_device' +SERVICE_SET_CHIME_VOLUME = "set_chime_volume" +SERVICE_SET_SIREN_VOLUME = "set_siren_volume" +SERVICE_ENABLE_CHIME = "enable_chime" +SERVICE_SET_SIREN_TONE = "set_siren_tone" +SERVICE_SET_AUTO_SHUTOFF = "siren_set_auto_shutoff" +SERVICE_SIREN_STROBE_ENABLED = "set_siren_strobe_enabled" +SERVICE_CHIME_STROBE_ENABLED = "set_chime_strobe_enabled" +SERVICE_ENABLE_SIREN = "enable_siren" +ATTR_VOLUME = "volume" +ATTR_TONE = "tone" +ATTR_ENABLED = "enabled" +ATTR_AUTO_SHUTOFF = "auto_shutoff" + +VOLUMES = ["low", "medium", "high"] +TONES = ["doorbell", "fur_elise", "doorbell_extended", "alert", + "william_tell", "rondo_alla_turca", "police_siren", + "evacuation", "beep_beep", "beep"] +CHIME_TONES = TONES + ["inactive"] +AUTO_SHUTOFF_TIMES = [None, -1, 30, 60, 120] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -82,7 +104,6 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) - RENAME_DEVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_NAME): cv.string @@ -98,6 +119,36 @@ SET_PAIRING_MODE_SCHEMA = vol.Schema({ vol.Optional(ATTR_KIDDE_RADIO_CODE): cv.string }, extra=vol.ALLOW_EXTRA) +SET_VOLUME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VOLUME): vol.In(VOLUMES) +}) + +SET_SIREN_TONE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_TONE): vol.In(TONES) +}) + +SET_CHIME_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_TONE): vol.In(CHIME_TONES) +}) + +SET_AUTO_SHUTOFF_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AUTO_SHUTOFF): vol.In(AUTO_SHUTOFF_TIMES) +}) + +SET_STROBE_ENABLED_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENABLED): cv.boolean +}) + +ENABLED_SIREN_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENABLED): cv.boolean +}) + WINK_COMPONENTS = [ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate', 'fan', 'alarm_control_panel', 'scene' @@ -204,7 +255,7 @@ def setup(hass, config): from pubnubsubhandler import PubNubSubscriptionHandler descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')).get(DOMAIN) + os.path.join(os.path.dirname(__file__), 'services.yaml')) if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = { @@ -434,11 +485,110 @@ def setup(hass, config): descriptions.get(SERVICE_SET_PAIRING_MODE), schema=SET_PAIRING_MODE_SCHEMA) + def service_handle(service): + """Handler for services.""" + entity_ids = service.data.get('entity_id') + all_sirens = [] + for switch in hass.data[DOMAIN]['entities']['switch']: + if isinstance(switch, WinkSirenDevice): + all_sirens.append(switch) + sirens_to_set = [] + if entity_ids is None: + sirens_to_set = all_sirens + else: + for siren in all_sirens: + if siren.entity_id in entity_ids: + sirens_to_set.append(siren) + + for siren in sirens_to_set: + if (service.service != SERVICE_SET_AUTO_SHUTOFF and + service.service != SERVICE_ENABLE_SIREN and + siren.wink.device_manufacturer() != 'dome'): + _LOGGER.error("Service only valid for Dome sirens.") + return + + if service.service == SERVICE_ENABLE_SIREN: + siren.wink.set_state(service.data.get(ATTR_ENABLED)) + elif service.service == SERVICE_SET_AUTO_SHUTOFF: + siren.wink.set_auto_shutoff( + service.data.get(ATTR_AUTO_SHUTOFF)) + elif service.service == SERVICE_SET_CHIME_VOLUME: + siren.wink.set_chime_volume(service.data.get(ATTR_VOLUME)) + elif service.service == SERVICE_SET_SIREN_VOLUME: + siren.wink.set_siren_volume(service.data.get(ATTR_VOLUME)) + elif service.service == SERVICE_SET_SIREN_TONE: + siren.wink.set_siren_sound(service.data.get(ATTR_TONE)) + elif service.service == SERVICE_ENABLE_CHIME: + siren.wink.set_chime(service.data.get(ATTR_TONE)) + elif service.service == SERVICE_SIREN_STROBE_ENABLED: + siren.wink.set_siren_strobe_enabled( + service.data.get(ATTR_ENABLED)) + elif service.service == SERVICE_CHIME_STROBE_ENABLED: + siren.wink.set_chime_strobe_enabled( + service.data.get(ATTR_ENABLED)) + # Load components for the devices in Wink that we support for wink_component in WINK_COMPONENTS: hass.data[DOMAIN]['entities'][wink_component] = [] discovery.load_platform(hass, wink_component, DOMAIN, {}, config) + component = EntityComponent(_LOGGER, DOMAIN, hass) + + sirens = [] + has_dome_siren = False + for siren in pywink.get_sirens(): + if siren.device_manufacturer() == "dome": + has_dome_siren = True + _id = siren.object_id() + siren.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + sirens.append(WinkSirenDevice(siren, hass)) + + if sirens: + + hass.services.register(DOMAIN, SERVICE_SET_AUTO_SHUTOFF, + service_handle, + descriptions.get(SERVICE_SET_AUTO_SHUTOFF), + schema=SET_AUTO_SHUTOFF_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_ENABLE_SIREN, + service_handle, + descriptions.get(SERVICE_ENABLE_SIREN), + schema=ENABLED_SIREN_SCHEMA) + + if has_dome_siren: + + hass.services.register(DOMAIN, SERVICE_SET_SIREN_TONE, + service_handle, + descriptions.get(SERVICE_SET_SIREN_TONE), + schema=SET_SIREN_TONE_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_ENABLE_CHIME, + service_handle, + descriptions.get(SERVICE_ENABLE_CHIME), + schema=SET_CHIME_MODE_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_SIREN_VOLUME, + service_handle, + descriptions.get(SERVICE_SET_SIREN_VOLUME), + schema=SET_VOLUME_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_CHIME_VOLUME, + service_handle, + descriptions.get(SERVICE_SET_CHIME_VOLUME), + schema=SET_VOLUME_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SIREN_STROBE_ENABLED, + service_handle, + descriptions.get(SERVICE_SIREN_STROBE_ENABLED), + schema=SET_STROBE_ENABLED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_CHIME_STROBE_ENABLED, + service_handle, + descriptions.get(SERVICE_CHIME_STROBE_ENABLED), + schema=SET_STROBE_ENABLED_SCHEMA) + + component.add_entities(sirens) + return True @@ -594,3 +744,59 @@ class WinkDevice(Entity): if hasattr(self.wink, 'tamper_detected'): return self.wink.tamper_detected() return None + + +class WinkSirenDevice(WinkDevice): + """Representation of a Wink siren device.""" + + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + self.hass.data[DOMAIN]['entities']['switch'].append(self) + + @property + def state(self): + """Return sirens state.""" + if self.wink.state(): + return STATE_ON + return STATE_OFF + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:bell-ring" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = super(WinkSirenDevice, self).device_state_attributes + + auto_shutoff = self.wink.auto_shutoff() + if auto_shutoff is not None: + attributes["auto_shutoff"] = auto_shutoff + + siren_volume = self.wink.siren_volume() + if siren_volume is not None: + attributes["siren_volume"] = siren_volume + + chime_volume = self.wink.chime_volume() + if chime_volume is not None: + attributes["chime_volume"] = chime_volume + + strobe_enabled = self.wink.strobe_enabled() + if strobe_enabled is not None: + attributes["siren_strobe_enabled"] = strobe_enabled + + chime_strobe_enabled = self.wink.chime_strobe_enabled() + if chime_strobe_enabled is not None: + attributes["chime_strobe_enabled"] = chime_strobe_enabled + + siren_sound = self.wink.siren_sound() + if siren_sound is not None: + attributes["siren_sound"] = siren_sound + + chime_mode = self.wink.chime_mode() + if chime_mode is not None: + attributes["chime_mode"] = chime_mode + + return attributes diff --git a/homeassistant/components/wink/services.yaml b/homeassistant/components/wink/services.yaml new file mode 100644 index 00000000000..29952af4c81 --- /dev/null +++ b/homeassistant/components/wink/services.yaml @@ -0,0 +1,122 @@ +pair_new_device: + description: Pair a new device to a Wink Hub. + + fields: + hub_name: + description: The name of the hub to pair a new device to. + example: 'My hub' + pairing_mode: + description: One of ["zigbee", "zwave", "zwave_exclusion", "zwave_network_rediscovery", "lutron", "bluetooth", "kidde"] + example: 'zigbee' + kidde_radio_code: + description: 'A string of 8 1s and 0s one for each dip switch on the kidde device left --> right = 1 --> 8' + example: '10101010' + +rename_wink_device: + description: Rename the provided device. + + fields: + entity_id: + description: The entity_id of the device to rename. + example: binary_sensor.front_door_opened + name: + description: The name to change it to. + example: back_door + +delete_wink_device: + description: Remove/unpair device from Wink. + + fields: + entity_id: + description: The entity_id of the device to delete. + +pull_newly_added_devices_from_wink: + description: Pull newly pair devices from Wink. + +refresh_state_from_wink: + description: Pull the latest states for every device. + +set_siren_volume: + description: Set the volume of the siren for a Dome siren/chime. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + volume: + description: Volume level. One of ["low", "medium", "high"] + example: "high" + +enable_chime: + description: Enable the chime of a Dome siren with the provided sound. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + tone: + description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"] + example: "doorbell" + +set_siren_tone: + description: Set the sound to use when the siren is enabled. (This doesn't enable the siren) + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + tone: + description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"] + example: "alert" + +siren_set_auto_shutoff: + description: How long to sound the siren before turning off. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + auto_shutoff: + description: The time in seconds to sound the siren. One of [None, -1, 30, 60, 120] (None and -1 are forever. Use None for gocontrol, and -1 for Dome) + example: 60 + +set_siren_strobe_enabled: + description: Enable or disable the strobe light when the siren is sounding. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + enabled: + description: "True or False" + +set_chime_strobe_enabled: + description: Enable or disable the strobe light when the chime is sounding. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + enabled: + description: "True or False" + +enable_siren: + description: Enable/disable the siren. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + enabled: + description: "True or False" + +set_chime_volume: + description: Set the volume of the chime for a Dome siren/chime. + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'switch.dome_siren' + volume: + description: Volume level. One of ["low", "medium", "high"] + example: "low" diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index f786faf853a..700018ac29c 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -6,9 +6,9 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.components.discovery import SERVICE_XIAOMI_GW from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, - CONF_MAC) + CONF_MAC, CONF_HOST, CONF_PORT) -REQUIREMENTS = ['PyXiaomiGateway==0.5.1'] +REQUIREMENTS = ['PyXiaomiGateway==0.5.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' @@ -24,30 +24,36 @@ def _validate_conf(config): """Validate a list of devices definitions.""" res_config = [] for gw_conf in config: + for _conf in gw_conf.keys(): + if _conf not in [CONF_MAC, CONF_HOST, CONF_PORT, 'key']: + raise vol.Invalid('{} is not a valid config parameter'. + format(_conf)) + res_gw_conf = {'sid': gw_conf.get(CONF_MAC)} if res_gw_conf['sid'] is not None: res_gw_conf['sid'] = res_gw_conf['sid'].replace(":", "").lower() if len(res_gw_conf['sid']) != 12: raise vol.Invalid('Invalid mac address', gw_conf.get(CONF_MAC)) key = gw_conf.get('key') + if key is None: _LOGGER.warning( 'Gateway Key is not provided.' ' Controlling gateway device will not be possible.') elif len(key) != 16: - raise vol.Invalid('Invalid key %s.' - ' Key must be 16 characters', key) + raise vol.Invalid('Invalid key {}.' + ' Key must be 16 characters'.format(key)) res_gw_conf['key'] = key - host = gw_conf.get('host') + host = gw_conf.get(CONF_HOST) if host is not None: - res_gw_conf['host'] = host - res_gw_conf['port'] = gw_conf.get('port', 9898) + res_gw_conf[CONF_HOST] = host + res_gw_conf['port'] = gw_conf.get(CONF_PORT, 9898) _LOGGER.warning( 'Static address (%s:%s) of the gateway provided. ' 'Discovery of this host will be skipped.', - res_gw_conf['host'], res_gw_conf['port']) + res_gw_conf[CONF_HOST], res_gw_conf[CONF_PORT]) res_config.append(res_gw_conf) return res_config diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 55fb0e41cb2..3cd9446dc4f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -14,13 +14,14 @@ from homeassistant import const as ha_const from homeassistant.helpers import discovery, entity from homeassistant.util import slugify -REQUIREMENTS = ['bellows==0.3.4'] +REQUIREMENTS = ['bellows==0.4.0'] DOMAIN = 'zha' -CONF_USB_PATH = 'usb_path' +CONF_BAUDRATE = 'baudrate' CONF_DATABASE = 'database_path' CONF_DEVICE_CONFIG = 'device_config' +CONF_USB_PATH = 'usb_path' DATA_DEVICE_CONFIG = 'zha_device_config' DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ @@ -30,6 +31,7 @@ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ CONF_USB_PATH: cv.string, + vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, CONF_DATABASE: cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}), @@ -41,9 +43,9 @@ ATTR_DURATION = 'duration' SERVICE_PERMIT = 'permit' SERVICE_DESCRIPTIONS = { SERVICE_PERMIT: { - "description": "Allow nodes to join the Zigbee network", + "description": "Allow nodes to join the ZigBee network", "fields": { - "duration": { + ATTR_DURATION: { "description": "Time to permit joins, in seconds", "example": "60", }, @@ -81,7 +83,8 @@ def async_setup(hass, config): ezsp_ = bellows.ezsp.EZSP() usb_path = config[DOMAIN].get(CONF_USB_PATH) - yield from ezsp_.connect(usb_path) + baudrate = config[DOMAIN].get(CONF_BAUDRATE) + yield from ezsp_.connect(usb_path, baudrate) database = config[DOMAIN].get(CONF_DATABASE) APPLICATION_CONTROLLER = ControllerApplication(ezsp_, database) @@ -132,6 +135,10 @@ class ApplicationListener: """Handle device leaving the network.""" pass + def device_removed(self, device): + """Handle device being removed from the network.""" + pass + @asyncio.coroutine def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 6be0e776f3f..89289378c76 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -96,6 +96,9 @@ history: # View all events in a logbook logbook: +# Enables a map showing the location of tracked devices +map: + # Track the sun sun: @@ -120,7 +123,7 @@ http_password: welcome PACKAGES_CONFIG_SCHEMA = vol.Schema({ cv.slug: vol.Schema( # Package names are slugs - {cv.slug: vol.Any(dict, list)}) # Only slugs for component names + {cv.slug: vol.Any(dict, list, None)}) # Only slugs for component names }) CUSTOMIZE_CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/const.py b/homeassistant/const.py index 95a3c63bb8f..ddb7114dbca 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 55 -PATCH_VERSION = '2' +MINOR_VERSION = 56 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -113,6 +113,7 @@ CONF_ID = 'id' CONF_IP_ADDRESS = 'ip_address' CONF_LATITUDE = 'latitude' CONF_LONGITUDE = 'longitude' +CONF_LIGHTS = 'lights' CONF_MAC = 'mac' CONF_METHOD = 'method' CONF_MINIMUM = 'minimum' @@ -144,6 +145,7 @@ CONF_SCAN_INTERVAL = 'scan_interval' CONF_SENDER = 'sender' CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' +CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' CONF_SSL = 'ssl' CONF_STATE = 'state' @@ -278,6 +280,9 @@ MASS_KILOGRAMS = 'kg' # type: str MASS_OUNCES = 'oz' # type: str MASS_POUNDS = 'lb' # type: str +# UV Index units +UNIT_UV_INDEX = 'UV index' # type: str + # Contains the information that is discovered ATTR_DISCOVERED = 'discovered' diff --git a/homeassistant/core.py b/homeassistant/core.py index a8704869f21..e7f4f8758f8 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -30,7 +30,7 @@ from homeassistant.const import ( EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, __version__) -from homeassistant.loader import Components +from homeassistant import loader from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) from homeassistant.util.async import ( @@ -129,7 +129,8 @@ class HomeAssistant(object): self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) self.config = Config() # type: Config - self.components = Components(self) + self.components = loader.Components(self) + self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. self.data = {} self.state = CoreState.not_running diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 29e2a6260fd..239aaea64a0 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -1,5 +1,6 @@ """Helper for aiohttp webclient stuff.""" import asyncio +import ssl import sys import aiohttp @@ -7,10 +8,11 @@ from aiohttp.hdrs import USER_AGENT, CONTENT_TYPE from aiohttp import web from aiohttp.web_exceptions import HTTPGatewayTimeout, HTTPBadGateway import async_timeout +import certifi from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE -from homeassistant.const import __version__ +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ +from homeassistant.loader import bind_hass DATA_CONNECTOR = 'aiohttp_connector' DATA_CONNECTOR_NOTVERIFY = 'aiohttp_connector_notverify' @@ -21,6 +23,7 @@ SERVER_SOFTWARE = 'HomeAssistant/{0} aiohttp/{1} Python/{2[0]}.{2[1]}'.format( @callback +@bind_hass def async_get_clientsession(hass, verify_ssl=True): """Return default aiohttp ClientSession. @@ -45,6 +48,7 @@ def async_get_clientsession(hass, verify_ssl=True): @callback +@bind_hass def async_create_clientsession(hass, verify_ssl=True, auto_cleanup=True, **kwargs): """Create a new ClientSession with kwargs, i.e. for cookies. @@ -71,6 +75,7 @@ def async_create_clientsession(hass, verify_ssl=True, auto_cleanup=True, @asyncio.coroutine +@bind_hass def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, timeout=10): """Stream websession request to aiohttp web response.""" @@ -102,6 +107,7 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, @asyncio.coroutine +@bind_hass def async_aiohttp_proxy_stream(hass, request, stream, content_type, buffer_size=102400, timeout=10): """Stream a stream to aiohttp web response.""" @@ -155,7 +161,11 @@ def _async_get_connector(hass, verify_ssl=True): if verify_ssl: if DATA_CONNECTOR not in hass.data: - connector = aiohttp.TCPConnector(loop=hass.loop) + ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + ssl_context.load_verify_locations(cafile=certifi.where(), + capath=None) + connector = aiohttp.TCPConnector(loop=hass.loop, + ssl_context=ssl_context) hass.data[DATA_CONNECTOR] = connector is_new = True else: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index c3e4b2b4942..46eeef45f14 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -8,6 +8,7 @@ There are two different types of discoveries that can be fired/listened for. import asyncio from homeassistant import setup, core +from homeassistant.loader import bind_hass from homeassistant.const import ( ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED) from homeassistant.exceptions import HomeAssistantError @@ -18,6 +19,7 @@ EVENT_LOAD_PLATFORM = 'load_platform.{}' ATTR_PLATFORM = 'platform' +@bind_hass def listen(hass, service, callback): """Set up listener for discovery of specific service. @@ -28,6 +30,7 @@ def listen(hass, service, callback): @core.callback +@bind_hass def async_listen(hass, service, callback): """Set up listener for discovery of specific service. @@ -48,6 +51,7 @@ def async_listen(hass, service, callback): hass.bus.async_listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) +@bind_hass def discover(hass, service, discovered=None, component=None, hass_config=None): """Fire discovery event. Can ensure a component is loaded.""" hass.add_job( @@ -55,6 +59,7 @@ def discover(hass, service, discovered=None, component=None, hass_config=None): @asyncio.coroutine +@bind_hass def async_discover(hass, service, discovered=None, component=None, hass_config=None): """Fire discovery event. Can ensure a component is loaded.""" @@ -76,6 +81,7 @@ def async_discover(hass, service, discovered=None, component=None, hass.bus.async_fire(EVENT_PLATFORM_DISCOVERED, data) +@bind_hass def listen_platform(hass, component, callback): """Register a platform loader listener.""" run_callback_threadsafe( @@ -83,6 +89,7 @@ def listen_platform(hass, component, callback): ).result() +@bind_hass def async_listen_platform(hass, component, callback): """Register a platform loader listener. @@ -109,6 +116,7 @@ def async_listen_platform(hass, component, callback): EVENT_PLATFORM_DISCOVERED, discovery_platform_listener) +@bind_hass def load_platform(hass, component, platform, discovered=None, hass_config=None): """Load a component and platform dynamically. @@ -127,6 +135,7 @@ def load_platform(hass, component, platform, discovered=None, @asyncio.coroutine +@bind_hass def async_load_platform(hass, component, platform, discovered=None, hass_config=None): """Load a component and platform dynamically. diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index a426f2de855..8c41505bd29 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,6 +2,7 @@ import logging from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.util.async import run_callback_threadsafe @@ -9,6 +10,7 @@ _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = 'dispatcher' +@bind_hass def dispatcher_connect(hass, signal, target): """Connect a callable function to a signal.""" async_unsub = run_callback_threadsafe( @@ -22,6 +24,7 @@ def dispatcher_connect(hass, signal, target): @callback +@bind_hass def async_dispatcher_connect(hass, signal, target): """Connect a callable function to a signal. @@ -49,12 +52,14 @@ def async_dispatcher_connect(hass, signal, target): return async_remove_dispatcher +@bind_hass def dispatcher_send(hass, signal, *args): """Send signal and data.""" hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) @callback +@bind_hass def async_dispatcher_send(hass, signal, *args): """Send signal and data. diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index d45c3c6b2f9..930c76f9779 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -71,8 +71,11 @@ class Entity(object): # If we reported if this entity was slow _slow_reported = False - # protect for multiple updates - _update_warn = None + # Protect for multiple updates + _update_staged = False + + # Process updates pararell + parallel_updates = None @property def should_poll(self) -> bool: @@ -197,11 +200,15 @@ class Entity(object): # update entity data if force_refresh: - if self._update_warn: - # Update is already in progress. + if self._update_staged: return + self._update_staged = True - self._update_warn = self.hass.loop.call_later( + # Process update sequential + if self.parallel_updates: + yield from self.parallel_updates.acquire() + + update_warn = self.hass.loop.call_later( SLOW_UPDATE_WARNING, _LOGGER.warning, "Update of %s is taking over %s seconds", self.entity_id, SLOW_UPDATE_WARNING @@ -217,8 +224,10 @@ class Entity(object): _LOGGER.exception("Update for %s fails", self.entity_id) return finally: - self._update_warn.cancel() - self._update_warn = None + self._update_staged = False + update_warn.cancel() + if self.parallel_updates: + self.parallel_updates.release() start = timer() diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2833010789e..8a3026c49e5 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -44,7 +44,7 @@ class EntityComponent(object): self.config = None self._platforms = { - 'core': EntityPlatform(self, domain, self.scan_interval, None), + 'core': EntityPlatform(self, domain, self.scan_interval, 0, None), } self.async_add_entities = self._platforms['core'].async_add_entities self.add_entities = self._platforms['core'].add_entities @@ -128,17 +128,23 @@ class EntityComponent(object): return # Config > Platform > Component - scan_interval = (platform_config.get(CONF_SCAN_INTERVAL) or - getattr(platform, 'SCAN_INTERVAL', None) or - self.scan_interval) + scan_interval = ( + platform_config.get(CONF_SCAN_INTERVAL) or + getattr(platform, 'SCAN_INTERVAL', None) or self.scan_interval) + parallel_updates = getattr( + platform, 'PARALLEL_UPDATES', + int(not hasattr(platform, 'async_setup_platform'))) + entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) key = (platform_type, scan_interval, entity_namespace) if key not in self._platforms: - self._platforms[key] = EntityPlatform( - self, platform_type, scan_interval, entity_namespace) - entity_platform = self._platforms[key] + entity_platform = self._platforms[key] = EntityPlatform( + self, platform_type, scan_interval, parallel_updates, + entity_namespace) + else: + entity_platform = self._platforms[key] self.logger.info("Setting up %s.%s", self.domain, platform_type) warn_task = self.hass.loop.call_later( @@ -204,13 +210,6 @@ class EntityComponent(object): entity.hass = self.hass - # update/init entity data - if update_before_add: - if hasattr(entity, 'async_update'): - yield from entity.async_update() - else: - yield from self.hass.async_add_job(entity.update) - if getattr(entity, 'entity_id', None) is None: object_id = entity.name or DEVICE_DEFAULT_NAME @@ -235,7 +234,7 @@ class EntityComponent(object): if hasattr(entity, 'async_added_to_hass'): yield from entity.async_added_to_hass() - yield from entity.async_update_ha_state() + yield from entity.async_update_ha_state(update_before_add) return True @@ -316,17 +315,23 @@ class EntityComponent(object): class EntityPlatform(object): """Keep track of entities for a single platform and stay in loop.""" - def __init__(self, component, platform, scan_interval, entity_namespace): + def __init__(self, component, platform, scan_interval, parallel_updates, + entity_namespace): """Initialize the entity platform.""" self.component = component self.platform = platform self.scan_interval = scan_interval + self.parallel_updates = None self.entity_namespace = entity_namespace self.platform_entities = [] self._tasks = [] self._async_unsub_polling = None self._process_updates = asyncio.Lock(loop=component.hass.loop) + if parallel_updates: + self.parallel_updates = asyncio.Semaphore( + parallel_updates, loop=component.hass.loop) + @asyncio.coroutine def async_block_entities_done(self): """Wait until all entities add to hass.""" @@ -377,6 +382,7 @@ class EntityPlatform(object): @asyncio.coroutine def async_process_entity(new_entity): """Add entities to StateMachine.""" + new_entity.parallel_updates = self.parallel_updates ret = yield from self.component.async_add_entity( new_entity, self, update_before_add=update_before_add ) @@ -432,26 +438,10 @@ class EntityPlatform(object): with (yield from self._process_updates): tasks = [] - to_update = [] - for entity in self.platform_entities: if not entity.should_poll: continue - - update_coro = entity.async_update_ha_state(True) - if hasattr(entity, 'async_update'): - tasks.append( - self.component.hass.async_add_job(update_coro)) - else: - to_update.append(update_coro) - - for update_coro in to_update: - try: - yield from update_coro - except Exception: # pylint: disable=broad-except - self.component.logger.exception( - "Error while update entity from %s in %s", - self.platform, self.component.domain) + tasks.append(entity.async_update_ha_state(True)) if tasks: yield from asyncio.wait(tasks, loop=self.component.hass.loop) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5db4ece5ef5..6cd1916d4c2 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,6 +1,7 @@ """Helpers for listening to events.""" import functools as ft +from homeassistant.loader import bind_hass from homeassistant.helpers.sun import get_astral_event_next from ..core import HomeAssistant, callback from ..const import ( @@ -35,6 +36,7 @@ def threaded_listener_factory(async_factory): @callback +@bind_hass def async_track_state_change(hass, entity_ids, action, from_state=None, to_state=None): """Track specific state changes. @@ -46,8 +48,8 @@ def async_track_state_change(hass, entity_ids, action, from_state=None, Must be run within the event loop. """ - from_state = _process_state_match(from_state) - to_state = _process_state_match(to_state) + match_from_state = _process_state_match(from_state) + match_to_state = _process_state_match(to_state) # Ensure it is a lowercase list with entity ids we want to match on if entity_ids == MATCH_ALL: @@ -64,17 +66,15 @@ def async_track_state_change(hass, entity_ids, action, from_state=None, event.data.get('entity_id') not in entity_ids: return - if event.data.get('old_state') is not None: - old_state = event.data['old_state'].state - else: - old_state = None + old_state = event.data.get('old_state') + if old_state is not None: + old_state = old_state.state - if event.data.get('new_state') is not None: - new_state = event.data['new_state'].state - else: - new_state = None + new_state = event.data.get('new_state') + if new_state is not None: + new_state = new_state.state - if _matcher(old_state, from_state) and _matcher(new_state, to_state): + if match_from_state(old_state) and match_to_state(new_state): hass.async_run_job(action, event.data.get('entity_id'), event.data.get('old_state'), event.data.get('new_state')) @@ -86,6 +86,7 @@ track_state_change = threaded_listener_factory(async_track_state_change) @callback +@bind_hass def async_track_template(hass, template, action, variables=None): """Add a listener that track state changes with template condition.""" from . import condition @@ -107,15 +108,17 @@ def async_track_template(hass, template, action, variables=None): already_triggered = False return async_track_state_change( - hass, template.extract_entities(), template_condition_listener) + hass, template.extract_entities(variables), + template_condition_listener) track_template = threaded_listener_factory(async_track_template) @callback -def async_track_same_state(hass, orig_value, period, action, - async_check_func=None, entity_ids=MATCH_ALL): +@bind_hass +def async_track_same_state(hass, period, action, async_check_same_func, + entity_ids=MATCH_ALL): """Track the state of entities for a period and run a action. If async_check_func is None it use the state of orig_value. @@ -148,14 +151,8 @@ def async_track_same_state(hass, orig_value, period, action, @callback def state_for_cancel_listener(entity, from_state, to_state): """Fire on changes and cancel for listener if changed.""" - if async_check_func: - value = async_check_func(entity, from_state, to_state) - else: - value = to_state.state - - if orig_value == value: - return - clear_listener() + if not async_check_same_func(entity, from_state, to_state): + clear_listener() async_remove_state_for_listener = async_track_point_in_utc_time( hass, state_for_listener, dt_util.utcnow() + period) @@ -170,6 +167,7 @@ track_same_state = threaded_listener_factory(async_track_same_state) @callback +@bind_hass def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" utc_point_in_time = dt_util.as_utc(point_in_time) @@ -187,6 +185,7 @@ track_point_in_time = threaded_listener_factory(async_track_point_in_time) @callback +@bind_hass def async_track_point_in_utc_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC @@ -221,6 +220,7 @@ track_point_in_utc_time = threaded_listener_factory( @callback +@bind_hass def async_track_time_interval(hass, action, interval): """Add a listener that fires repetitively at every timedelta interval.""" remove = None @@ -251,6 +251,7 @@ track_time_interval = threaded_listener_factory(async_track_time_interval) @callback +@bind_hass def async_track_sunrise(hass, action, offset=None): """Add a listener that will fire a specified offset from sunrise daily.""" remove = None @@ -279,6 +280,7 @@ track_sunrise = threaded_listener_factory(async_track_sunrise) @callback +@bind_hass def async_track_sunset(hass, action, offset=None): """Add a listener that will fire a specified offset from sunset daily.""" remove = None @@ -307,6 +309,7 @@ track_sunset = threaded_listener_factory(async_track_sunset) @callback +@bind_hass def async_track_utc_time_change(hass, action, year=None, month=None, day=None, hour=None, minute=None, second=None, local=False): @@ -332,15 +335,10 @@ def async_track_utc_time_change(hass, action, year=None, month=None, day=None, if local: now = dt_util.as_local(now) - mat = _matcher # pylint: disable=too-many-boolean-expressions - if mat(now.year, year) and \ - mat(now.month, month) and \ - mat(now.day, day) and \ - mat(now.hour, hour) and \ - mat(now.minute, minute) and \ - mat(now.second, second): + if second(now.second) and minute(now.minute) and hour(now.hour) and \ + day(now.day) and month(now.month) and year(now.year): hass.async_run_job(action, now) @@ -352,6 +350,7 @@ track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) @callback +@bind_hass def async_track_time_change(hass, action, year=None, month=None, day=None, hour=None, minute=None, second=None): """Add a listener that will fire if UTC time matches a pattern.""" @@ -363,34 +362,28 @@ track_time_change = threaded_listener_factory(async_track_time_change) def _process_state_match(parameter): - """Wrap parameter in a tuple if it is not one and returns it.""" + """Convert parameter to function that matches input against parameter.""" if parameter is None or parameter == MATCH_ALL: - return MATCH_ALL + return lambda _: True + elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): - return (parameter,) - return tuple(parameter) + return lambda state: state == parameter + + parameter = tuple(parameter) + return lambda state: state in parameter def _process_time_match(parameter): """Wrap parameter in a tuple if it is not one and returns it.""" if parameter is None or parameter == MATCH_ALL: - return MATCH_ALL + return lambda _: True + elif isinstance(parameter, str) and parameter.startswith('/'): - return parameter + parameter = float(parameter[1:]) + return lambda time: time % parameter == 0 + elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): - return (parameter,) - return tuple(parameter) + return lambda time: time == parameter - -def _matcher(subject, pattern): - """Return True if subject matches the pattern. - - Pattern is either a tuple of allowed subjects or a `MATCH_ALL`. - """ - if isinstance(pattern, str) and pattern.startswith('/'): - try: - return subject % float(pattern.lstrip('/')) == 0 - except ValueError: - return False - - return MATCH_ALL == pattern or subject in pattern + parameter = tuple(parameter) + return lambda time: time in parameter diff --git a/homeassistant/util/icon.py b/homeassistant/helpers/icon.py similarity index 85% rename from homeassistant/util/icon.py rename to homeassistant/helpers/icon.py index dc8cce64712..e4c78fcbed2 100644 --- a/homeassistant/util/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,4 +1,4 @@ -"""Icon util methods.""" +"""Icon helper methods.""" from typing import Optional @@ -11,8 +11,10 @@ def icon_for_battery_level(battery_level: Optional[int]=None, if charging and battery_level > 10: icon += '-charging-{}'.format( int(round(battery_level / 20 - .01)) * 20) - elif charging or battery_level <= 5: + elif charging: icon += '-outline' + elif battery_level <= 5: + icon += '-alert' elif 5 < battery_level < 95: icon += '-{}'.format(int(round(battery_level / 10 - .01)) * 10) return icon diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 8843bf53df9..c5aad3ababc 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass DATA_KEY = 'intent' @@ -19,6 +20,7 @@ SPEECH_TYPE_SSML = 'ssml' @callback +@bind_hass def async_register(hass, handler): """Register an intent with Home Assistant.""" intents = hass.data.get(DATA_KEY) @@ -33,6 +35,7 @@ def async_register(hass, handler): @asyncio.coroutine +@bind_hass def async_handle(hass, platform, intent_type, slots=None, text_input=None): """Handle an intent.""" handler = hass.data.get(DATA_KEY, {}).get(intent_type) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 3afbac5c8dd..a2940f06022 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -7,6 +7,7 @@ import async_timeout from homeassistant.core import HomeAssistant, CoreState, callback from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.loader import bind_hass from homeassistant.components.history import get_states, last_recorder_run from homeassistant.components.recorder import ( wait_connection_ready, DOMAIN as _RECORDER) @@ -49,6 +50,7 @@ def _load_restore_cache(hass: HomeAssistant): @asyncio.coroutine +@bind_hass def async_get_last_state(hass, entity_id: str): """Restore state.""" if DATA_RESTORE_CACHE in hass.data: diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bafaf4d0fdb..7154e990563 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -129,7 +129,7 @@ class Script(): self.hass.async_add_job(self.async_run(variables)) self._async_listener.append(async_track_template( - self.hass, wait_template, async_script_wait)) + self.hass, wait_template, async_script_wait, variables)) self._cur = cur + 1 if self._change_listener: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index af6aa0f2195..98cd704144e 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant # NOQA from homeassistant.exceptions import TemplateError -from homeassistant.loader import get_component +from homeassistant.loader import get_component, bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.util.async import run_coroutine_threadsafe @@ -22,6 +22,7 @@ CONF_SERVICE_DATA_TEMPLATE = 'data_template' _LOGGER = logging.getLogger(__name__) +@bind_hass def call_from_config(hass, config, blocking=False, variables=None, validate_config=True): """Call a service based on a config hash.""" @@ -31,6 +32,7 @@ def call_from_config(hass, config, blocking=False, variables=None, @asyncio.coroutine +@bind_hass def async_call_from_config(hass, config, blocking=False, variables=None, validate_config=True): """Call a service based on a config hash.""" @@ -80,6 +82,7 @@ def async_call_from_config(hass, config, blocking=False, variables=None, domain, service_name, service_data, blocking) +@bind_hass def extract_entity_ids(hass, service_call, expand_group=True): """Extract a list of entity ids from a service call. diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index de4c344d375..3ea52388d33 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -5,11 +5,13 @@ import sys from homeassistant.core import callback from homeassistant.const import RESTART_EXIT_CODE +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @callback +@bind_hass def async_register_signal_handling(hass): """Register system signal handler for core.""" if sys.platform != 'win32': diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 93953fcd69e..8b98bfadb68 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -4,6 +4,7 @@ import json import logging from collections import defaultdict +from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_SEEK_POSITION, @@ -120,6 +121,7 @@ def get_changed_since(states, utc_point_in_time): if state.last_updated >= utc_point_in_time] +@bind_hass def reproduce_state(hass, states, blocking=False): """Reproduce given state.""" return run_coroutine_threadsafe( @@ -127,6 +129,7 @@ def reproduce_state(hass, states, blocking=False): @asyncio.coroutine +@bind_hass def async_reproduce_state(hass, states, blocking=False): """Reproduce given state.""" if isinstance(states, State): diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 5ad4f06fdf1..59c2160a180 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -3,11 +3,13 @@ import datetime from homeassistant.core import callback from homeassistant.util import dt as dt_util +from homeassistant.loader import bind_hass DATA_LOCATION_CACHE = 'astral_location_cache' @callback +@bind_hass def get_astral_location(hass): """Get an astral location for the current Home Assistant configuration.""" from astral import Location @@ -29,6 +31,7 @@ def get_astral_location(hass): @callback +@bind_hass def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): """Calculate the next specified solar event.""" import astral @@ -56,6 +59,7 @@ def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): @callback +@bind_hass def get_astral_event_date(hass, event, date=None): """Calculate the astral event time for the specified date.""" import astral @@ -76,6 +80,7 @@ def get_astral_event_date(hass, event, date=None): @callback +@bind_hass def is_up(hass, utc_point_in_time=None): """Calculate if the sun is currently up.""" if utc_point_in_time is None: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d5dbcb77a32..6f83688623a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper -from homeassistant.loader import get_component +from homeassistant.loader import get_component, bind_hass from homeassistant.util import convert, dt as dt_util, location as loc_util from homeassistant.util.async import run_callback_threadsafe @@ -25,11 +25,12 @@ DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( - r"(?:(?:states\.|(?:is_state|is_state_attr|states)\(.)([\w]+\.[\w]+))", - re.I | re.M + r"(?:(?:states\.|(?:is_state|is_state_attr|states)" + r"\((?:[\ \'\"]?))([\w]+\.[\w]+)|([\w]+))", re.I | re.M ) +@bind_hass def attach(hass, obj): """Recursively attach hass to all template instances in list and dict.""" if isinstance(obj, list): @@ -42,14 +43,27 @@ def attach(hass, obj): obj.hass = hass -def extract_entities(template): +def extract_entities(template, variables=None): """Extract all entities for state_changed listener from template string.""" if template is None or _RE_NONE_ENTITIES.search(template): return MATCH_ALL extraction = _RE_GET_ENTITIES.findall(template) - if extraction: - return list(set(extraction)) + extraction_final = [] + + for result in extraction: + if result[0] == 'trigger.entity_id' and 'trigger' in variables and \ + 'entity_id' in variables['trigger']: + extraction_final.append(variables['trigger']['entity_id']) + elif result[0]: + extraction_final.append(result[0]) + + if variables and result[1] in variables and \ + isinstance(variables[result[1]], str): + extraction_final.append(variables[result[1]]) + + if extraction_final: + return list(set(extraction_final)) return MATCH_ALL @@ -76,9 +90,9 @@ class Template(object): except jinja2.exceptions.TemplateSyntaxError as err: raise TemplateError(err) - def extract_entities(self): + def extract_entities(self, variables=None): """Extract all entities for state_changed listener.""" - return extract_entities(self.template) + return extract_entities(self.template, variables) def render(self, variables=None, **kwargs): """Render given template.""" diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 566cdd4fb15..e7a0854f047 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -4,7 +4,7 @@ Provides methods for loading Home Assistant components. This module has quite some complex parts. I have tried to add as much documentation as possible to keep it understandable. -Components are loaded by calling get_component('switch') from your code. +Components can be accessed via hass.components.switch from your code. If you want to retrieve a platform that is part of a component, you should call get_component('switch.your_platform'). In both cases the config directory is checked to see if it contains a user provided version. If not available it @@ -183,22 +183,38 @@ class Components: component = get_component(comp_name) if component is None: raise ImportError('Unable to load {}'.format(comp_name)) - wrapped = ComponentWrapper(self._hass, component) + wrapped = ModuleWrapper(self._hass, component) setattr(self, comp_name, wrapped) return wrapped -class ComponentWrapper: - """Class to wrap a component and auto fill in hass argument.""" +class Helpers: + """Helper to load helpers.""" - def __init__(self, hass, component): - """Initialize the component wrapper.""" + def __init__(self, hass): + """Initialize the Helpers class.""" self._hass = hass - self._component = component + + def __getattr__(self, helper_name): + """Fetch a helper.""" + helper = importlib.import_module( + 'homeassistant.helpers.{}'.format(helper_name)) + wrapped = ModuleWrapper(self._hass, helper) + setattr(self, helper_name, wrapped) + return wrapped + + +class ModuleWrapper: + """Class to wrap a Python module and auto fill in hass argument.""" + + def __init__(self, hass, module): + """Initialize the module wrapper.""" + self._hass = hass + self._module = module def __getattr__(self, attr): """Fetch an attribute.""" - value = getattr(self._component, attr) + value = getattr(self._module, attr) if hasattr(value, '__bind_hass'): value = ft.partial(value, self._hass) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ef34bd15319..783aca0ceac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,9 +6,10 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.4.0 +async_timeout==2.0.0 chardet==3.0.4 astral==1.4 +certifi>=2017.4.17 # Breaks Python 3.6 and is not needed for our supported Pythons enum34==1000000000.0.0 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 73ad8bc0cd2..100d3aa3508 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -2,10 +2,14 @@ import asyncio import argparse from contextlib import suppress +from datetime import datetime import logging from timeit import default_timer as timer +from homeassistant.const import ( + EVENT_TIME_CHANGED, ATTR_NOW, EVENT_STATE_CHANGED) from homeassistant import core +from homeassistant.util import dt as dt_util BENCHMARKS = {} @@ -64,11 +68,79 @@ def async_million_events(hass): hass.bus.async_listen(event_name, listener) - start = timer() - for _ in range(10**6): hass.bus.async_fire(event_name) + start = timer() + + yield from event.wait() + + return timer() - start + + +@benchmark +@asyncio.coroutine +# pylint: disable=invalid-name +def async_million_time_changed_helper(hass): + """Run a million events through time changed helper.""" + count = 0 + event = asyncio.Event(loop=hass.loop) + + @core.callback + def listener(_): + """Handle event.""" + nonlocal count + count += 1 + + if count == 10**6: + event.set() + + hass.helpers.event.async_track_time_change(listener, minute=0, second=0) + event_data = { + ATTR_NOW: datetime(2017, 10, 10, 15, 0, 0, tzinfo=dt_util.UTC) + } + + for _ in range(10**6): + hass.bus.async_fire(EVENT_TIME_CHANGED, event_data) + + start = timer() + + yield from event.wait() + + return timer() - start + + +@benchmark +@asyncio.coroutine +# pylint: disable=invalid-name +def async_million_state_changed_helper(hass): + """Run a million events through state changed helper.""" + count = 0 + entity_id = 'light.kitchen' + event = asyncio.Event(loop=hass.loop) + + @core.callback + def listener(*args): + """Handle event.""" + nonlocal count + count += 1 + + if count == 10**6: + event.set() + + hass.helpers.event.async_track_state_change( + entity_id, listener, 'off', 'on') + event_data = { + 'entity_id': entity_id, + 'old_state': core.State(entity_id, 'off'), + 'new_state': core.State(entity_id, 'on'), + } + + for _ in range(10**6): + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data) + + start = timer() + yield from event.wait() return timer() - start diff --git a/requirements_all.txt b/requirements_all.txt index d7f53c75dc1..985d9d539cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,9 +7,10 @@ jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.4.0 +async_timeout==2.0.0 chardet==3.0.4 astral==1.4 +certifi>=2017.4.17 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 @@ -17,6 +18,9 @@ astral==1.4 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 +# homeassistant.components.tradfri +# DTLSSocket==0.1.3 + # homeassistant.components.doorbird DoorBirdPy==0.0.4 @@ -33,7 +37,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.5.1 +PyXiaomiGateway==0.5.2 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -41,11 +45,14 @@ PyXiaomiGateway==0.5.1 # homeassistant.components.media_player.sonos SoCo==0.12 +# homeassistant.components.sensor.travisci +TravisPy==0.3.5 + # homeassistant.components.notify.twitter TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.11.9 +abodepy==0.12.1 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.3 @@ -58,7 +65,7 @@ aiodns==1.1.1 aiohttp_cors==0.5.3 # homeassistant.components.sensor.imap -aioimaplib==0.7.12 +aioimaplib==0.7.13 # homeassistant.components.light.lifx aiolifx==0.6.0 @@ -108,7 +115,7 @@ batinfo==0.4.2 beautifulsoup4==4.6.0 # homeassistant.components.zha -bellows==0.3.4 +bellows==0.4.0 # homeassistant.components.blink blinkpy==0.6.0 @@ -174,11 +181,14 @@ datapoint==0.4.3 # homeassistant.components.light.decora_wifi # decora_wifi==1.3 +# homeassistant.components.device_tracker.upc_connect +defusedxml==0.5.0 + # homeassistant.components.media_player.denonavr -denonavr==0.5.3 +denonavr==0.5.4 # homeassistant.components.media_player.directv -directpy==0.1 +directpy==0.2 # homeassistant.components.notify.discord discord.py==0.16.12 @@ -206,7 +216,7 @@ dweepy==0.3.0 eliqonline==1.0.13 # homeassistant.components.enocean -enocean==0.31 +enocean==0.40 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 @@ -249,10 +259,10 @@ freesms==0.1.1 # homeassistant.components.device_tracker.fritz # homeassistant.components.sensor.fritzbox_callmonitor # homeassistant.components.sensor.fritzbox_netmonitor -# fritzconnection==0.6.3 +# fritzconnection==0.6.5 # homeassistant.components.switch.fritzdect -fritzhome==1.0.2 +fritzhome==1.0.3 # homeassistant.components.media_player.frontier_silicon fsapi==0.0.7 @@ -285,7 +295,7 @@ gps3==0.33.3 gstreamer-player==1.1.0 # homeassistant.components.ffmpeg -ha-ffmpeg==1.7 +ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js ha-philipsjs==0.0.1 @@ -300,7 +310,7 @@ hbmqtt==0.8 heatmiserV3==0.9.1 # homeassistant.components.switch.hikvisioncam -hikvision==0.4 +hikvision==1.2 # homeassistant.components.notify.hipchat hipnotify==1.0.8 @@ -317,6 +327,9 @@ http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b89974819 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 +# homeassistant.components.tradfri +# https://github.com/chrysn/aiocoap/archive/3286f48f0b949901c8b5c04c0719dc54ab63d431.zip#aiocoap==0.3 + # homeassistant.components.media_player.spotify https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 @@ -374,7 +387,7 @@ keyring>=9.3,<10.0 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http -libnacl==1.5.2 +libnacl==1.6.0 # homeassistant.components.dyson libpurecoollink==0.4.2 @@ -383,7 +396,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==1.0.2 +librouteros==1.0.4 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 @@ -474,7 +487,7 @@ orvibo==1.1.1 # homeassistant.components.mqtt # homeassistant.components.shiftr -paho-mqtt==1.3.0 +paho-mqtt==1.3.1 # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.2 @@ -521,7 +534,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.3.1 +psutil==5.4.0 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -540,14 +553,14 @@ pwmled==1.2.1 py-cpuinfo==3.3.0 # homeassistant.components.camera.synology -py-synology==0.1.3 +py-synology==0.1.5 # homeassistant.components.hdmi_cec pyCEC==0.4.13 # homeassistant.components.light.tplink # homeassistant.components.switch.tplink -pyHS100==0.2.4.2 +pyHS100==0.3.0 # homeassistant.components.rfxtrx pyRFXtrx==0.20.1 @@ -565,13 +578,13 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.0.6 +pyarlo==0.0.7 # homeassistant.components.notify.xmpp -pyasn1-modules==0.1.4 +pyasn1-modules==0.1.5 # homeassistant.components.notify.xmpp -pyasn1==0.3.6 +pyasn1==0.3.7 # homeassistant.components.apple_tv pyatv==0.3.5 @@ -629,7 +642,7 @@ pyharmony==1.0.16 pyhik==0.1.4 # homeassistant.components.homematic -pyhomematic==0.1.32 +pyhomematic==0.1.34 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.2.0 @@ -677,6 +690,9 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.monoprice +pymonoprice==0.2 + # homeassistant.components.media_player.yamaha_musiccast pymusiccast==0.1.2 @@ -718,6 +734,9 @@ pyqwikswitch==0.4 # homeassistant.components.climate.sensibo pysensibo==1.0.1 +# homeassistant.components.sensor.serial +pyserial-asyncio==0.4 + # homeassistant.components.switch.acer_projector pyserial==3.1.1 @@ -729,7 +748,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.9 +pysnmp==4.3.10 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner @@ -748,7 +767,7 @@ python-digitalocean==1.12 python-ecobee-api==0.0.10 # homeassistant.components.climate.eq3btsmart -# python-eq3bt==0.1.5 +# python-eq3bt==0.1.6 # homeassistant.components.sensor.etherscan python-etherscan-api==0.0.1 @@ -770,6 +789,7 @@ python-juicenet==0.0.5 # python-lirc==1.2.3 # homeassistant.components.light.xiaomi_miio +# homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio python-mirobo==0.2.0 @@ -802,7 +822,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==8.0.0 +python-telegram-bot==8.1.1 # homeassistant.components.sensor.twitch python-twitch==1.3.0 @@ -814,7 +834,7 @@ python-velbus==2.0.11 python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.6.0 +python-wink==1.7.0 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.0.2 @@ -823,13 +843,13 @@ python_opendata_transport==0.0.2 python_openzwave==0.4.0.35 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.21 +pythonegardia==1.0.22 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==2.2.2 +pytradfri==3.0 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -865,7 +885,7 @@ rachiopy==0.1.2 radiotherm==1.3 # homeassistant.components.raincloud -raincloudy==0.0.1 +raincloudy==0.0.3 # homeassistant.components.raspihats # raspihats==2.2.3 @@ -882,6 +902,9 @@ rflink==0.0.34 # homeassistant.components.ring ring_doorbell==0.1.4 +# homeassistant.components.notify.rocketchat +rocketchat-API==0.6.1 + # homeassistant.components.vacuum.roomba roombapy==1.3.1 @@ -895,7 +918,7 @@ russound==0.1.7 russound_rio==0.1.4 # homeassistant.components.media_player.yamaha -rxv==0.4.0 +rxv==0.5.1 # homeassistant.components.media_player.samsungtv samsungctl==0.6.0 @@ -928,6 +951,9 @@ simplepush==1.1.3 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 +# homeassistant.components.skybell +skybellpy==0.1.1 + # homeassistant.components.notify.slack slacker==0.9.60 @@ -983,7 +1009,7 @@ tellduslive==0.3.4 temperusb==1.5.3 # homeassistant.components.tesla -teslajsonpy==0.0.11 +teslajsonpy==0.0.17 # homeassistant.components.thingspeak thingspeak==0.4.1 @@ -994,6 +1020,9 @@ tikteck==0.4 # homeassistant.components.calendar.todoist todoist-python==7.0.17 +# homeassistant.components.toon +toonlib==1.0.2 + # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.11 @@ -1033,7 +1062,7 @@ wakeonlan==0.2.2 waqiasync==1.0.0 # homeassistant.components.cloud -warrant==0.2.0 +warrant==0.5.0 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 @@ -1049,7 +1078,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.14 +xknx==0.7.16 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data @@ -1071,7 +1100,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.10.01 +youtube_dl==2017.10.12 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c079d4555e..cdd6a55bc0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,6 +36,9 @@ aiohttp_cors==0.5.3 # homeassistant.components.notify.apns apns2==0.1.1 +# homeassistant.components.device_tracker.upc_connect +defusedxml==0.5.0 + # homeassistant.components.sensor.dsmr dsmr_parser==0.11 @@ -56,7 +59,7 @@ fuzzywuzzy==0.15.1 gTTS-token==1.1.1 # homeassistant.components.ffmpeg -ha-ffmpeg==1.7 +ha-ffmpeg==1.9 # homeassistant.components.sensor.geo_rss_events haversine==0.4.5 @@ -83,7 +86,7 @@ mficlient==0.3.0 # homeassistant.components.mqtt # homeassistant.components.shiftr -paho-mqtt==1.3.0 +paho-mqtt==1.3.1 # homeassistant.components.device_tracker.aruba # homeassistant.components.device_tracker.asuswrt @@ -130,7 +133,7 @@ rflink==0.0.34 ring_doorbell==0.1.4 # homeassistant.components.media_player.yamaha -rxv==0.4.0 +rxv==0.5.1 # homeassistant.components.sleepiq sleepyq==0.6 @@ -149,7 +152,7 @@ statsd==3.2.1 uvcclient==0.10.1 # homeassistant.components.cloud -warrant==0.2.0 +warrant==0.5.0 # homeassistant.components.sensor.yahoo_finance yahoo-finance==1.4.0 diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend index 0efe2e3584d..d8338161e74 100755 --- a/script/bootstrap_frontend +++ b/script/bootstrap_frontend @@ -8,7 +8,7 @@ cd "$(dirname "$0")/.." echo "Bootstrapping frontend..." -git submodule update +git submodule update --init cd homeassistant/components/frontend/www_static/home-assistant-polymer # Install node modules @@ -17,4 +17,7 @@ yarn install # Install bower web components. Allow to download the components as root since the user in docker is root. ./node_modules/.bin/bower install --allow-root +# Build files that need to be generated to run development mode +yarn dev + cd ../../../../.. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ba9cecb6684..ddac210bc26 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -30,18 +30,20 @@ COMMENT_REQUIREMENTS = ( 'smbus-cffi', 'envirophat', 'i2csense', - 'credstash' + 'credstash', + 'aiocoap', # Temp, will be removed when Python 3.4 is no longer supported. + 'DTLSSocket' # Requires cython. ) TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', 'apns2', + 'defusedxml', 'dsmr_parser', 'ephem', 'evohomeclient', 'feedparser', - 'forecastio', 'fuzzywuzzy', 'gTTS-token', 'ha-ffmpeg', @@ -52,15 +54,16 @@ TEST_REQUIREMENTS = ( 'libpurecoollink', 'libsoundtouch', 'mficlient', - 'nx584', - 'paho', + 'paho-mqtt', 'pexpect', 'pilight', 'pmsensor', 'prometheus_client', - 'pydispatch', + 'pydispatcher', 'PyJWT', 'pylitejet', + 'pynx584', + 'python-forecastio', 'pyunifi', 'pywebpush', 'restrictedpython', @@ -202,11 +205,13 @@ def requirements_test_output(reqs): output = [] output.append('# Home Assistant test') output.append('\n') - with open('requirements_test.txt') as fp: - output.append(fp.read()) + with open('requirements_test.txt') as test_file: + output.append(test_file.read()) output.append('\n') filtered = {key: value for key, value in reqs.items() - if any(ign in key for ign in TEST_REQUIREMENTS)} + if any( + re.search(r'(^|#){}($|[=><])'.format(ign), + key) is not None for ign in TEST_REQUIREMENTS)} output.append(generate_requirements_list(filtered)) return ''.join(output) diff --git a/setup.py b/setup.py index ce5b49d4232..9ced64df954 100755 --- a/setup.py +++ b/setup.py @@ -23,9 +23,10 @@ REQUIRES = [ 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', - 'async_timeout==1.4.0', + 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', + 'certifi>=2017.4.17', ] setup( diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 22cd149009f..1c1fcfb7594 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,5 +1,6 @@ """Test for smart home alexa support.""" import asyncio +from uuid import uuid4 import pytest @@ -8,22 +9,86 @@ from homeassistant.components.alexa import smart_home from tests.common import async_mock_service -def test_create_api_message(): - """Create a API message.""" - msg = smart_home.api_message('testName', 'testNameSpace') +def get_new_request(namespace, name, endpoint=None): + """Generate a new API message.""" + raw_msg = { + 'directive': { + 'header': { + 'namespace': namespace, + 'name': name, + 'messageId': str(uuid4()), + 'correlationToken': str(uuid4()), + 'payloadVersion': '3', + }, + 'endpoint': { + 'scope': { + 'type': 'BearerToken', + 'token': str(uuid4()), + }, + 'endpointId': endpoint, + }, + 'payload': {}, + } + } + + if not endpoint: + raw_msg['directive'].pop('endpoint') + + return raw_msg + + +def test_create_api_message_defaults(): + """Create a API message response of a request with defaults.""" + request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy') + request = request['directive'] + + msg = smart_home.api_message(request, payload={'test': 3}) + + assert 'event' in msg + msg = msg['event'] assert msg['header']['messageId'] is not None + assert msg['header']['messageId'] != request['header']['messageId'] + assert msg['header']['correlationToken'] == \ + request['header']['correlationToken'] + assert msg['header']['name'] == 'Response' + assert msg['header']['namespace'] == 'Alexa' + assert msg['header']['payloadVersion'] == '3' + + assert 'test' in msg['payload'] + assert msg['payload']['test'] == 3 + + assert msg['endpoint'] == request['endpoint'] + + +def test_create_api_message_special(): + """Create a API message response of a request with non defaults.""" + request = get_new_request('Alexa.PowerController', 'TurnOn') + request = request['directive'] + + request['header'].pop('correlationToken') + + msg = smart_home.api_message(request, 'testName', 'testNameSpace') + + assert 'event' in msg + msg = msg['event'] + + assert msg['header']['messageId'] is not None + assert msg['header']['messageId'] != request['header']['messageId'] + assert 'correlationToken' not in msg['header'] assert msg['header']['name'] == 'testName' assert msg['header']['namespace'] == 'testNameSpace' - assert msg['header']['payloadVersion'] == '2' + assert msg['header']['payloadVersion'] == '3' + assert msg['payload'] == {} + assert 'endpoint' not in msg @asyncio.coroutine def test_wrong_version(hass): """Test with wrong version.""" - msg = smart_home.api_message('testName', 'testNameSpace') - msg['header']['payloadVersion'] = '3' + msg = get_new_request('Alexa.PowerController', 'TurnOn') + msg['directive']['header']['payloadVersion'] = '2' with pytest.raises(AssertionError): yield from smart_home.async_handle_message(hass, msg) @@ -32,8 +97,7 @@ def test_wrong_version(hass): @asyncio.coroutine def test_discovery_request(hass): """Test alexa discovery request.""" - msg = smart_home.api_message( - 'DiscoverAppliancesRequest', 'Alexa.ConnectedHome.Discovery') + request = get_new_request('Alexa.Discovery', 'Discover') # settup test devices hass.states.async_set( @@ -46,30 +110,44 @@ def test_discovery_request(hass): 'friendly_name': "Test light 2", 'supported_features': 1 }) - resp = yield from smart_home.async_api_discovery(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) - assert len(resp['payload']['discoveredAppliances']) == 3 - assert resp['header']['name'] == 'DiscoverAppliancesResponse' - assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Discovery' + assert 'event' in msg + msg = msg['event'] - for i, appliance in enumerate(resp['payload']['discoveredAppliances']): - if appliance['applianceId'] == 'switch#test': - assert appliance['applianceTypes'][0] == "SWITCH" + assert len(msg['payload']['endpoints']) == 3 + assert msg['header']['name'] == 'Discover.Response' + assert msg['header']['namespace'] == 'Alexa.Discovery' + + for appliance in msg['payload']['endpoints']: + if appliance['endpointId'] == 'switch#test': + assert appliance['displayCategories'][0] == "SWITCH" assert appliance['friendlyName'] == "Test switch" - assert appliance['actions'] == ['turnOff', 'turnOn'] + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' continue - if appliance['applianceId'] == 'light#test_1': - assert appliance['applianceTypes'][0] == "LIGHT" + if appliance['endpointId'] == 'light#test_1': + assert appliance['displayCategories'][0] == "LIGHT" assert appliance['friendlyName'] == "Test light 1" - assert appliance['actions'] == ['turnOff', 'turnOn'] + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' continue - if appliance['applianceId'] == 'light#test_2': - assert appliance['applianceTypes'][0] == "LIGHT" + if appliance['endpointId'] == 'light#test_2': + assert appliance['displayCategories'][0] == "LIGHT" assert appliance['friendlyName'] == "Test light 2" - assert appliance['actions'] == \ - ['turnOff', 'turnOn', 'setPercentage'] + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.BrightnessController' in caps + assert 'Alexa.PowerController' in caps + continue raise AssertionError("Unknown appliance!") @@ -78,31 +156,41 @@ def test_discovery_request(hass): @asyncio.coroutine def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" - msg_switch = smart_home.api_message( - 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': 'switch#test' - } - }) + request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test') call_switch = async_mock_service(hass, 'switch', 'turn_on') - resp = yield from smart_home.async_api_turn_on(hass, msg_switch) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call_switch) == 0 - assert resp['header']['name'] == 'DriverInternalError' - assert resp['header']['namespace'] == 'Alexa.ConnectedHome.Control' + assert msg['header']['name'] == 'ErrorResponse' + assert msg['header']['namespace'] == 'Alexa' + assert msg['payload']['type'] == 'NO_SUCH_ENDPOINT' + + +@asyncio.coroutine +def test_api_function_not_implemented(hass): + """Test api call that is not implemented to us.""" + request = get_new_request('Alexa.HAHAAH', 'Sweet') + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + + assert msg['header']['name'] == 'ErrorResponse' + assert msg['header']['namespace'] == 'Alexa' + assert msg['payload']['type'] == 'INTERNAL_ERROR' @asyncio.coroutine @pytest.mark.parametrize("domain", ['light', 'switch']) def test_api_turn_on(hass, domain): """Test api turn on process.""" - msg = smart_home.api_message( - 'TurnOnRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': '{}#test'.format(domain) - } - }) + request = get_new_request( + 'Alexa.PowerController', 'TurnOn', '{}#test'.format(domain)) # settup test devices hass.states.async_set( @@ -112,22 +200,22 @@ def test_api_turn_on(hass, domain): call = async_mock_service(hass, domain, 'turn_on') - resp = yield from smart_home.async_api_turn_on(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) - assert resp['header']['name'] == 'TurnOnConfirmation' + assert msg['header']['name'] == 'Response' @asyncio.coroutine @pytest.mark.parametrize("domain", ['light', 'switch']) def test_api_turn_off(hass, domain): """Test api turn on process.""" - msg = smart_home.api_message( - 'TurnOffRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': '{}#test'.format(domain) - } - }) + request = get_new_request( + 'Alexa.PowerController', 'TurnOff', '{}#test'.format(domain)) # settup test devices hass.states.async_set( @@ -137,24 +225,24 @@ def test_api_turn_off(hass, domain): call = async_mock_service(hass, domain, 'turn_off') - resp = yield from smart_home.async_api_turn_off(hass, msg) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call) == 1 assert call[0].data['entity_id'] == '{}.test'.format(domain) - assert resp['header']['name'] == 'TurnOffConfirmation' + assert msg['header']['name'] == 'Response' @asyncio.coroutine -def test_api_set_percentage_light(hass): +def test_api_set_brightness(hass): """Test api set brightness process.""" - msg_light = smart_home.api_message( - 'SetPercentageRequest', 'Alexa.ConnectedHome.Control', { - 'appliance': { - 'applianceId': 'light#test' - }, - 'percentageState': { - 'value': '50' - } - }) + request = get_new_request( + 'Alexa.BrightnessController', 'SetBrightness', 'light#test') + + # add payload + request['directive']['payload']['brightness'] = '50' # settup test devices hass.states.async_set( @@ -162,8 +250,12 @@ def test_api_set_percentage_light(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - resp = yield from smart_home.async_api_set_percentage(hass, msg_light) + msg = yield from smart_home.async_handle_message(hass, request) + + assert 'event' in msg + msg = msg['event'] + assert len(call_light) == 1 assert call_light[0].data['entity_id'] == 'light.test' assert call_light[0].data['brightness'] == '50' - assert resp['header']['name'] == 'SetPercentageConfirmation' + assert msg['header']['name'] == 'Response' diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index b4686650057..bde34f7fb9f 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -74,6 +74,34 @@ class TestAutomationEvent(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + def test_if_fires_on_event_with_nested_data(self): + """Test the firing of events with nested data.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + 'event_data': { + 'parent_attr': { + 'some_attr': 'some_value' + } + } + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.bus.fire('test_event', { + 'parent_attr': { + 'some_attr': 'some_value', + 'another': 'value' + } + }) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_not_fires_if_event_data_not_matches(self): """Test firing of event if no match.""" assert setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 0a7db4a122d..cb36a91dddb 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -704,3 +704,37 @@ class TestAutomationNumericState(unittest.TestCase): fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) self.hass.block_till_done() self.assertEqual(1, len(self.calls)) + + def test_wait_template_with_trigger(self): + """Test using wait template with 'trigger.entity_id'.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 10, + }, + 'action': [ + {'wait_template': + "{{ states(trigger.entity_id) | int < 10 }}"}, + {'service': 'test.automation', + 'data_template': { + 'some': + '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', 'to_state.state')) + }} + ], + } + }) + + self.hass.block_till_done() + self.calls = [] + + self.hass.states.set('test.entity', '12') + self.hass.block_till_done() + self.hass.states.set('test.entity', '8') + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'numeric_state - test.entity - 12', + self.calls[0].data['some']) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 2fd6c8415db..1f245d1cf5c 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -506,3 +506,38 @@ class TestAutomationState(unittest.TestCase): }, 'action': {'service': 'test.automation'}, }}) + + def test_wait_template_with_trigger(self): + """Test using wait template with 'trigger.entity_id'.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'state', + 'entity_id': 'test.entity', + 'to': 'world', + }, + 'action': [ + {'wait_template': + "{{ is_state(trigger.entity_id, 'hello') }}"}, + {'service': 'test.automation', + 'data_template': { + 'some': + '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', 'from_state.state', + 'to_state.state')) + }} + ], + } + }) + + self.hass.block_till_done() + self.calls = [] + + self.hass.states.set('test.entity', 'world') + self.hass.block_till_done() + self.hass.states.set('test.entity', 'hello') + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'state - test.entity - hello - world', + self.calls[0].data['some']) diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 5cc47687665..937fa16988a 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -399,3 +399,38 @@ class TestAutomationTemplate(unittest.TestCase): self.hass.states.set('test.entity', 'world') self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + + def test_wait_template_with_trigger(self): + """Test using wait template with 'trigger.entity_id'.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'template', + 'value_template': + "{{ states.test.entity.state == 'world' }}", + }, + 'action': [ + {'wait_template': + "{{ is_state(trigger.entity_id, 'hello') }}"}, + {'service': 'test.automation', + 'data_template': { + 'some': + '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', 'from_state.state', + 'to_state.state')) + }} + ], + } + }) + + self.hass.block_till_done() + self.calls = [] + + self.hass.states.set('test.entity', 'world') + self.hass.block_till_done() + self.hass.states.set('test.entity', 'hello') + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'template - test.entity - hello - world', + self.calls[0].data['some']) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 11163d42ab5..481226c4f73 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -4,7 +4,6 @@ from datetime import timedelta import unittest from unittest import mock -from homeassistant.core import CoreState, State from homeassistant.const import MATCH_ALL from homeassistant import setup from homeassistant.components.binary_sensor import template @@ -12,11 +11,9 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe import homeassistant.util.dt as dt_util -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component, - async_fire_time_changed) + get_test_home_assistant, assert_setup_component, async_fire_time_changed) class TestBinarySensorTemplate(unittest.TestCase): @@ -169,41 +166,6 @@ class TestBinarySensorTemplate(unittest.TestCase): run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() -@asyncio.coroutine -def test_restore_state(hass): - """Ensure states are restored on startup.""" - hass.data[DATA_RESTORE_CACHE] = { - 'binary_sensor.test': State('binary_sensor.test', 'on'), - } - - hass.state = CoreState.starting - mock_component(hass, 'recorder') - - config = { - 'binary_sensor': { - 'platform': 'template', - 'sensors': { - 'test': { - 'friendly_name': 'virtual thingy', - 'value_template': - "{{ states.sensor.test_state.state == 'on' }}", - 'device_class': 'motion', - }, - }, - }, - } - yield from setup.async_setup_component(hass, 'binary_sensor', config) - - state = hass.states.get('binary_sensor.test') - assert state.state == 'on' - - yield from hass.async_start() - yield from hass.async_block_till_done() - - state = hass.states.get('binary_sensor.test') - assert state.state == 'off' - - @asyncio.coroutine def test_template_delay_on(hass): """Test binary sensor template delay on.""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index 652829d2f32..d9f005fdcfa 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -4,35 +4,7 @@ from unittest.mock import MagicMock, patch from botocore.exceptions import ClientError import pytest -from homeassistant.components.cloud import DOMAIN, auth_api - - -MOCK_AUTH = { - "id_token": "fake_id_token", - "access_token": "fake_access_token", - "refresh_token": "fake_refresh_token", -} - - -@pytest.fixture -def cloud_hass(hass): - """Fixture to return a hass instance with cloud mode set.""" - hass.data[DOMAIN] = {'mode': 'development'} - return hass - - -@pytest.fixture -def mock_write(): - """Mock reading authentication.""" - with patch.object(auth_api, '_write_info') as mock: - yield mock - - -@pytest.fixture -def mock_read(): - """Mock writing authentication.""" - with patch.object(auth_api, '_read_info') as mock: - yield mock +from homeassistant.components.cloud import auth_api @pytest.fixture @@ -42,13 +14,6 @@ def mock_cognito(): yield mock_cog() -@pytest.fixture -def mock_auth(): - """Mock warrant.""" - with patch('homeassistant.components.cloud.auth_api.Auth') as mock_auth: - yield mock_auth() - - def aws_error(code, message='Unknown', operation_name='fake_operation_name'): """Generate AWS error response.""" response = { @@ -60,159 +25,64 @@ def aws_error(code, message='Unknown', operation_name='fake_operation_name'): return ClientError(response, operation_name) -def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): - """Test loading authentication with no stored auth.""" - mock_read.return_value = None - auth = auth_api.load_auth(cloud_hass) - assert auth.cognito is None - - -def test_load_auth_with_invalid_auth(cloud_hass, mock_read, mock_cognito): - """Test calling load_auth when auth is no longer valid.""" - mock_cognito.get_user.side_effect = aws_error('SomeError') - auth = auth_api.load_auth(cloud_hass) - - assert auth.cognito is None - - -def test_load_auth_with_valid_auth(cloud_hass, mock_read, mock_cognito): - """Test calling load_auth when valid auth.""" - auth = auth_api.load_auth(cloud_hass) - - assert auth.cognito is not None - - -def test_auth_properties(): - """Test Auth class properties.""" - auth = auth_api.Auth(None, None) - assert not auth.is_logged_in - auth.account = {} - assert auth.is_logged_in - - -def test_auth_validate_auth_verification_fails(mock_cognito): - """Test validate authentication with verify request failing.""" - mock_cognito.get_user.side_effect = aws_error('UserNotFoundException') - - auth = auth_api.Auth(None, mock_cognito) - assert auth.validate_auth() is False - - -def test_auth_validate_auth_token_refresh_needed_fails(mock_cognito): - """Test validate authentication with refresh needed which gets 401.""" - mock_cognito.get_user.side_effect = aws_error('NotAuthorizedException') - mock_cognito.renew_access_token.side_effect = \ - aws_error('NotAuthorizedException') - - auth = auth_api.Auth(None, mock_cognito) - assert auth.validate_auth() is False - - -def test_auth_validate_auth_token_refresh_needed_succeeds(mock_write, - mock_cognito): - """Test validate authentication with refresh.""" - mock_cognito.get_user.side_effect = [ - aws_error('NotAuthorizedException'), - MagicMock(email='hello@home-assistant.io') - ] - - auth = auth_api.Auth(None, mock_cognito) - assert auth.validate_auth() is True - assert len(mock_write.mock_calls) == 1 - - -def test_auth_login_invalid_auth(mock_cognito, mock_write): +def test_login_invalid_auth(mock_cognito): """Test trying to login with invalid credentials.""" + cloud = MagicMock(is_logged_in=False) mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') - auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.Unauthenticated): - auth.login('user', 'pass') + auth_api.login(cloud, 'user', 'pass') - assert not auth.is_logged_in - assert len(mock_cognito.get_user.mock_calls) == 0 - assert len(mock_write.mock_calls) == 0 + assert len(cloud.write_user_info.mock_calls) == 0 -def test_auth_login_user_not_found(mock_cognito, mock_write): +def test_login_user_not_found(mock_cognito): """Test trying to login with invalid credentials.""" + cloud = MagicMock(is_logged_in=False) mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') - auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotFound): - auth.login('user', 'pass') + auth_api.login(cloud, 'user', 'pass') - assert not auth.is_logged_in - assert len(mock_cognito.get_user.mock_calls) == 0 - assert len(mock_write.mock_calls) == 0 + assert len(cloud.write_user_info.mock_calls) == 0 -def test_auth_login_user_not_confirmed(mock_cognito, mock_write): +def test_login_user_not_confirmed(mock_cognito): """Test trying to login without confirming account.""" + cloud = MagicMock(is_logged_in=False) mock_cognito.authenticate.side_effect = \ aws_error('UserNotConfirmedException') - auth = auth_api.Auth(None, None) + with pytest.raises(auth_api.UserNotConfirmed): - auth.login('user', 'pass') + auth_api.login(cloud, 'user', 'pass') - assert not auth.is_logged_in - assert len(mock_cognito.get_user.mock_calls) == 0 - assert len(mock_write.mock_calls) == 0 + assert len(cloud.write_user_info.mock_calls) == 0 -def test_auth_login(cloud_hass, mock_cognito, mock_write): +def test_login(mock_cognito): """Test trying to login without confirming account.""" - mock_cognito.get_user.return_value = \ - MagicMock(email='hello@home-assistant.io') - auth = auth_api.Auth(cloud_hass, None) - auth.login('user', 'pass') - assert auth.is_logged_in + cloud = MagicMock(is_logged_in=False) + mock_cognito.id_token = 'test_id_token' + mock_cognito.access_token = 'test_access_token' + mock_cognito.refresh_token = 'test_refresh_token' + + auth_api.login(cloud, 'user', 'pass') + assert len(mock_cognito.authenticate.mock_calls) == 1 - assert len(mock_write.mock_calls) == 1 - result_hass, result_auth = mock_write.mock_calls[0][1] - assert result_hass is cloud_hass - assert result_auth is auth - - -def test_auth_renew_access_token(mock_write, mock_cognito): - """Test renewing an access token.""" - auth = auth_api.Auth(None, mock_cognito) - assert auth.renew_access_token() - assert len(mock_write.mock_calls) == 1 - - -def test_auth_renew_access_token_fails(mock_write, mock_cognito): - """Test failing to renew an access token.""" - mock_cognito.renew_access_token.side_effect = aws_error('SomeError') - auth = auth_api.Auth(None, mock_cognito) - assert not auth.renew_access_token() - assert len(mock_write.mock_calls) == 0 - - -def test_auth_logout(mock_write, mock_cognito): - """Test renewing an access token.""" - auth = auth_api.Auth(None, mock_cognito) - auth.account = MagicMock() - auth.logout() - assert auth.account is None - assert len(mock_write.mock_calls) == 1 - - -def test_auth_logout_fails(mock_write, mock_cognito): - """Test error while logging out.""" - mock_cognito.logout.side_effect = aws_error('SomeError') - auth = auth_api.Auth(None, mock_cognito) - auth.account = MagicMock() - with pytest.raises(auth_api.CloudError): - auth.logout() - assert auth.account is not None - assert len(mock_write.mock_calls) == 0 + assert cloud.email == 'user' + assert cloud.id_token == 'test_id_token' + assert cloud.access_token == 'test_access_token' + assert cloud.refresh_token == 'test_refresh_token' + assert len(cloud.write_user_info.mock_calls) == 1 def test_register(mock_cognito): """Test registering an account.""" auth_api.register(None, 'email@home-assistant.io', 'password') assert len(mock_cognito.register.mock_calls) == 1 - result_email, result_password = mock_cognito.register.mock_calls[0][1] - assert result_email == 'email@home-assistant.io' + result_user, result_password = mock_cognito.register.mock_calls[0][1] + assert result_user == \ + auth_api._generate_username('email@home-assistant.io') assert result_password == 'password' @@ -227,8 +97,9 @@ def test_confirm_register(mock_cognito): """Test confirming a registration of an account.""" auth_api.confirm_register(None, '123456', 'email@home-assistant.io') assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 - result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_email == 'email@home-assistant.io' + result_code, result_user = mock_cognito.confirm_sign_up.mock_calls[0][1] + assert result_user == \ + auth_api._generate_username('email@home-assistant.io') assert result_code == '123456' @@ -269,3 +140,45 @@ def test_confirm_forgot_password_fails(mock_cognito): with pytest.raises(auth_api.CloudError): auth_api.confirm_forgot_password( None, '123456', 'email@home-assistant.io', 'new password') + + +def test_check_token_writes_new_token_on_refresh(mock_cognito): + """Test check_token writes new token if refreshed.""" + cloud = MagicMock() + mock_cognito.check_token.return_value = True + mock_cognito.id_token = 'new id token' + mock_cognito.access_token = 'new access token' + + auth_api.check_token(cloud) + + assert len(mock_cognito.check_token.mock_calls) == 1 + assert cloud.id_token == 'new id token' + assert cloud.access_token == 'new access token' + assert len(cloud.write_user_info.mock_calls) == 1 + + +def test_check_token_does_not_write_existing_token(mock_cognito): + """Test check_token won't write new token if still valid.""" + cloud = MagicMock() + mock_cognito.check_token.return_value = False + + auth_api.check_token(cloud) + + assert len(mock_cognito.check_token.mock_calls) == 1 + assert cloud.id_token != mock_cognito.id_token + assert cloud.access_token != mock_cognito.access_token + assert len(cloud.write_user_info.mock_calls) == 0 + + +def test_check_token_raises(mock_cognito): + """Test we raise correct error.""" + cloud = MagicMock() + mock_cognito.check_token.side_effect = aws_error('SomeError') + + with pytest.raises(auth_api.CloudError): + auth_api.check_token(cloud) + + assert len(mock_cognito.check_token.mock_calls) == 1 + assert cloud.id_token != mock_cognito.id_token + assert cloud.access_token != mock_cognito.access_token + assert len(cloud.write_user_info.mock_calls) == 0 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index e79f23c0845..1090acb01e9 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,25 +7,25 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components.cloud import DOMAIN, auth_api +from tests.common import mock_coro + @pytest.fixture def cloud_client(hass, test_client): """Fixture that can fetch from the cloud client.""" - hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { - 'cloud': { - 'mode': 'development' - } - })) + with patch('homeassistant.components.cloud.Cloud.initialize'): + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development', + 'cognito_client_id': 'cognito_client_id', + 'user_pool_id': 'user_pool_id', + 'region': 'region', + 'relayer': 'relayer', + } + })) return hass.loop.run_until_complete(test_client(hass.http.app)) -@pytest.fixture -def mock_auth(cloud_client, hass): - """Fixture to mock authentication.""" - auth = hass.data[DOMAIN]['auth'] = MagicMock() - return auth - - @pytest.fixture def mock_cognito(): """Mock warrant.""" @@ -41,9 +41,9 @@ def test_account_view_no_account(cloud_client): @asyncio.coroutine -def test_account_view(mock_auth, cloud_client): +def test_account_view(hass, cloud_client): """Test fetching account if no account available.""" - mock_auth.account = MagicMock(email='hello@home-assistant.io') + hass.data[DOMAIN].email = 'hello@home-assistant.io' req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() @@ -51,99 +51,112 @@ def test_account_view(mock_auth, cloud_client): @asyncio.coroutine -def test_login_view(mock_auth, cloud_client): +def test_login_view(hass, cloud_client): """Test logging in.""" - mock_auth.account = MagicMock(email='hello@home-assistant.io') - req = yield from cloud_client.post('/api/cloud/login', json={ - 'email': 'my_username', - 'password': 'my_password' - }) + hass.data[DOMAIN].email = 'hello@home-assistant.io' + + with patch('homeassistant.components.cloud.iot.CloudIoT.connect'), \ + patch('homeassistant.components.cloud.' + 'auth_api.login') as mock_login: + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 200 result = yield from req.json() assert result == {'email': 'hello@home-assistant.io'} - assert len(mock_auth.login.mock_calls) == 1 - result_user, result_pass = mock_auth.login.mock_calls[0][1] + assert len(mock_login.mock_calls) == 1 + cloud, result_user, result_pass = mock_login.mock_calls[0][1] assert result_user == 'my_username' assert result_pass == 'my_password' @asyncio.coroutine -def test_login_view_invalid_json(mock_auth, cloud_client): +def test_login_view_invalid_json(cloud_client): """Try logging in with invalid JSON.""" - req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + with patch('homeassistant.components.cloud.auth_api.login') as mock_login: + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') assert req.status == 400 - assert len(mock_auth.mock_calls) == 0 + assert len(mock_login.mock_calls) == 0 @asyncio.coroutine -def test_login_view_invalid_schema(mock_auth, cloud_client): +def test_login_view_invalid_schema(cloud_client): """Try logging in with invalid schema.""" - req = yield from cloud_client.post('/api/cloud/login', json={ - 'invalid': 'schema' - }) + with patch('homeassistant.components.cloud.auth_api.login') as mock_login: + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) assert req.status == 400 - assert len(mock_auth.mock_calls) == 0 + assert len(mock_login.mock_calls) == 0 @asyncio.coroutine -def test_login_view_request_timeout(mock_auth, cloud_client): +def test_login_view_request_timeout(cloud_client): """Test request timeout while trying to log in.""" - mock_auth.login.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/login', json={ - 'email': 'my_username', - 'password': 'my_password' - }) + with patch('homeassistant.components.cloud.auth_api.login', + side_effect=asyncio.TimeoutError): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 502 @asyncio.coroutine -def test_login_view_invalid_credentials(mock_auth, cloud_client): +def test_login_view_invalid_credentials(cloud_client): """Test logging in with invalid credentials.""" - mock_auth.login.side_effect = auth_api.Unauthenticated - req = yield from cloud_client.post('/api/cloud/login', json={ - 'email': 'my_username', - 'password': 'my_password' - }) + with patch('homeassistant.components.cloud.auth_api.login', + side_effect=auth_api.Unauthenticated): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 401 @asyncio.coroutine -def test_login_view_unknown_error(mock_auth, cloud_client): +def test_login_view_unknown_error(cloud_client): """Test unknown error while logging in.""" - mock_auth.login.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/login', json={ - 'email': 'my_username', - 'password': 'my_password' - }) + with patch('homeassistant.components.cloud.auth_api.login', + side_effect=auth_api.UnknownError): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'email': 'my_username', + 'password': 'my_password' + }) assert req.status == 502 @asyncio.coroutine -def test_logout_view(mock_auth, cloud_client): +def test_logout_view(hass, cloud_client): """Test logging out.""" + cloud = hass.data['cloud'] = MagicMock() + cloud.logout.return_value = mock_coro() req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 200 data = yield from req.json() assert data == {'message': 'ok'} - assert len(mock_auth.logout.mock_calls) == 1 + assert len(cloud.logout.mock_calls) == 1 @asyncio.coroutine -def test_logout_view_request_timeout(mock_auth, cloud_client): +def test_logout_view_request_timeout(hass, cloud_client): """Test timeout while logging out.""" - mock_auth.logout.side_effect = asyncio.TimeoutError + cloud = hass.data['cloud'] = MagicMock() + cloud.logout.side_effect = asyncio.TimeoutError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 @asyncio.coroutine -def test_logout_view_unknown_error(mock_auth, cloud_client): +def test_logout_view_unknown_error(hass, cloud_client): """Test unknown error while logging out.""" - mock_auth.logout.side_effect = auth_api.UnknownError + cloud = hass.data['cloud'] = MagicMock() + cloud.logout.side_effect = auth_api.UnknownError req = yield from cloud_client.post('/api/cloud/logout') assert req.status == 502 @@ -158,7 +171,7 @@ def test_register_view(mock_cognito, cloud_client): assert req.status == 200 assert len(mock_cognito.register.mock_calls) == 1 result_email, result_pass = mock_cognito.register.mock_calls[0][1] - assert result_email == 'hello@bla.com' + assert result_email == auth_api._generate_username('hello@bla.com') assert result_pass == 'falcon42' @@ -205,7 +218,7 @@ def test_confirm_register_view(mock_cognito, cloud_client): assert req.status == 200 assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_email == 'hello@bla.com' + assert result_email == auth_api._generate_username('hello@bla.com') assert result_code == '123456' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py new file mode 100644 index 00000000000..1eb1051520f --- /dev/null +++ b/tests/components/cloud/test_init.py @@ -0,0 +1,135 @@ +"""Test the cloud component.""" +import asyncio +import json +from unittest.mock import patch, MagicMock, mock_open + +import pytest + +from homeassistant.components import cloud + +from tests.common import mock_coro + + +@pytest.fixture +def mock_os(): + """Mock os module.""" + with patch('homeassistant.components.cloud.os') as os: + os.path.isdir.return_value = True + yield os + + +@asyncio.coroutine +def test_constructor_loads_info_from_constant(): + """Test non-dev mode loads info from SERVERS constant.""" + hass = MagicMock(data={}) + with patch.dict(cloud.SERVERS, { + 'beer': { + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }): + result = yield from cloud.async_setup(hass, { + 'cloud': {cloud.CONF_MODE: 'beer'} + }) + assert result + + cl = hass.data['cloud'] + assert cl.mode == 'beer' + assert cl.cognito_client_id == 'test-cognito_client_id' + assert cl.user_pool_id == 'test-user_pool_id' + assert cl.region == 'test-region' + assert cl.relayer == 'test-relayer' + + +@asyncio.coroutine +def test_constructor_loads_info_from_config(): + """Test non-dev mode loads info from SERVERS constant.""" + hass = MagicMock(data={}) + + result = yield from cloud.async_setup(hass, { + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) + assert result + + cl = hass.data['cloud'] + assert cl.mode == cloud.MODE_DEV + assert cl.cognito_client_id == 'test-cognito_client_id' + assert cl.user_pool_id == 'test-user_pool_id' + assert cl.region == 'test-region' + assert cl.relayer == 'test-relayer' + + +@asyncio.coroutine +def test_initialize_loads_info(mock_os, hass): + """Test initialize will load info from config file.""" + mock_os.path.isfile.return_value = True + mopen = mock_open(read_data=json.dumps({ + 'email': 'test-email', + 'id_token': 'test-id-token', + 'access_token': 'test-access-token', + 'refresh_token': 'test-refresh-token', + })) + + cl = cloud.Cloud(hass, cloud.MODE_DEV) + cl.iot = MagicMock() + cl.iot.connect.return_value = mock_coro() + + with patch('homeassistant.components.cloud.open', mopen, create=True): + yield from cl.initialize() + + assert cl.email == 'test-email' + assert cl.id_token == 'test-id-token' + assert cl.access_token == 'test-access-token' + assert cl.refresh_token == 'test-refresh-token' + assert len(cl.iot.connect.mock_calls) == 1 + + +@asyncio.coroutine +def test_logout_clears_info(mock_os, hass): + """Test logging out disconnects and removes info.""" + cl = cloud.Cloud(hass, cloud.MODE_DEV) + cl.iot = MagicMock() + cl.iot.disconnect.return_value = mock_coro() + + yield from cl.logout() + + assert len(cl.iot.disconnect.mock_calls) == 1 + assert cl.email is None + assert cl.id_token is None + assert cl.access_token is None + assert cl.refresh_token is None + assert len(mock_os.remove.mock_calls) == 1 + + +@asyncio.coroutine +def test_write_user_info(): + """Test writing user info works.""" + mopen = mock_open() + + cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV) + cl.email = 'test-email' + cl.id_token = 'test-id-token' + cl.access_token = 'test-access-token' + cl.refresh_token = 'test-refresh-token' + + with patch('homeassistant.components.cloud.open', mopen, create=True): + cl.write_user_info() + + handle = mopen() + + assert len(handle.write.mock_calls) == 1 + data = json.loads(handle.write.mock_calls[0][1][0]) + assert data == { + 'access_token': 'test-access-token', + 'email': 'test-email', + 'id_token': 'test-id-token', + 'refresh_token': 'test-refresh-token', + } diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py new file mode 100644 index 00000000000..f1254cdb3c7 --- /dev/null +++ b/tests/components/cloud/test_iot.py @@ -0,0 +1,243 @@ +"""Test the cloud.iot module.""" +import asyncio +from unittest.mock import patch, MagicMock, PropertyMock + +from aiohttp import WSMsgType, client_exceptions +import pytest + +from homeassistant.components.cloud import iot, auth_api +from tests.common import mock_coro + + +@pytest.fixture +def mock_client(): + """Mock the IoT client.""" + client = MagicMock() + type(client).closed = PropertyMock(side_effect=[False, True]) + + with patch('asyncio.sleep'), \ + patch('homeassistant.components.cloud.iot' + '.async_get_clientsession') as session: + session().ws_connect.return_value = mock_coro(client) + yield client + + +@pytest.fixture +def mock_handle_message(): + """Mock handle message.""" + with patch('homeassistant.components.cloud.iot' + '.async_handle_message') as mock: + yield mock + + +@asyncio.coroutine +def test_cloud_calling_handler(mock_client, mock_handle_message): + """Test we call handle message with correct info.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.text, + json=MagicMock(return_value={ + 'msgid': 'test-msg-id', + 'handler': 'test-handler', + 'payload': 'test-payload' + }) + )) + mock_handle_message.return_value = mock_coro('response') + mock_client.send_json.return_value = mock_coro(None) + + yield from conn.connect() + + # Check that we sent message to handler correctly + assert len(mock_handle_message.mock_calls) == 1 + p_hass, p_cloud, handler_name, payload = \ + mock_handle_message.mock_calls[0][1] + + assert p_hass is cloud.hass + assert p_cloud is cloud + assert handler_name == 'test-handler' + assert payload == 'test-payload' + + # Check that we forwarded response from handler to cloud + assert len(mock_client.send_json.mock_calls) == 1 + assert mock_client.send_json.mock_calls[0][1][0] == { + 'msgid': 'test-msg-id', + 'payload': 'response' + } + + +@asyncio.coroutine +def test_connection_msg_for_unknown_handler(mock_client): + """Test a msg for an unknown handler.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.text, + json=MagicMock(return_value={ + 'msgid': 'test-msg-id', + 'handler': 'non-existing-handler', + 'payload': 'test-payload' + }) + )) + mock_client.send_json.return_value = mock_coro(None) + + yield from conn.connect() + + # Check that we sent the correct error + assert len(mock_client.send_json.mock_calls) == 1 + assert mock_client.send_json.mock_calls[0][1][0] == { + 'msgid': 'test-msg-id', + 'error': 'unknown-handler', + } + + +@asyncio.coroutine +def test_connection_msg_for_handler_raising(mock_client, mock_handle_message): + """Test we sent error when handler raises exception.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.text, + json=MagicMock(return_value={ + 'msgid': 'test-msg-id', + 'handler': 'test-handler', + 'payload': 'test-payload' + }) + )) + mock_handle_message.side_effect = Exception('Broken') + mock_client.send_json.return_value = mock_coro(None) + + yield from conn.connect() + + # Check that we sent the correct error + assert len(mock_client.send_json.mock_calls) == 1 + assert mock_client.send_json.mock_calls[0][1][0] == { + 'msgid': 'test-msg-id', + 'error': 'exception', + } + + +@asyncio.coroutine +def test_handler_forwarding(): + """Test we forward messages to correct handler.""" + handler = MagicMock() + handler.return_value = mock_coro() + hass = object() + cloud = object() + with patch.dict(iot.HANDLERS, {'test': handler}): + yield from iot.async_handle_message( + hass, cloud, 'test', 'payload') + + assert len(handler.mock_calls) == 1 + r_hass, r_cloud, payload = handler.mock_calls[0][1] + assert r_hass is hass + assert r_cloud is cloud + assert payload == 'payload' + + +@asyncio.coroutine +def test_handling_core_messages(hass): + """Test handling core messages.""" + cloud = MagicMock() + cloud.logout.return_value = mock_coro() + yield from iot.async_handle_cloud(hass, cloud, { + 'action': 'logout', + 'reason': 'Logged in at two places.' + }) + assert len(cloud.logout.mock_calls) == 1 + + +@asyncio.coroutine +def test_cloud_getting_disconnected_by_server(mock_client, caplog): + """Test server disconnecting instance.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.CLOSING, + )) + + yield from conn.connect() + + assert 'Connection closed: Closed by server' in caplog.text + assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + + +@asyncio.coroutine +def test_cloud_receiving_bytes(mock_client, caplog): + """Test server disconnecting instance.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.BINARY, + )) + + yield from conn.connect() + + assert 'Connection closed: Received non-Text message' in caplog.text + assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + + +@asyncio.coroutine +def test_cloud_sending_invalid_json(mock_client, caplog): + """Test cloud sending invalid JSON.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.return_value = mock_coro(MagicMock( + type=WSMsgType.TEXT, + json=MagicMock(side_effect=ValueError) + )) + + yield from conn.connect() + + assert 'Connection closed: Received invalid JSON.' in caplog.text + assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + + +@asyncio.coroutine +def test_cloud_check_token_raising(mock_client, caplog): + """Test cloud sending invalid JSON.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.side_effect = auth_api.CloudError + + yield from conn.connect() + + assert 'Unable to connect: Unable to refresh token.' in caplog.text + assert 'connect' in str(cloud.hass.async_add_job.mock_calls[-1][1][0]) + + +@asyncio.coroutine +def test_cloud_connect_invalid_auth(mock_client, caplog): + """Test invalid auth detected by server.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.side_effect = \ + client_exceptions.WSServerHandshakeError(None, None, code=401) + + yield from conn.connect() + + assert 'Connection closed: Invalid auth.' in caplog.text + + +@asyncio.coroutine +def test_cloud_unable_to_connect(mock_client, caplog): + """Test unable to connect error.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.side_effect = client_exceptions.ClientError(None, None) + + yield from conn.connect() + + assert 'Unable to connect:' in caplog.text + + +@asyncio.coroutine +def test_cloud_random_exception(mock_client, caplog): + """Test random exception.""" + cloud = MagicMock() + conn = iot.CloudIoT(cloud) + mock_client.receive.side_effect = Exception + + yield from conn.connect() + + assert 'Unexpected error' in caplog.text diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 92cb84cba2f..ecdbe0085ee 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -11,7 +11,6 @@ import os from homeassistant.components import zone from homeassistant.core import callback, State from homeassistant.setup import setup_component -from homeassistant.helpers import discovery from homeassistant.loader import get_component from homeassistant.util.async import run_coroutine_threadsafe import homeassistant.util.dt as dt_util @@ -23,7 +22,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.remote import JSONEncoder from tests.common import ( - get_test_home_assistant, fire_time_changed, fire_service_discovered, + get_test_home_assistant, fire_time_changed, patch_yaml_files, assert_setup_component, mock_restore_cache, mock_coro) from ...test_util.aiohttp import mock_aiohttp_client @@ -311,36 +310,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): 'No http request for macvendor made!' self.assertEqual(tracker.devices['b827eb000000'].vendor, vendor_string) - def test_discovery(self): - """Test discovery.""" - scanner = get_component('device_tracker.test').SCANNER - - with patch.dict(device_tracker.DISCOVERY_PLATFORMS, {'test': 'test'}): - with patch.object(scanner, 'scan_devices', - autospec=True) as mock_scan: - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component( - self.hass, device_tracker.DOMAIN, TEST_PLATFORM) - fire_service_discovered(self.hass, 'test', {}) - self.assertTrue(mock_scan.called) - - @patch( - 'homeassistant.components.device_tracker.DeviceTracker.see') - @patch( - 'homeassistant.components.device_tracker.demo.setup_scanner', - autospec=True) - def test_discover_platform(self, mock_demo_setup_scanner, mock_see): - """Test discovery of device_tracker demo platform.""" - assert device_tracker.DOMAIN not in self.hass.config.components - discovery.load_platform( - self.hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, - {}) - self.hass.block_till_done() - assert device_tracker.DOMAIN in self.hass.config.components - assert mock_demo_setup_scanner.called - assert mock_demo_setup_scanner.call_args[0] == ( - self.hass, {}, mock_see, {'test_key': 'test_val'}) - def test_update_stale(self): """Test stalled update.""" scanner = get_component('device_tracker.test').SCANNER diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 3a23fe61d41..eb163fdcbdf 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,10 +4,9 @@ import json import unittest from unittest.mock import patch -from tests.common import (assert_setup_component, fire_mqtt_message, mock_coro, - get_test_home_assistant, mock_mqtt_component, - mock_component) - +from tests.common import ( + assert_setup_component, fire_mqtt_message, mock_coro, mock_component, + get_test_home_assistant, mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -154,10 +153,12 @@ WAYPOINTS_UPDATED_MESSAGE = { ] } -WAYPOINT_ENTITY_NAMES = ['zone.greg_phone__exp_wayp1', - 'zone.greg_phone__exp_wayp2', - 'zone.ram_phone__exp_wayp1', - 'zone.ram_phone__exp_wayp2'] +WAYPOINT_ENTITY_NAMES = [ + 'zone.greg_phone__exp_wayp1', + 'zone.greg_phone__exp_wayp2', + 'zone.ram_phone__exp_wayp1', + 'zone.ram_phone__exp_wayp2', +] REGION_ENTER_ZERO_MESSAGE = { 'lon': 1.0, @@ -194,7 +195,8 @@ ENCRYPTED_LOCATION_MESSAGE = { '9pOw75Lo4gHcyy2wV5CmkjrpKEBR7Qhye4AR0y7hOvlx6U/a3GuY1+W8' 'I4smrLkwMvGgBOzXSNdVTzbFTHDvG3gRRaNHFkt2+5MsbH2Dd6CXmpzq' 'DIfSN7QzwOevuvNIElii5MlFxI6ZnYIDYA/ZdnAXHEVsNIbyT2N0CXt3' - 'fTPzgGtFzsufx40EEUkC06J7QTJl7lLG6qaLW1cCWp86Vp0eL3vtZ6xq')} + 'fTPzgGtFzsufx40EEUkC06J7QTJl7lLG6qaLW1cCWp86Vp0eL3vtZ6xq') +} MOCK_ENCRYPTED_LOCATION_MESSAGE = { # Mock-encrypted version of LOCATION_MESSAGE using pickle diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index d62897a86c4..083315b4c71 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -1,6 +1,9 @@ """The tests for the Unifi WAP device tracker platform.""" from unittest import mock from pyunifi.controller import APIError +import homeassistant.util.dt as dt_util +from datetime import timedelta + import pytest import voluptuous as vol @@ -8,6 +11,7 @@ import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN, unifi as unifi from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL) +DEFAULT_DETECTION_TIME = timedelta(seconds=300) @pytest.fixture @@ -25,6 +29,33 @@ def mock_scanner(): yield scanner +@mock.patch('os.access', return_value=True) +@mock.patch('os.path.isfile', mock.Mock(return_value=True)) +def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): + """Test the setup with a string for ssl_verify. + + Representing the absolute path to a CA certificate bundle. + """ + config = { + DOMAIN: unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', + CONF_VERIFY_SSL: "/tmp/unifi.crt" + }) + } + result = unifi.get_scanner(hass, config) + assert mock_scanner.return_value == result + assert mock_ctrl.call_count == 1 + assert mock_ctrl.mock_calls[0] == \ + mock.call('localhost', 'foo', 'password', 8443, + version='v4', site_id='default', ssl_verify="/tmp/unifi.crt") + + assert mock_scanner.call_count == 1 + assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, + DEFAULT_DETECTION_TIME) + + def test_config_minimal(hass, mock_scanner, mock_ctrl): """Test the setup with minimal configuration.""" config = { @@ -42,7 +73,8 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): version='v4', site_id='default', ssl_verify=True) assert mock_scanner.call_count == 1 - assert mock_scanner.call_args == mock.call(mock_ctrl.return_value) + assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, + DEFAULT_DETECTION_TIME) def test_config_full(hass, mock_scanner, mock_ctrl): @@ -56,6 +88,7 @@ def test_config_full(hass, mock_scanner, mock_ctrl): CONF_VERIFY_SSL: False, 'port': 123, 'site_id': 'abcdef01', + 'detection_time': 300, }) } result = unifi.get_scanner(hass, config) @@ -66,7 +99,8 @@ def test_config_full(hass, mock_scanner, mock_ctrl): version='v4', site_id='abcdef01', ssl_verify=False) assert mock_scanner.call_count == 1 - assert mock_scanner.call_args == mock.call(mock_ctrl.return_value) + assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, + DEFAULT_DETECTION_TIME) def test_config_error(): @@ -86,6 +120,13 @@ def test_config_error(): CONF_HOST: 'myhost', 'port': 'foo', # bad port! }) + with pytest.raises(vol.Invalid): + unifi.PLATFORM_SCHEMA({ + CONF_PLATFORM: unifi.DOMAIN, + CONF_USERNAME: 'foo', + CONF_PASSWORD: 'password', + CONF_VERIFY_SSL: "dfdsfsdfsd", # Invalid ssl_verify (no file) + }) def test_config_controller_failed(hass, mock_ctrl, mock_scanner): @@ -107,11 +148,11 @@ def test_scanner_update(): """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123'}, - {'mac': '234'}, + {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) assert ctrl.get_clients.call_count == 1 assert ctrl.get_clients.call_args == mock.call() @@ -121,18 +162,18 @@ def test_scanner_update_error(): ctrl = mock.MagicMock() ctrl.get_clients.side_effect = APIError( '/', 500, 'foo', {}, None) - unifi.UnifiScanner(ctrl) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) def test_scan_devices(): """Test the scanning for devices.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123'}, - {'mac': '234'}, + {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) assert set(scanner.scan_devices()) == set(['123', '234']) @@ -140,12 +181,17 @@ def test_get_device_name(): """Test the getting of device names.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123', 'hostname': 'foobar'}, - {'mac': '234', 'name': 'Nice Name'}, - {'mac': '456'}, + {'mac': '123', + 'hostname': 'foobar', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', + 'name': 'Nice Name', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '456', + 'last_seen': '1504786810'}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) assert scanner.get_device_name('123') == 'foobar' assert scanner.get_device_name('234') == 'Nice Name' assert scanner.get_device_name('456') is None diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py new file mode 100644 index 00000000000..df4826470d0 --- /dev/null +++ b/tests/components/google_assistant/__init__.py @@ -0,0 +1,173 @@ +"""Tests for the Google Assistant integration.""" + +DEMO_DEVICES = [{ + 'id': + 'light.kitchen_lights', + 'name': { + 'name': 'Kitchen Lights' + }, + 'traits': [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.ColorSpectrum', + 'action.devices.traits.ColorTemperature' + ], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': + 'light.ceiling_lights', + 'name': { + 'name': 'Roof Lights', + 'nicknames': ['top lights', 'ceiling lights'] + }, + 'traits': [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.ColorSpectrum', + 'action.devices.traits.ColorTemperature' + ], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': + 'light.bed_light', + 'name': { + 'name': 'Bed Light' + }, + 'traits': [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.ColorSpectrum', + 'action.devices.traits.ColorTemperature' + ], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': 'group.all_lights', + 'name': { + 'name': 'all lights' + }, + 'traits': ['action.devices.traits.Scene'], + 'type': 'action.devices.types.SCENE', + 'willReportState': False +}, { + 'id': + 'cover.living_room_window', + 'name': { + 'name': 'Living Room Window' + }, + 'traits': + ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': + 'cover.hall_window', + 'name': { + 'name': 'Hall Window' + }, + 'traits': + ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': 'cover.garage_door', + 'name': { + 'name': 'Garage Door' + }, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.LIGHT', + 'willReportState': False +}, { + 'id': 'cover.kitchen_window', + 'name': { + 'name': 'Kitchen Window' + }, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.LIGHT', + 'willReportState': False +}, { + 'id': 'group.all_covers', + 'name': { + 'name': 'all covers' + }, + 'traits': ['action.devices.traits.Scene'], + 'type': 'action.devices.types.SCENE', + 'willReportState': False +}, { + 'id': + 'media_player.bedroom', + 'name': { + 'name': 'Bedroom' + }, + 'traits': + ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': + 'media_player.living_room', + 'name': { + 'name': 'Living Room' + }, + 'traits': + ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': 'media_player.lounge_room', + 'name': { + 'name': 'Lounge room' + }, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.LIGHT', + 'willReportState': False +}, { + 'id': + 'media_player.walkman', + 'name': { + 'name': 'Walkman' + }, + 'traits': + ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + 'type': + 'action.devices.types.LIGHT', + 'willReportState': + False +}, { + 'id': 'fan.living_room_fan', + 'name': { + 'name': 'Living Room Fan' + }, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.SWITCH', + 'willReportState': False +}, { + 'id': 'fan.ceiling_fan', + 'name': { + 'name': 'Ceiling Fan' + }, + 'traits': ['action.devices.traits.OnOff'], + 'type': 'action.devices.types.SWITCH', + 'willReportState': False +}, { + 'id': 'group.all_fans', + 'name': { + 'name': 'all fans' + }, + 'traits': ['action.devices.traits.Scene'], + 'type': 'action.devices.types.SCENE', + 'willReportState': False +}] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py new file mode 100644 index 00000000000..5a7cac6afc2 --- /dev/null +++ b/tests/components/google_assistant/test_google_assistant.py @@ -0,0 +1,214 @@ +"""The tests for the Google Actions component.""" +# pylint: disable=protected-access +import json +import asyncio +import pytest + +from homeassistant import setup, const, core +from homeassistant.components import ( + http, async_setup, light, cover, media_player, fan +) +from homeassistant.components import google_assistant as ga +from tests.common import get_test_instance_port + +from . import DEMO_DEVICES + + +API_PASSWORD = "test1234" +SERVER_PORT = get_test_instance_port() +BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) + +HA_HEADERS = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD, + const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, +} + +AUTHCFG = { + 'project_id': 'hasstest-1234', + 'client_id': 'helloworld', + 'access_token': 'superdoublesecret' +} +AUTH_HEADER = {'Authorization': 'Bearer {}'.format(AUTHCFG['access_token'])} + + +@pytest.fixture +def assistant_client(loop, hass_fixture, test_client): + """Create web client for emulated hue api.""" + hass = hass_fixture + web_app = hass.http.app + + ga.http.GoogleAssistantView(hass, AUTHCFG).register(web_app.router) + ga.auth.GoogleAssistantAuthView(hass, AUTHCFG).register(web_app.router) + + return loop.run_until_complete(test_client(web_app)) + + +@pytest.fixture +def hass_fixture(loop, hass): + """Setup a hass instance for these tests.""" + # We need to do this to get access to homeassistant/turn_(on,off) + loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) + + loop.run_until_complete( + setup.async_setup_component(hass, http.DOMAIN, { + http.DOMAIN: { + http.CONF_SERVER_PORT: SERVER_PORT + } + })) + + loop.run_until_complete( + setup.async_setup_component(hass, light.DOMAIN, { + 'light': [{ + 'platform': 'demo' + }] + })) + loop.run_until_complete( + setup.async_setup_component(hass, cover.DOMAIN, { + 'cover': [{ + 'platform': 'demo' + }], + })) + + loop.run_until_complete( + setup.async_setup_component(hass, media_player.DOMAIN, { + 'media_player': [{ + 'platform': 'demo' + }] + })) + + loop.run_until_complete( + setup.async_setup_component(hass, fan.DOMAIN, { + 'fan': [{ + 'platform': 'demo' + }] + })) + + # Kitchen light is explicitly excluded from being exposed + ceiling_lights_entity = hass.states.get('light.ceiling_lights') + attrs = dict(ceiling_lights_entity.attributes) + attrs[ga.const.ATTR_GOOGLE_ASSISTANT_NAME] = "Roof Lights" + attrs[ga.const.CONF_ALIASES] = ['top lights', 'ceiling lights'] + hass.states.async_set( + ceiling_lights_entity.entity_id, + ceiling_lights_entity.state, + attributes=attrs) + + return hass + + +@asyncio.coroutine +def test_auth(hass_fixture, assistant_client): + """Test the auth process.""" + result = yield from assistant_client.get( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT + '/auth', + params={ + 'redirect_uri': + 'http://testurl/r/{}'.format(AUTHCFG['project_id']), + 'client_id': AUTHCFG['client_id'], + 'state': 'random1234', + }, + allow_redirects=False) + assert result.status == 301 + loc = result.headers.get('Location') + assert AUTHCFG['access_token'] in loc + + +@asyncio.coroutine +def test_sync_request(hass_fixture, assistant_client): + """Test a sync request.""" + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + # assert len(devices) == 4 + assert len(devices) == len(DEMO_DEVICES) + # HACK this is kind of slow and lazy + for dev in devices: + for demo in DEMO_DEVICES: + if dev['id'] == demo['id']: + assert dev['name'] == demo['name'] + assert set(dev['traits']) == set(demo['traits']) + assert dev['type'] == demo['type'] + + +@asyncio.coroutine +def test_query_request(hass_fixture, assistant_client): + """Test a query request.""" + # hass.states.set("light.bedroom", "on") + # hass.states.set("switch.outside", "off") + # res = _sync_req() + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [{ + 'id': "light.ceiling_lights", + }, { + 'id': "light.bed_light", + }] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + devices = body['payload']['devices'] + assert len(devices) == 2 + assert devices['light.bed_light']['on'] is False + assert devices['light.ceiling_lights']['on'] is True + assert devices['light.ceiling_lights']['brightness'] == 70 + + +@asyncio.coroutine +def test_execute_request(hass_fixture, assistant_client): + """Test a execute request.""" + # hass.states.set("light.bedroom", "on") + # hass.states.set("switch.outside", "off") + # res = _sync_req() + reqid = '5711642932632160985' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.EXECUTE', + 'payload': { + "commands": [{ + "devices": [{ + "id": "light.ceiling_lights", + }, { + "id": "light.bed_light", + }], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": False + } + }] + }] + } + }] + } + result = yield from assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=AUTH_HEADER) + assert result.status == 200 + body = yield from result.json() + assert body.get('requestId') == reqid + commands = body['payload']['commands'] + assert len(commands) == 2 + ceiling = hass_fixture.states.get('light.ceiling_lights') + assert ceiling.state == 'off' diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py new file mode 100644 index 00000000000..9b3c5eab037 --- /dev/null +++ b/tests/components/google_assistant/test_smart_home.py @@ -0,0 +1,87 @@ +"""The tests for the Google Actions component.""" +# pylint: disable=protected-access +import asyncio + +from homeassistant import const +from homeassistant.components import google_assistant as ga + +DETERMINE_SERVICE_TESTS = [{ # Test light brightness + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_BRIGHTNESS, + 'params': { + 'brightness': 95 + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'brightness': 242} + ) +}, { # Test light on / off + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_ONOFF, + 'params': { + 'on': False + }, + 'expected': (const.SERVICE_TURN_OFF, {'entity_id': 'light.test'}) +}, { + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_ONOFF, + 'params': { + 'on': True + }, + 'expected': (const.SERVICE_TURN_ON, {'entity_id': 'light.test'}) +}, { # Test Cover open close + 'entity_id': 'cover.bedroom', + 'command': ga.const.COMMAND_ONOFF, + 'params': { + 'on': True + }, + 'expected': (const.SERVICE_OPEN_COVER, {'entity_id': 'cover.bedroom'}), +}, { + 'entity_id': 'cover.bedroom', + 'command': ga.const.COMMAND_ONOFF, + 'params': { + 'on': False + }, + 'expected': (const.SERVICE_CLOSE_COVER, {'entity_id': 'cover.bedroom'}), +}, { # Test cover position + 'entity_id': 'cover.bedroom', + 'command': ga.const.COMMAND_BRIGHTNESS, + 'params': { + 'brightness': 50 + }, + 'expected': ( + const.SERVICE_SET_COVER_POSITION, + {'entity_id': 'cover.bedroom', 'position': 50} + ), +}, { # Test media_player volume + 'entity_id': 'media_player.living_room', + 'command': ga.const.COMMAND_BRIGHTNESS, + 'params': { + 'brightness': 30 + }, + 'expected': ( + const.SERVICE_VOLUME_SET, + {'entity_id': 'media_player.living_room', 'volume_level': 0.3} + ), +}] + + +@asyncio.coroutine +def test_make_actions_response(): + """Test make response helper.""" + reqid = 1234 + payload = 'hello' + result = ga.smart_home.make_actions_response(reqid, payload) + assert result['requestId'] == reqid + assert result['payload'] == payload + + +@asyncio.coroutine +def test_determine_service(): + """Test all branches of determine service.""" + for test in DETERMINE_SERVICE_TESTS: + result = ga.smart_home.determine_service( + test['entity_id'], + test['command'], + test['params']) + assert result == test['expected'] diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 0e741cc7ee1..5c32a1050a2 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -1,16 +1,14 @@ """The tests for the Template light platform.""" import logging -import asyncio -from homeassistant.core import callback, State, CoreState +from homeassistant.core import callback from homeassistant import setup import homeassistant.components as core from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component) _LOGGER = logging.getLogger(__name__) @@ -627,49 +625,3 @@ class TestTemplateLight: assert state is not None assert state.attributes.get('friendly_name') == 'Template light' - - -@asyncio.coroutine -def test_restore_state(hass): - """Ensure states are restored on startup.""" - hass.data[DATA_RESTORE_CACHE] = { - 'light.test_template_light': - State('light.test_template_light', 'on'), - } - - hass.state = CoreState.starting - mock_component(hass, 'recorder') - yield from setup.async_setup_component(hass, 'light', { - 'light': { - 'platform': 'template', - 'lights': { - 'test_template_light': { - 'value_template': - "{{states.light.test_state.state}}", - 'turn_on': { - 'service': 'test.automation', - }, - 'turn_off': { - 'service': 'light.turn_off', - 'entity_id': 'light.test_state' - }, - 'set_level': { - 'service': 'test.automation', - 'data_template': { - 'entity_id': 'light.test_state', - 'brightness': '{{brightness}}' - } - } - } - } - } - }) - - state = hass.states.get('light.test_template_light') - assert state.state == 'on' - - yield from hass.async_start() - yield from hass.async_block_till_done() - - state = hass.states.get('light.test_template_light') - assert state.state == 'off' diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py new file mode 100644 index 00000000000..451b6b51feb --- /dev/null +++ b/tests/components/media_player/test_monoprice.py @@ -0,0 +1,323 @@ +"""The tests for Monoprice Media player platform.""" +import unittest +import voluptuous as vol + +from collections import defaultdict + +from homeassistant.components.media_player import ( + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE) +from homeassistant.const import STATE_ON, STATE_OFF + +from components.media_player.monoprice import MonopriceZone, PLATFORM_SCHEMA + + +class MockState(object): + """Mock for zone state object.""" + + def __init__(self): + """Init zone state.""" + self.power = True + self.volume = 0 + self.mute = True + self.source = 1 + + +class MockMonoprice(object): + """Mock for pymonoprice object.""" + + def __init__(self): + """Init mock object.""" + self.zones = defaultdict(lambda *a: MockState()) + + def zone_status(self, zone_id): + """Get zone status.""" + return self.zones[zone_id] + + def set_source(self, zone_id, source_idx): + """Set source for zone.""" + self.zones[zone_id].source = source_idx + + def set_power(self, zone_id, power): + """Turn zone on/off.""" + self.zones[zone_id].power = power + + def set_mute(self, zone_id, mute): + """Mute/unmute zone.""" + self.zones[zone_id].mute = mute + + def set_volume(self, zone_id, volume): + """Set volume for zone.""" + self.zones[zone_id].volume = volume + + +class TestMonopriceSchema(unittest.TestCase): + """Test Monoprice schema.""" + + def test_valid_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'monoprice', + 'port': '/dev/ttyUSB0', + 'zones': {11: {'name': 'a'}, + 12: {'name': 'a'}, + 13: {'name': 'a'}, + 14: {'name': 'a'}, + 15: {'name': 'a'}, + 16: {'name': 'a'}, + 21: {'name': 'a'}, + 22: {'name': 'a'}, + 23: {'name': 'a'}, + 24: {'name': 'a'}, + 25: {'name': 'a'}, + 26: {'name': 'a'}, + 31: {'name': 'a'}, + 32: {'name': 'a'}, + 33: {'name': 'a'}, + 34: {'name': 'a'}, + 35: {'name': 'a'}, + 36: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'} + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_invalid_schemas(self): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Missing port + { + 'platform': 'monoprice', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid zone number + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {10: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid source number + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {0: {'name': 'b'}}, + }, + # Zone missing name + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {}}, + 'sources': {1: {'name': 'b'}}, + }, + # Source missing name + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {}}, + }, + + ) + for value in schemas: + with self.assertRaises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +class TestMonopriceMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): + """Set up the test case.""" + self.monoprice = MockMonoprice() + # Note, source dictionary is unsorted! + self.media_player = MonopriceZone(self.monoprice, {1: 'one', + 3: 'three', + 2: 'two'}, + 12, 'Zone name') + + def test_update(self): + """Test updating values from monoprice.""" + self.assertIsNone(self.media_player.state) + self.assertIsNone(self.media_player.volume_level) + self.assertIsNone(self.media_player.is_volume_muted) + self.assertIsNone(self.media_player.source) + + self.media_player.update() + + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + def test_name(self): + """Test name property.""" + self.assertEqual('Zone name', self.media_player.name) + + def test_state(self): + """Test state property.""" + self.assertIsNone(self.media_player.state) + + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.monoprice.zones[12].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_volume_level(self): + """Test volume level property.""" + self.assertIsNone(self.media_player.volume_level) + self.media_player.update() + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + + self.monoprice.zones[12].volume = 38 + self.media_player.update() + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + + self.monoprice.zones[12].volume = 19 + self.media_player.update() + self.assertEqual(.5, self.media_player.volume_level, 0.0001) + + def test_is_volume_muted(self): + """Test volume muted property.""" + self.assertIsNone(self.media_player.is_volume_muted) + + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + self.monoprice.zones[12].mute = False + self.media_player.update() + self.assertFalse(self.media_player.is_volume_muted) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | + SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | + SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE, + self.media_player.supported_features) + + def test_source(self): + """Test source property.""" + self.assertIsNone(self.media_player.source) + self.media_player.update() + self.assertEqual('one', self.media_player.source) + + def test_source_list(self): + """Test source list property.""" + # Note, the list is sorted! + self.assertEqual(['one', 'two', 'three'], + self.media_player.source_list) + + def test_select_source(self): + """Test source selection methods.""" + self.media_player.update() + + self.assertEqual('one', self.media_player.source) + + self.media_player.select_source('two') + self.assertEqual(2, self.monoprice.zones[12].source) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + # Trying to set unknown source + self.media_player.select_source('no name') + self.assertEqual(2, self.monoprice.zones[12].source) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + def test_turn_on(self): + """Test turning on the zone.""" + self.monoprice.zones[12].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + self.media_player.turn_on() + self.assertTrue(self.monoprice.zones[12].power) + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + def test_turn_off(self): + """Test turning off the zone.""" + self.monoprice.zones[12].power = True + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.media_player.turn_off() + self.assertFalse(self.monoprice.zones[12].power) + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_mute_volume(self): + """Test mute functionality.""" + self.monoprice.zones[12].mute = True + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + self.media_player.mute_volume(False) + self.assertFalse(self.monoprice.zones[12].mute) + self.media_player.update() + self.assertFalse(self.media_player.is_volume_muted) + + self.media_player.mute_volume(True) + self.assertTrue(self.monoprice.zones[12].mute) + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + def test_set_volume_level(self): + """Test set volume level.""" + self.media_player.set_volume_level(1.0) + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + self.media_player.set_volume_level(0.0) + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + self.media_player.set_volume_level(0.5) + self.assertEqual(19, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + def test_volume_up(self): + """Test increasing volume by one.""" + self.monoprice.zones[12].volume = 37 + self.media_player.update() + self.media_player.volume_up() + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + # Try to raise value beyond max + self.media_player.update() + self.media_player.volume_up() + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + def test_volume_down(self): + """Test decreasing volume by one.""" + self.monoprice.zones[12].volume = 1 + self.media_player.update() + self.media_player.volume_down() + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + # Try to lower value beyond minimum + self.media_player.update() + self.media_player.volume_down() + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index ba71c6e3993..bfb8fb61f9b 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -8,6 +8,8 @@ from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant from unittest.mock import patch from datetime import datetime, timedelta +from tests.common import init_recorder_component +from homeassistant.components import recorder class TestStatisticsSensor(unittest.TestCase): @@ -135,3 +137,28 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(6, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + + def test_initialize_from_database(self): + """Test initializing the statistics from the database.""" + # enable the recorder + init_recorder_component(self.hass) + # store some values + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # wait for the recorder to really store the data + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + # only now create the statistics component, so that it must read the + # data from the database + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'sampling_size': 100, + } + }) + # check if the result is as in test_sensor_source() + state = self.hass.states.get('sensor.test_mean') + self.assertEqual(str(self.mean), state.state) diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index efff5186854..5e6a4957c04 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -1,12 +1,7 @@ """The test for the Template sensor platform.""" -import asyncio +from homeassistant.setup import setup_component -from homeassistant.core import CoreState, State -from homeassistant.setup import setup_component, async_setup_component -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE - -from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) +from tests.common import get_test_home_assistant, assert_setup_component class TestTemplateSensor: @@ -188,36 +183,3 @@ class TestTemplateSensor: self.hass.block_till_done() assert self.hass.states.all() == [] - - -@asyncio.coroutine -def test_restore_state(hass): - """Ensure states are restored on startup.""" - hass.data[DATA_RESTORE_CACHE] = { - 'sensor.test_template_sensor': - State('sensor.test_template_sensor', 'It Test.'), - } - - hass.state = CoreState.starting - mock_component(hass, 'recorder') - - yield from async_setup_component(hass, 'sensor', { - 'sensor': { - 'platform': 'template', - 'sensors': { - 'test_template_sensor': { - 'value_template': - "It {{ states.sensor.test_state.state }}." - } - } - } - }) - - state = hass.states.get('sensor.test_template_sensor') - assert state.state == 'It Test.' - - yield from hass.async_start() - yield from hass.async_block_till_done() - - state = hass.states.get('sensor.test_template_sensor') - assert state.state == 'It .' diff --git a/tests/components/sensor/test_uptime.py b/tests/components/sensor/test_uptime.py new file mode 100644 index 00000000000..991ecd3960b --- /dev/null +++ b/tests/components/sensor/test_uptime.py @@ -0,0 +1,88 @@ +"""The tests for the uptime sensor platform.""" +import unittest +from unittest.mock import patch +from datetime import timedelta + +from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.setup import setup_component +from homeassistant.components.sensor.uptime import UptimeSensor +from tests.common import get_test_home_assistant + + +class TestUptimeSensor(unittest.TestCase): + """Test the uptime sensor.""" + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_uptime_min_config(self): + """Test minimum uptime configutation.""" + config = { + 'sensor': { + 'platform': 'uptime', + } + } + assert setup_component(self.hass, 'sensor', config) + + def test_uptime_sensor_name_change(self): + """Test uptime sensor with different name.""" + config = { + 'sensor': { + 'platform': 'uptime', + 'name': 'foobar', + } + } + assert setup_component(self.hass, 'sensor', config) + + def test_uptime_sensor_config_hours(self): + """Test uptime sensor with hours defined in config.""" + config = { + 'sensor': { + 'platform': 'uptime', + 'unit_of_measurement': 'hours', + } + } + assert setup_component(self.hass, 'sensor', config) + + def test_uptime_sensor_days_output(self): + """Test uptime sensor output data.""" + sensor = UptimeSensor('test', 'days') + self.assertEqual(sensor.unit_of_measurement, 'days') + new_time = sensor.initial + timedelta(days=1) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 1.00) + new_time = sensor.initial + timedelta(days=111.499) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 111.50) + + def test_uptime_sensor_hours_output(self): + """Test uptime sensor output data.""" + sensor = UptimeSensor('test', 'hours') + self.assertEqual(sensor.unit_of_measurement, 'hours') + new_time = sensor.initial + timedelta(hours=16) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 16.00) + new_time = sensor.initial + timedelta(hours=72.499) + with patch('homeassistant.util.dt.now', return_value=new_time): + run_coroutine_threadsafe( + sensor.async_update(), + self.hass.loop + ).result() + self.assertEqual(sensor.state, 72.50) diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index f7e9b7d730c..e4a1a1af558 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -1,14 +1,11 @@ """The tests for the Template switch platform.""" -import asyncio - -from homeassistant.core import callback, State, CoreState +from homeassistant.core import callback from homeassistant import setup import homeassistant.components as core from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component) class TestTemplateSwitch: @@ -410,44 +407,3 @@ class TestTemplateSwitch: self.hass.block_till_done() assert len(self.calls) == 1 - - -@asyncio.coroutine -def test_restore_state(hass): - """Ensure states are restored on startup.""" - hass.data[DATA_RESTORE_CACHE] = { - 'switch.test_template_switch': - State('switch.test_template_switch', 'on'), - } - - hass.state = CoreState.starting - mock_component(hass, 'recorder') - - yield from setup.async_setup_component(hass, 'switch', { - 'switch': { - 'platform': 'template', - 'switches': { - 'test_template_switch': { - 'value_template': - "{{ states.switch.test_state.state }}", - 'turn_on': { - 'service': 'switch.turn_on', - 'entity_id': 'switch.test_state' - }, - 'turn_off': { - 'service': 'switch.turn_off', - 'entity_id': 'switch.test_state' - }, - } - } - } - }) - - state = hass.states.get('switch.test_template_switch') - assert state.state == 'on' - - yield from hass.async_start() - yield from hass.async_block_till_done() - - state = hass.states.get('switch.test_template_switch') - assert state.state == 'unavailable' diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index f7c967da862..761ba29e403 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -25,11 +25,13 @@ def hassio_env(): @pytest.fixture def hassio_client(hassio_env, hass, test_client): """Create mock hassio http client.""" - hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': API_PASSWORD - } - })) + with patch('homeassistant.components.hassio.HassIO.update_hass_api', + Mock(return_value=mock_coro(True))): + hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + })) yield hass.loop.run_until_complete(test_client(hass.http.app)) @@ -109,6 +111,42 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): assert aioclient_mock.mock_calls[-1][2]['port'] == 8123 +@asyncio.coroutine +def test_setup_core_push_timezone(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + 'homeassistant': { + 'time_zone': 'testzone', + }, + }) + assert result + + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[-1][2]['timezone'] == "testzone" + + +@asyncio.coroutine +def test_setup_hassio_no_additional_data(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + }) + assert result + + assert aioclient_mock.call_count == 1 + + @asyncio.coroutine def test_service_register(hassio_env, hass): """Check if service will be settup.""" @@ -117,6 +155,8 @@ def test_service_register(hassio_env, hass): assert hass.services.has_service('hassio', 'addon_stop') assert hass.services.has_service('hassio', 'addon_restart') assert hass.services.has_service('hassio', 'addon_stdin') + assert hass.services.has_service('hassio', 'host_shutdown') + assert hass.services.has_service('hassio', 'host_reboot') @asyncio.coroutine @@ -132,6 +172,10 @@ def test_service_calls(hassio_env, hass, aioclient_mock): "http://127.0.0.1/addons/test/restart", json={'result': 'ok'}) aioclient_mock.post( "http://127.0.0.1/addons/test/stdin", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/host/shutdown", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/host/reboot", json={'result': 'ok'}) yield from hass.services.async_call( 'hassio', 'addon_start', {'addon': 'test'}) @@ -146,6 +190,12 @@ def test_service_calls(hassio_env, hass, aioclient_mock): assert aioclient_mock.call_count == 4 assert aioclient_mock.mock_calls[-1][2] == 'test' + yield from hass.services.async_call('hassio', 'host_shutdown', {}) + yield from hass.services.async_call('hassio', 'host_reboot', {}) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 6 + @asyncio.coroutine def test_forward_request(hassio_client): diff --git a/tests/components/test_namecheapdns.py b/tests/components/test_namecheapdns.py new file mode 100644 index 00000000000..b225c0af7c8 --- /dev/null +++ b/tests/components/test_namecheapdns.py @@ -0,0 +1,78 @@ +"""Test the NamecheapDNS component.""" +import asyncio +from datetime import timedelta + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import namecheapdns +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + +HOST = 'test' +DOMAIN = 'bla' +TOKEN = 'abcdefgh' + + +@pytest.fixture +def setup_namecheapdns(hass, aioclient_mock): + """Fixture that sets up NamecheapDNS.""" + aioclient_mock.get(namecheapdns.UPDATE_URL, params={ + 'host': HOST, + 'domain': DOMAIN, + 'password': TOKEN + }, text='0') + + hass.loop.run_until_complete(async_setup_component( + hass, namecheapdns.DOMAIN, { + 'namecheapdns': { + 'host': HOST, + 'domain': DOMAIN, + 'access_token': TOKEN + } + })) + + +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test setup works if update passes.""" + aioclient_mock.get(namecheapdns.UPDATE_URL, params={ + 'host': HOST, + 'domain': DOMAIN, + 'password': TOKEN + }, text='0') + + result = yield from async_setup_component(hass, namecheapdns.DOMAIN, { + 'namecheapdns': { + 'host': HOST, + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert result + assert aioclient_mock.call_count == 1 + + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + +@asyncio.coroutine +def test_setup_fails_if_update_fails(hass, aioclient_mock): + """Test setup fails if first update fails.""" + aioclient_mock.get(namecheapdns.UPDATE_URL, params={ + 'host': HOST, + 'domain': DOMAIN, + 'password': TOKEN + }, text='1') + + result = yield from async_setup_component(hass, namecheapdns.DOMAIN, { + 'namecheapdns': { + 'host': HOST, + 'domain': DOMAIN, + 'access_token': TOKEN + } + }) + assert not result + assert aioclient_mock.call_count == 1 diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 660ed3c1b18..667d1849100 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -157,14 +157,17 @@ logger.info('Logging from inside script: %s %s' % (mydict["a"], mylist[2])) def test_accessing_forbidden_methods(hass, caplog): """Test compile error logs error.""" caplog.set_level(logging.ERROR) - source = """ -hass.stop() - """ - hass.async_add_job(execute, hass, 'test.py', source, {}) - yield from hass.async_block_till_done() - - assert "Not allowed to access HomeAssistant.stop" in caplog.text + for source, name in { + 'hass.stop()': 'HomeAssistant.stop', + 'dt_util.set_default_time_zone()': 'module.set_default_time_zone', + 'datetime.non_existing': 'module.non_existing', + 'time.tzset()': 'TimeWrapper.tzset', + }.items(): + caplog.records.clear() + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + assert "Not allowed to access {}".format(name) in caplog.text @asyncio.coroutine @@ -205,6 +208,26 @@ hass.states.set('hello.ab_list', '{}'.format(ab_list)) assert caplog.text == '' +@asyncio.coroutine +def test_exposed_modules(hass, caplog): + """Test datetime and time modules exposed.""" + caplog.set_level(logging.ERROR) + source = """ +hass.states.set('module.time', time.strftime('%Y', time.gmtime(521276400))) +hass.states.set('module.datetime', + datetime.timedelta(minutes=1).total_seconds()) +""" + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('module.time', '1986') + assert hass.states.is_state('module.datetime', '60.0') + + # No errors logged = good + assert caplog.text == '' + + @asyncio.coroutine def test_reload(hass): """Test we can re-discover scripts.""" @@ -238,3 +261,19 @@ def test_reload(hass): assert hass.services.has_service('python_script', 'hello2') assert hass.services.has_service('python_script', 'world_beer') assert hass.services.has_service('python_script', 'reload') + + +@asyncio.coroutine +def test_sleep_warns_one(hass, caplog): + """Test time.sleep warns once.""" + caplog.set_level(logging.WARNING) + source = """ +time.sleep(2) +time.sleep(5) +""" + + with patch('homeassistant.components.python_script.time.sleep'): + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert caplog.text.count('time.sleep') == 1 diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index e1f2e114ba1..2087dc2adb5 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -27,6 +27,7 @@ class TestHelpersDiscovery: @patch('homeassistant.setup.async_setup_component') def test_listen(self, mock_setup_component): """Test discovery listen/discover combo.""" + helpers = self.hass.helpers calls_single = [] calls_multi = [] @@ -40,12 +41,12 @@ class TestHelpersDiscovery: """Service discovered callback.""" calls_multi.append((service, info)) - discovery.listen(self.hass, 'test service', callback_single) - discovery.listen(self.hass, ['test service', 'another service'], - callback_multi) + helpers.discovery.listen('test service', callback_single) + helpers.discovery.listen(['test service', 'another service'], + callback_multi) - discovery.discover(self.hass, 'test service', 'discovery info', - 'test_component') + helpers.discovery.discover('test service', 'discovery info', + 'test_component') self.hass.block_till_done() assert mock_setup_component.called @@ -54,8 +55,8 @@ class TestHelpersDiscovery: assert len(calls_single) == 1 assert calls_single[0] == ('test service', 'discovery info') - discovery.discover(self.hass, 'another service', 'discovery info', - 'test_component') + helpers.discovery.discover('another service', 'discovery info', + 'test_component') self.hass.block_till_done() assert len(calls_single) == 1 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index cf73e066072..56a696e1f1b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -213,3 +213,162 @@ def test_async_schedule_update_ha_state(hass): yield from hass.async_block_till_done() assert update_call is True + + +@asyncio.coroutine +def test_async_pararell_updates_with_zero(hass): + """Test pararell updates with 0 (disabled).""" + updates = [] + test_lock = asyncio.Event(loop=hass.loop) + + class AsyncEntity(entity.Entity): + + def __init__(self, entity_id, count): + """Initialize Async test entity.""" + self.entity_id = entity_id + self.hass = hass + self._count = count + + @asyncio.coroutine + def async_update(self): + """Test update.""" + updates.append(self._count) + yield from test_lock.wait() + + ent_1 = AsyncEntity("sensor.test_1", 1) + ent_2 = AsyncEntity("sensor.test_2", 2) + + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + + while True: + if len(updates) == 2: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 2 + assert updates == [1, 2] + + test_lock.set() + + +@asyncio.coroutine +def test_async_pararell_updates_with_one(hass): + """Test pararell updates with 1 (sequential).""" + updates = [] + test_lock = asyncio.Lock(loop=hass.loop) + test_semephore = asyncio.Semaphore(1, loop=hass.loop) + + yield from test_lock.acquire() + + class AsyncEntity(entity.Entity): + + def __init__(self, entity_id, count): + """Initialize Async test entity.""" + self.entity_id = entity_id + self.hass = hass + self._count = count + self.parallel_updates = test_semephore + + @asyncio.coroutine + def async_update(self): + """Test update.""" + updates.append(self._count) + yield from test_lock.acquire() + + ent_1 = AsyncEntity("sensor.test_1", 1) + ent_2 = AsyncEntity("sensor.test_2", 2) + ent_3 = AsyncEntity("sensor.test_3", 3) + + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + ent_3.async_schedule_update_ha_state(True) + + while True: + if len(updates) == 1: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 1 + assert updates == [1] + + test_lock.release() + + while True: + if len(updates) == 2: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 2 + assert updates == [1, 2] + + test_lock.release() + + while True: + if len(updates) == 3: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 3 + assert updates == [1, 2, 3] + + test_lock.release() + + +@asyncio.coroutine +def test_async_pararell_updates_with_two(hass): + """Test pararell updates with 2 (pararell).""" + updates = [] + test_lock = asyncio.Lock(loop=hass.loop) + test_semephore = asyncio.Semaphore(2, loop=hass.loop) + + yield from test_lock.acquire() + + class AsyncEntity(entity.Entity): + + def __init__(self, entity_id, count): + """Initialize Async test entity.""" + self.entity_id = entity_id + self.hass = hass + self._count = count + self.parallel_updates = test_semephore + + @asyncio.coroutine + def async_update(self): + """Test update.""" + updates.append(self._count) + yield from test_lock.acquire() + + ent_1 = AsyncEntity("sensor.test_1", 1) + ent_2 = AsyncEntity("sensor.test_2", 2) + ent_3 = AsyncEntity("sensor.test_3", 3) + ent_4 = AsyncEntity("sensor.test_4", 4) + + ent_1.async_schedule_update_ha_state(True) + ent_2.async_schedule_update_ha_state(True) + ent_3.async_schedule_update_ha_state(True) + ent_4.async_schedule_update_ha_state(True) + + while True: + if len(updates) == 2: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 2 + assert updates == [1, 2] + + test_lock.release() + yield from asyncio.sleep(0, loop=hass.loop) + test_lock.release() + + while True: + if len(updates) == 4: + break + yield from asyncio.sleep(0, loop=hass.loop) + + assert len(updates) == 4 + assert updates == [1, 2, 3, 4] + + test_lock.release() + yield from asyncio.sleep(0, loop=hass.loop) + test_lock.release() diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index efa079a7e4a..462d57160c9 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -578,3 +578,79 @@ def test_platform_not_ready(hass): yield from hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 assert 'test_domain.mod1' in hass.config.components + + +@asyncio.coroutine +def test_pararell_updates_async_platform(hass): + """Warn we log when platform setup takes a long time.""" + platform = MockPlatform() + + @asyncio.coroutine + def mock_update(*args, **kwargs): + pass + + platform.async_setup_platform = mock_update + + loader.set_component('test_domain.platform', platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + yield from component.async_setup({ + DOMAIN: { + 'platform': 'platform', + } + }) + + handle = list(component._platforms.values())[-1] + + assert handle.parallel_updates is None + + +@asyncio.coroutine +def test_pararell_updates_async_platform_with_constant(hass): + """Warn we log when platform setup takes a long time.""" + platform = MockPlatform() + + @asyncio.coroutine + def mock_update(*args, **kwargs): + pass + + platform.async_setup_platform = mock_update + platform.PARALLEL_UPDATES = 1 + + loader.set_component('test_domain.platform', platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + yield from component.async_setup({ + DOMAIN: { + 'platform': 'platform', + } + }) + + handle = list(component._platforms.values())[-1] + + assert handle.parallel_updates is not None + + +@asyncio.coroutine +def test_pararell_updates_sync_platform(hass): + """Warn we log when platform setup takes a long time.""" + platform = MockPlatform() + + loader.set_component('test_domain.platform', platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + yield from component.async_setup({ + DOMAIN: { + 'platform': 'platform', + } + }) + + handle = list(component._platforms.values())[-1] + + assert handle.parallel_updates is not None diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 9c325df181e..7d601c7a78d 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -5,6 +5,7 @@ import unittest from datetime import datetime, timedelta from astral import Astral +import pytest from homeassistant.setup import setup_component import homeassistant.core as ha @@ -274,7 +275,8 @@ class TestEventHelpers(unittest.TestCase): thread_runs.append(1) track_same_state( - self.hass, 'on', period, thread_run_callback, + self.hass, period, thread_run_callback, + lambda _, _2, to_s: to_s.state == 'on', entity_ids='light.Bowl') @ha.callback @@ -282,7 +284,8 @@ class TestEventHelpers(unittest.TestCase): callback_runs.append(1) track_same_state( - self.hass, 'on', period, callback_run_callback, + self.hass, period, callback_run_callback, + lambda _, _2, to_s: to_s.state == 'on', entity_ids='light.Bowl') @asyncio.coroutine @@ -290,7 +293,8 @@ class TestEventHelpers(unittest.TestCase): coroutine_runs.append(1) track_same_state( - self.hass, 'on', period, coroutine_run_callback) + self.hass, period, coroutine_run_callback, + lambda _, _2, to_s: to_s.state == 'on') # Adding state to state machine self.hass.states.set("light.Bowl", "on") @@ -317,7 +321,8 @@ class TestEventHelpers(unittest.TestCase): callback_runs.append(1) track_same_state( - self.hass, 'on', period, callback_run_callback, + self.hass, period, callback_run_callback, + lambda _, _2, to_s: to_s.state == 'on', entity_ids='light.Bowl') # Adding state to state machine @@ -349,11 +354,11 @@ class TestEventHelpers(unittest.TestCase): @ha.callback def async_check_func(entity, from_s, to_s): check_func.append((entity, from_s, to_s)) - return 'on' + return True track_same_state( - self.hass, 'on', period, callback_run_callback, - entity_ids='light.Bowl', async_check_func=async_check_func) + self.hass, period, callback_run_callback, + entity_ids='light.Bowl', async_check_same_func=async_check_func) # Adding state to state machine self.hass.states.set("light.Bowl", "on") @@ -630,8 +635,9 @@ class TestEventHelpers(unittest.TestCase): """Test periodic tasks with wrong input.""" specific_runs = [] - track_utc_time_change( - self.hass, lambda x: specific_runs.append(1), year='/two') + with pytest.raises(ValueError): + track_utc_time_change( + self.hass, lambda x: specific_runs.append(1), year='/two') self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) self.hass.block_till_done() diff --git a/tests/util/test_icon.py b/tests/helpers/test_icon.py similarity index 92% rename from tests/util/test_icon.py rename to tests/helpers/test_icon.py index 2275fdcc6d3..29507b25cb7 100644 --- a/tests/util/test_icon.py +++ b/tests/helpers/test_icon.py @@ -7,7 +7,7 @@ class TestIconUtil(unittest.TestCase): def test_battery_icon(self): """Test icon generator for battery sensor.""" - from homeassistant.util.icon import icon_for_battery_level + from homeassistant.helpers.icon import icon_for_battery_level self.assertEqual('mdi:battery-unknown', icon_for_battery_level(None, True)) @@ -16,7 +16,7 @@ class TestIconUtil(unittest.TestCase): self.assertEqual('mdi:battery-outline', icon_for_battery_level(5, True)) - self.assertEqual('mdi:battery-outline', + self.assertEqual('mdi:battery-alert', icon_for_battery_level(5, False)) self.assertEqual('mdi:battery-charging-100', @@ -44,7 +44,7 @@ class TestIconUtil(unittest.TestCase): if 5 < level < 95: postfix = '-{}'.format(int(round(level / 10 - .01)) * 10) elif level <= 5: - postfix = '-outline' + postfix = '-alert' else: postfix = '' self.assertEqual(iconbase + postfix, diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index d9ef7bc5a2b..b6e3ea17e1a 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -345,6 +345,41 @@ class TestScriptHelper(unittest.TestCase): assert not script_obj.is_running assert len(events) == 1 + def test_wait_template_variables(self): + """Test the wait template with variables.""" + event = 'test_event' + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(event, record_event) + + self.hass.states.set('switch.test', 'on') + + script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ + {'event': event}, + {'wait_template': "{{is_state(data, 'off')}}"}, + {'event': event}])) + + script_obj.run({ + 'data': 'switch.test' + }) + self.hass.block_till_done() + + assert script_obj.is_running + assert script_obj.can_cancel + assert script_obj.last_action == event + assert len(events) == 1 + + self.hass.states.set('switch.test', 'off') + self.hass.block_till_done() + + assert not script_obj.is_running + assert len(events) == 2 + def test_passing_variables_to_script(self): """Test if we can pass variables to script.""" calls = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a32b2dc13a1..a214d69f80a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -683,7 +683,7 @@ class TestHelpersTemplate(unittest.TestCase): MATCH_ALL, template.extract_entities(""" {% for state in states.sensor %} - {{ state.entity_id }}={{ state.state }}, + {{ state.entity_id }}={{ state.state }},d {% endfor %} """)) @@ -753,6 +753,35 @@ is_state_attr('device_tracker.phone_2', 'battery', 40) " %}true{% endif %}" ))) + def test_extract_entities_with_variables(self): + """Test extract entities function with variables and entities stuff.""" + self.assertEqual( + ['input_boolean.switch'], + template.extract_entities( + "{{ is_state('input_boolean.switch', 'off') }}", {})) + + self.assertEqual( + ['trigger.entity_id'], + template.extract_entities( + "{{ is_state(trigger.entity_id, 'off') }}", {})) + + self.assertEqual( + MATCH_ALL, + template.extract_entities( + "{{ is_state(data, 'off') }}", {})) + + self.assertEqual( + ['input_boolean.switch'], + template.extract_entities( + "{{ is_state(data, 'off') }}", + {'data': 'input_boolean.switch'})) + + self.assertEqual( + ['input_boolean.switch'], + template.extract_entities( + "{{ is_state(trigger.entity_id, 'off') }}", + {'trigger': {'entity_id': 'input_boolean.switch'}})) + @asyncio.coroutine def test_state_with_unit(hass): diff --git a/tests/test_config.py b/tests/test_config.py index 400acbef17a..2c8edc32f82 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -442,6 +442,38 @@ class TestConfig(unittest.TestCase): assert self.hass.config.units.name == CONF_UNIT_SYSTEM_METRIC assert self.hass.config.time_zone.zone == 'America/New_York' + def test_loading_configuration_from_packages(self): + """Test loading packages config onto hass object config.""" + self.hass.config = mock.Mock() + + run_coroutine_threadsafe( + config_util.async_process_ha_core_config(self.hass, { + 'latitude': 39, + 'longitude': -1, + 'elevation': 500, + 'name': 'Huis', + CONF_TEMPERATURE_UNIT: 'C', + 'time_zone': 'Europe/Madrid', + 'packages': { + 'package_1': {'wake_on_lan': None}, + 'package_2': {'light': {'platform': 'hue'}, + 'media_extractor': None, + 'sun': None}}, + }), self.hass.loop).result() + + # Empty packages not allowed + with pytest.raises(MultipleInvalid): + run_coroutine_threadsafe( + config_util.async_process_ha_core_config(self.hass, { + 'latitude': 39, + 'longitude': -1, + 'elevation': 500, + 'name': 'Huis', + CONF_TEMPERATURE_UNIT: 'C', + 'time_zone': 'Europe/Madrid', + 'packages': {'empty_package': None}, + }), self.hass.loop).result() + @mock.patch('homeassistant.util.location.detect_location_info', autospec=True, return_value=location_util.LocationInfo( '0.0.0.0', 'US', 'United States', 'CA', 'California', @@ -541,6 +573,7 @@ def test_merge(merge_log_err): 'pack_11': {'input_select': {'is1': None}}, 'pack_list': {'light': {'platform': 'test'}}, 'pack_list2': {'light': [{'platform': 'test'}]}, + 'pack_none': {'wake_on_lan': None}, } config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, @@ -550,10 +583,11 @@ def test_merge(merge_log_err): config_util.merge_packages_config(config, packages) assert merge_log_err.call_count == 0 - assert len(config) == 4 + assert len(config) == 5 assert len(config['input_boolean']) == 2 assert len(config['input_select']) == 1 assert len(config['light']) == 3 + assert config['wake_on_lan'] is None def test_merge_new(merge_log_err): diff --git a/tests/test_loader.py b/tests/test_loader.py index 6081b061ed2..7fc33df57bb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -84,3 +84,22 @@ def test_component_wrapper(hass): yield from hass.async_block_till_done() assert len(calls) == 1 + + +@asyncio.coroutine +def test_helpers_wrapper(hass): + """Test helpers wrapper.""" + helpers = loader.Helpers(hass) + + result = [] + + def discovery_callback(service, discovered): + """Handle discovery callback.""" + result.append(discovered) + + helpers.discovery.async_listen('service_name', discovery_callback) + + yield from helpers.discovery.async_discover('service_name', 'hello') + yield from hass.async_block_till_done() + + assert result == ['hello'] diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 70b1a19f46d..131819a6ca0 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -26,10 +26,10 @@ 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+. +# Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython # BEGIN: Development additions diff --git a/virtualization/Docker/scripts/aiocoap b/virtualization/Docker/scripts/aiocoap deleted file mode 100755 index e234aa31236..00000000000 --- a/virtualization/Docker/scripts/aiocoap +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# Installs a modified coap client with support for dtls for use with IKEA Tradfri - -# Stop on errors -set -e - -python3 -m pip install cython - -cd /usr/src/app/ -mkdir -p build && cd build - -git clone --depth 1 https://git.fslab.de/jkonra2m/tinydtls -cd tinydtls -autoreconf -./configure --with-ecc --without-debug -cd cython -python3 setup.py install - -cd ../.. -git clone https://github.com/chrysn/aiocoap -cd aiocoap -git reset --hard 3286f48f0b949901c8b5c04c0719dc54ab63d431 -python3 -m pip install . diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 95c8cd3f2e7..bd70af28dce 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -9,7 +9,6 @@ INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" INSTALL_FFMPEG="${INSTALL_FFMPEG:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" -INSTALL_COAP="${INSTALL_COAP:-yes}" INSTALL_SSOCR="${INSTALL_SSOCR:-yes}" # Required debian packages for running hass or components @@ -59,10 +58,6 @@ if [ "$INSTALL_PHANTOMJS" == "yes" ]; then virtualization/Docker/scripts/phantomjs fi -if [ "$INSTALL_COAP" == "yes" ]; then - virtualization/Docker/scripts/aiocoap -fi - if [ "$INSTALL_SSOCR" == "yes" ]; then virtualization/Docker/scripts/ssocr fi