diff --git a/.coveragerc b/.coveragerc index 5134f79297c..01187b92d66 100644 --- a/.coveragerc +++ b/.coveragerc @@ -309,6 +309,7 @@ omit = homeassistant/components/device_tracker/cisco_ios.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/gpslogger.py + homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/keenetic_ndms2.py @@ -325,6 +326,7 @@ omit = homeassistant/components/device_tracker/thomson.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tado.py + homeassistant/components/device_tracker/tile.py homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py @@ -517,6 +519,7 @@ omit = homeassistant/components/sensor/influxdb.py homeassistant/components/sensor/irish_rail_transport.py homeassistant/components/sensor/kwb.py + homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py @@ -545,6 +548,7 @@ omit = homeassistant/components/sensor/pocketcasts.py homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py + homeassistant/components/sensor/pyload.py homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py homeassistant/components/sensor/ripple.py @@ -579,6 +583,7 @@ omit = homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py homeassistant/components/sensor/vasttrafik.py + homeassistant/components/sensor/viaggiatreno.py homeassistant/components/sensor/waqi.py homeassistant/components/sensor/whois.py homeassistant/components/sensor/worldtidesinfo.py diff --git a/CODEOWNERS b/CODEOWNERS index 8fd5d0826c1..82ae451e59c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -64,6 +64,10 @@ homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/*/rfxtrx.py @danielhiversen +homeassistant/components/velux.py @Julius2342 +homeassistant/components/*/velux.py @Julius2342 +homeassistant/components/knx.py @Julius2342 +homeassistant/components/*/knx.py @Julius2342 homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon homeassistant/components/*/tradfri.py @ggravlingen diff --git a/docs/source/conf.py b/docs/source/conf.py index 8ca22e1a126..595c15717eb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,15 +19,13 @@ # import sys import os -from os.path import relpath import inspect -from homeassistant.const import (__version__, __short_version__, PROJECT_NAME, - PROJECT_LONG_DESCRIPTION, - PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY, - GITHUB_PATH, GITHUB_URL) +from homeassistant.const import __version__, __short_version__ +from setup import ( + PROJECT_NAME, PROJECT_LONG_DESCRIPTION, PROJECT_COPYRIGHT, PROJECT_AUTHOR, + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY, GITHUB_PATH, + GITHUB_URL) sys.path.insert(0, os.path.abspath('_ext')) sys.path.insert(0, os.path.abspath('../homeassistant')) @@ -87,9 +85,7 @@ edit_on_github_src_path = 'docs/source/' def linkcode_resolve(domain, info): - """ - Determine the URL corresponding to Python object - """ + """Determine the URL corresponding to Python object.""" if domain != 'py': return None modname = info['module'] diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4de464be88a..64ad88f8c8b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -30,8 +30,8 @@ ERROR_LOG_FILENAME = 'home-assistant.log' DATA_LOGGING = 'logging' FIRST_INIT_COMPONENT = set(( - 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction', - 'frontend', 'history')) + 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', + 'introduction', 'frontend', 'history')) def from_config_dict(config: Dict[str, Any], diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 581045c3790..b4c6adcc887 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -21,7 +21,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['abodepy==0.12.1'] +REQUIREMENTS = ['abodepy==0.12.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarm_control_panel/spc.py b/homeassistant/components/alarm_control_panel/spc.py index 1682ef2ae02..4d9c72df2f1 100644 --- a/homeassistant/components/alarm_control_panel/spc.py +++ b/homeassistant/components/alarm_control_panel/spc.py @@ -34,10 +34,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info[ATTR_DISCOVER_AREAS] is None): return - devices = [SpcAlarm(hass=hass, - area_id=area['id'], - name=area['name'], - state=_get_alarm_state(area['mode'])) + api = hass.data[DATA_API] + devices = [SpcAlarm(api, area) for area in discovery_info[ATTR_DISCOVER_AREAS]] async_add_devices(devices) @@ -46,21 +44,29 @@ def async_setup_platform(hass, config, async_add_devices, class SpcAlarm(alarm.AlarmControlPanel): """Represents the SPC alarm panel.""" - def __init__(self, hass, area_id, name, state): + def __init__(self, api, area): """Initialize the SPC alarm panel.""" - self._hass = hass - self._area_id = area_id - self._name = name - self._state = state - self._api = hass.data[DATA_API] - - hass.data[DATA_REGISTRY].register_alarm_device(area_id, self) + self._area_id = area['id'] + self._name = area['name'] + self._state = _get_alarm_state(area['mode']) + if self._state == STATE_ALARM_DISARMED: + self._changed_by = area.get('last_unset_user_name', 'unknown') + else: + self._changed_by = area.get('last_set_user_name', 'unknown') + self._api = api @asyncio.coroutine - def async_update_from_spc(self, state): + def async_added_to_hass(self): + """Calbback for init handlers.""" + self.hass.data[DATA_REGISTRY].register_alarm_device( + self._area_id, self) + + @asyncio.coroutine + def async_update_from_spc(self, state, extra): """Update the alarm panel with a new state.""" self._state = state - yield from self.async_update_ha_state() + self._changed_by = extra.get('changed_by', 'unknown') + self.async_schedule_update_ha_state() @property def should_poll(self): @@ -72,6 +78,11 @@ class SpcAlarm(alarm.AlarmControlPanel): """Return the name of the device.""" return self._name + @property + def changed_by(self): + """Return the user the last change was triggered by.""" + return self._changed_by + @property def state(self): """Return the state of the device.""" diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 7abdf5efcab..423628c9365 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_UNKNOWN, CONF_NAME) -REQUIREMENTS = ['total_connect_client==0.12'] +REQUIREMENTS = ['total_connect_client==0.13'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 9550b6dbade..c243fc12d5e 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -15,4 +15,6 @@ ATTR_STREAM_URL = 'streamUrl' ATTR_MAIN_TEXT = 'mainText' ATTR_REDIRECTION_URL = 'redirectionURL' +SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH' + DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z' diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index a0d0062414d..3ade199aabb 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -3,6 +3,7 @@ Support for Alexa skill service end point. For more details about this component, please refer to the documentation at https://home-assistant.io/components/alexa/ + """ import asyncio import enum @@ -13,7 +14,7 @@ from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import intent from homeassistant.components import http -from .const import DOMAIN +from .const import DOMAIN, SYN_RESOLUTION_MATCH INTENTS_API_ENDPOINT = '/api/alexa' @@ -123,6 +124,43 @@ class AlexaIntentsView(http.HomeAssistantView): return self.json(alexa_response) +def resolve_slot_synonyms(key, request): + """Check slot request for synonym resolutions.""" + # Default to the spoken slot value if more than one or none are found. For + # reference to the request object structure, see the Alexa docs: + # https://tinyurl.com/ybvm7jhs + resolved_value = request['value'] + + if ('resolutions' in request and + 'resolutionsPerAuthority' in request['resolutions'] and + len(request['resolutions']['resolutionsPerAuthority']) >= 1): + + # Extract all of the possible values from each authority with a + # successful match + possible_values = [] + + for entry in request['resolutions']['resolutionsPerAuthority']: + if entry['status']['code'] != SYN_RESOLUTION_MATCH: + continue + + possible_values.extend([item['value']['name'] + for item + in entry['values']]) + + # If there is only one match use the resolved value, otherwise the + # resolution cannot be determined, so use the spoken slot value + if len(possible_values) == 1: + resolved_value = possible_values[0] + else: + _LOGGER.debug( + 'Found multiple synonym resolutions for slot value: {%s: %s}', + key, + request['value'] + ) + + return resolved_value + + class AlexaResponse(object): """Help generating the response for Alexa.""" @@ -135,12 +173,17 @@ class AlexaResponse(object): self.session_attributes = {} self.should_end_session = True self.variables = {} + # Intent is None if request was a LaunchRequest or SessionEndedRequest if intent_info is not None: for key, value in intent_info.get('slots', {}).items(): - if 'value' in value: - underscored_key = key.replace('.', '_') - self.variables[underscored_key] = value['value'] + # Only include slots with values + if 'value' not in value: + continue + + _key = key.replace('.', '_') + + self.variables[_key] = resolve_slot_synonyms(key, value) def add_card(self, card_type, title, content): """Add a card to the response.""" diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index e65345cabca..6e71fc67df1 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,12 +1,20 @@ """Support for alexa Smart Home Skill API.""" import asyncio +from collections import namedtuple import logging import math from uuid import uuid4 +import homeassistant.core as ha from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF) -from homeassistant.components import switch, light + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_UNLOCK, SERVICE_VOLUME_SET) +from homeassistant.components import ( + alert, automation, cover, fan, group, input_boolean, light, lock, + media_player, scene, script, switch) import homeassistant.util.color as color_util from homeassistant.util.decorator import Registry @@ -14,14 +22,32 @@ HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) API_DIRECTIVE = 'directive' +API_ENDPOINT = 'endpoint' API_EVENT = 'event' API_HEADER = 'header' API_PAYLOAD = 'payload' -API_ENDPOINT = 'endpoint' + +ATTR_ALEXA_DESCRIPTION = 'alexa_description' +ATTR_ALEXA_DISPLAY_CATEGORIES = 'alexa_display_categories' +ATTR_ALEXA_HIDDEN = 'alexa_hidden' +ATTR_ALEXA_NAME = 'alexa_name' MAPPING_COMPONENT = { - switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], + alert.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + automation.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + cover.DOMAIN: [ + 'DOOR', ('Alexa.PowerController',), { + cover.SUPPORT_SET_POSITION: 'Alexa.PercentageController', + } + ], + fan.DOMAIN: [ + 'OTHER', ('Alexa.PowerController',), { + fan.SUPPORT_SET_SPEED: 'Alexa.PercentageController', + } + ], + group.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + input_boolean.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], light.DOMAIN: [ 'LIGHT', ('Alexa.PowerController',), { light.SUPPORT_BRIGHTNESS: 'Alexa.BrightnessController', @@ -30,11 +56,28 @@ MAPPING_COMPONENT = { light.SUPPORT_COLOR_TEMP: 'Alexa.ColorTemperatureController', } ], + lock.DOMAIN: ['SMARTLOCK', ('Alexa.LockController',), None], + media_player.DOMAIN: [ + 'TV', ('Alexa.PowerController',), { + media_player.SUPPORT_VOLUME_SET: 'Alexa.Speaker', + media_player.SUPPORT_PLAY: 'Alexa.PlaybackController', + media_player.SUPPORT_PAUSE: 'Alexa.PlaybackController', + media_player.SUPPORT_STOP: 'Alexa.PlaybackController', + media_player.SUPPORT_NEXT_TRACK: 'Alexa.PlaybackController', + media_player.SUPPORT_PREVIOUS_TRACK: 'Alexa.PlaybackController', + } + ], + scene.DOMAIN: ['ACTIVITY_TRIGGER', ('Alexa.SceneController',), None], + script.DOMAIN: ['OTHER', ('Alexa.PowerController',), None], + switch.DOMAIN: ['SWITCH', ('Alexa.PowerController',), None], } +Config = namedtuple('AlexaConfig', 'filter') + + @asyncio.coroutine -def async_handle_message(hass, message): +def async_handle_message(hass, config, message): """Handle incoming API messages.""" assert message[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3' @@ -50,7 +93,7 @@ def async_handle_message(hass, message): "Unsupported API request %s/%s", namespace, name) return api_error(message) - return (yield from funct_ref(hass, message)) + return (yield from funct_ref(hass, config, message)) def api_message(request, name='Response', namespace='Alexa', payload=None): @@ -99,7 +142,7 @@ def api_error(request, error_type='INTERNAL_ERROR', error_message=""): @HANDLERS.register(('Alexa.Discovery', 'Discover')) @asyncio.coroutine -def async_api_discovery(hass, request): +def async_api_discovery(hass, config, request): """Create a API formatted discovery response. Async friendly. @@ -107,18 +150,40 @@ def async_api_discovery(hass, request): discovery_endpoints = [] for entity in hass.states.async_all(): + if not config.filter(entity.entity_id): + _LOGGER.debug("Not exposing %s because filtered by config", + entity.entity_id) + continue + + if entity.attributes.get(ATTR_ALEXA_HIDDEN, False): + _LOGGER.debug("Not exposing %s because alexa_hidden is true", + entity.entity_id) + continue + class_data = MAPPING_COMPONENT.get(entity.domain) if not class_data: continue + friendly_name = entity.attributes.get(ATTR_ALEXA_NAME, entity.name) + description = entity.attributes.get(ATTR_ALEXA_DESCRIPTION, + entity.entity_id) + + # Required description as per Amazon Scene docs + if entity.domain == scene.DOMAIN: + scene_fmt = '%s (Scene connected via Home Assistant)' + description = scene_fmt.format(description) + + cat_key = ATTR_ALEXA_DISPLAY_CATEGORIES + display_categories = entity.attributes.get(cat_key, class_data[0]) + endpoint = { - 'displayCategories': [class_data[0]], + 'displayCategories': [display_categories], 'additionalApplianceDetails': {}, 'endpointId': entity.entity_id.replace('.', '#'), - 'friendlyName': entity.name, - 'description': '', - 'manufacturerName': 'Unknown', + 'friendlyName': friendly_name, + 'description': description, + 'manufacturerName': 'Home Assistant', } actions = set() @@ -153,7 +218,7 @@ def async_api_discovery(hass, request): def extract_entity(funct): """Decorator for extract entity object from request.""" @asyncio.coroutine - def async_api_entity_wrapper(hass, request): + def async_api_entity_wrapper(hass, config, request): """Process a turn on request.""" entity_id = request[API_ENDPOINT]['endpointId'].replace('#', '.') @@ -164,7 +229,7 @@ def extract_entity(funct): request[API_HEADER]['name'], entity_id) return api_error(request, error_type='NO_SUCH_ENDPOINT') - return (yield from funct(hass, request, entity)) + return (yield from funct(hass, config, request, entity)) return async_api_entity_wrapper @@ -172,9 +237,13 @@ def extract_entity(funct): @HANDLERS.register(('Alexa.PowerController', 'TurnOn')) @extract_entity @asyncio.coroutine -def async_api_turn_on(hass, request, entity): +def async_api_turn_on(hass, config, request, entity): """Process a turn on request.""" - yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + yield from hass.services.async_call(domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -184,9 +253,13 @@ def async_api_turn_on(hass, request, entity): @HANDLERS.register(('Alexa.PowerController', 'TurnOff')) @extract_entity @asyncio.coroutine -def async_api_turn_off(hass, request, entity): +def async_api_turn_off(hass, config, request, entity): """Process a turn off request.""" - yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, { + domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN + + yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: entity.entity_id }, blocking=True) @@ -196,7 +269,7 @@ def async_api_turn_off(hass, request, entity): @HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness')) @extract_entity @asyncio.coroutine -def async_api_set_brightness(hass, request, entity): +def async_api_set_brightness(hass, config, request, entity): """Process a set brightness request.""" brightness = int(request[API_PAYLOAD]['brightness']) @@ -211,7 +284,7 @@ def async_api_set_brightness(hass, request, entity): @HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness')) @extract_entity @asyncio.coroutine -def async_api_adjust_brightness(hass, request, entity): +def async_api_adjust_brightness(hass, config, request, entity): """Process a adjust brightness request.""" brightness_delta = int(request[API_PAYLOAD]['brightnessDelta']) @@ -235,7 +308,7 @@ def async_api_adjust_brightness(hass, request, entity): @HANDLERS.register(('Alexa.ColorController', 'SetColor')) @extract_entity @asyncio.coroutine -def async_api_set_color(hass, request, entity): +def async_api_set_color(hass, config, request, entity): """Process a set color request.""" supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES) rgb = color_util.color_hsb_to_RGB( @@ -263,7 +336,7 @@ def async_api_set_color(hass, request, entity): @HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_set_color_temperature(hass, request, entity): +def async_api_set_color_temperature(hass, config, request, entity): """Process a set color temperature request.""" kelvin = int(request[API_PAYLOAD]['colorTemperatureInKelvin']) @@ -279,7 +352,7 @@ def async_api_set_color_temperature(hass, request, entity): ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_decrease_color_temp(hass, request, entity): +def async_api_decrease_color_temp(hass, config, request, entity): """Process a decrease color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) @@ -297,7 +370,7 @@ def async_api_decrease_color_temp(hass, request, entity): ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature')) @extract_entity @asyncio.coroutine -def async_api_increase_color_temp(hass, request, entity): +def async_api_increase_color_temp(hass, config, request, entity): """Process a increase color temperature request.""" current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) @@ -309,3 +382,262 @@ def async_api_increase_color_temp(hass, request, entity): }, blocking=True) return api_message(request) + + +@HANDLERS.register(('Alexa.SceneController', 'Activate')) +@extract_entity +@asyncio.coroutine +def async_api_activate(hass, config, request, entity): + """Process a activate request.""" + yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_set_percentage(hass, config, request, entity): + """Process a set percentage request.""" + percentage = int(request[API_PAYLOAD]['percentage']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = percentage + + yield from hass.services.async_call(entity.domain, service, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_percentage(hass, config, request, entity): + """Process a adjust percentage request.""" + percentage_delta = int(request[API_PAYLOAD]['percentageDelta']) + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = entity.attributes.get(fan.ATTR_SPEED) + + if speed == "off": + current = 0 + elif speed == "low": + current = 33 + elif speed == "medium": + current = 66 + elif speed == "high": + current = 100 + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + elif entity.domain == cover.DOMAIN: + service = SERVICE_SET_COVER_POSITION + + current = entity.attributes.get(cover.ATTR_POSITION) + + data[cover.ATTR_POSITION] = max(0, percentage_delta + current) + + yield from hass.services.async_call(entity.domain, service, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.LockController', 'Lock')) +@extract_entity +@asyncio.coroutine +def async_api_lock(hass, config, request, entity): + """Process a lock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_LOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +# Not supported by Alexa yet +@HANDLERS.register(('Alexa.LockController', 'Unlock')) +@extract_entity +@asyncio.coroutine +def async_api_unlock(hass, config, request, entity): + """Process a unlock request.""" + yield from hass.services.async_call(entity.domain, SERVICE_UNLOCK, { + ATTR_ENTITY_ID: entity.entity_id + }, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'SetVolume')) +@extract_entity +@asyncio.coroutine +def async_api_set_volume(hass, config, request, entity): + """Process a set volume request.""" + volume = round(float(request[API_PAYLOAD]['volume'] / 100), 2) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call(entity.domain, SERVICE_VOLUME_SET, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume')) +@extract_entity +@asyncio.coroutine +def async_api_adjust_volume(hass, config, request, entity): + """Process a adjust volume request.""" + volume_delta = int(request[API_PAYLOAD]['volume']) + + current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) + + # read current state + try: + current = math.floor(int(current_level * 100)) + except ZeroDivisionError: + current = 0 + + volume = float(max(0, volume_delta + current) / 100) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, + } + + yield from hass.services.async_call(entity.domain, + media_player.SERVICE_VOLUME_SET, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.Speaker', 'SetMute')) +@extract_entity +@asyncio.coroutine +def async_api_set_mute(hass, config, request, entity): + """Process a set mute request.""" + mute = bool(request[API_PAYLOAD]['mute']) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_VOLUME_MUTED: mute, + } + + yield from hass.services.async_call(entity.domain, + media_player.SERVICE_VOLUME_MUTE, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Play')) +@extract_entity +@asyncio.coroutine +def async_api_play(hass, config, request, entity): + """Process a play request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PLAY, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Pause')) +@extract_entity +@asyncio.coroutine +def async_api_pause(hass, config, request, entity): + """Process a pause request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_PAUSE, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Stop')) +@extract_entity +@asyncio.coroutine +def async_api_stop(hass, config, request, entity): + """Process a stop request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, SERVICE_MEDIA_STOP, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Next')) +@extract_entity +@asyncio.coroutine +def async_api_next(hass, config, request, entity): + """Process a next request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, + SERVICE_MEDIA_NEXT_TRACK, + data, blocking=True) + + return api_message(request) + + +@HANDLERS.register(('Alexa.PlaybackController', 'Previous')) +@extract_entity +@asyncio.coroutine +def async_api_previous(hass, config, request, entity): + """Process a previous request.""" + data = { + ATTR_ENTITY_ID: entity.entity_id + } + + yield from hass.services.async_call(entity.domain, + SERVICE_MEDIA_PREVIOUS_TRACK, + data, blocking=True) + + return api_message(request) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 5e02f80f229..c8eb1841c0d 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.5'] +REQUIREMENTS = ['pyatv==0.3.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index f3397a884d1..a78b334de0b 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -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.7'] +REQUIREMENTS = ['pyarlo==0.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index 18f2c054b0c..401afe8c62c 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -269,7 +269,8 @@ def setup_device(hass, config, device_config): config) AXIS_DEVICES[device.serial_number] = device - hass.add_job(device.start) + if event_types: + hass.add_job(device.start) return True diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 4ba29e9b2ba..baf9c41cfdf 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -30,6 +30,7 @@ DEVICE_CLASSES = [ 'moving', # On means moving, Off means stopped 'occupancy', # On means occupied, Off means not occupied 'opening', # Door, window, etc. + 'plug', # On means plugged in, Off means unplugged 'power', # Power, over-current, etc 'safety', # Generic on=unsafe, off=safe 'smoke', # Smoke detector diff --git a/homeassistant/components/binary_sensor/aurora.py b/homeassistant/components/binary_sensor/aurora.py index 2530fecb7c1..772792f5785 100644 --- a/homeassistant/components/binary_sensor/aurora.py +++ b/homeassistant/components/binary_sensor/aurora.py @@ -7,25 +7,32 @@ https://home-assistant.io/components/binary_sensor.aurora/ from datetime import timedelta import logging +from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.binary_sensor \ - import (BinarySensorDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME) +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -CONF_THRESHOLD = "forecast_threshold" - _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric" \ + "Administration" +CONF_THRESHOLD = 'forecast_threshold' + +DEFAULT_DEVICE_CLASS = 'visible' DEFAULT_NAME = 'Aurora Visibility' -DEFAULT_DEVICE_CLASS = "visible" DEFAULT_THRESHOLD = 75 +HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0" + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int, @@ -43,10 +50,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: aurora_data = AuroraData( - hass.config.latitude, - hass.config.longitude, - threshold - ) + hass.config.latitude, hass.config.longitude, threshold) aurora_data.update() except requests.exceptions.HTTPError as error: _LOGGER.error( @@ -85,9 +89,9 @@ class AuroraSensor(BinarySensorDevice): attrs = {} if self.aurora_data: - attrs["visibility_level"] = self.aurora_data.visibility_level - attrs["message"] = self.aurora_data.is_visible_text - + attrs['visibility_level'] = self.aurora_data.visibility_level + attrs['message'] = self.aurora_data.is_visible_text + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION return attrs def update(self): @@ -104,10 +108,7 @@ class AuroraData(object): self.longitude = longitude self.number_of_latitude_intervals = 513 self.number_of_longitude_intervals = 1024 - self.api_url = \ - "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt" - self.headers = {"User-Agent": "Home Assistant Aurora Tracker v.0.1.0"} - + self.headers = {USER_AGENT: HA_USER_AGENT} self.threshold = int(threshold) self.is_visible = None self.is_visible_text = None @@ -132,14 +133,14 @@ class AuroraData(object): def get_aurora_forecast(self): """Get forecast data and parse for given long/lat.""" - raw_data = requests.get(self.api_url, headers=self.headers).text + raw_data = requests.get(URL, headers=self.headers, timeout=5).text forecast_table = [ row.strip(" ").split(" ") for row in raw_data.split("\n") if not row.startswith("#") ] - # convert lat and long for data points in table + # Convert lat and long for data points in table converted_latitude = round((self.latitude / 180) * self.number_of_latitude_intervals) converted_longitude = round((self.longitude / 360) diff --git a/homeassistant/components/binary_sensor/spc.py b/homeassistant/components/binary_sensor/spc.py index af3669c2b15..a3a84580edd 100644 --- a/homeassistant/components/binary_sensor/spc.py +++ b/homeassistant/components/binary_sensor/spc.py @@ -67,7 +67,7 @@ class SpcBinarySensor(BinarySensorDevice): spc_registry.register_sensor_device(zone_id, self) @asyncio.coroutine - def async_update_from_spc(self, state): + def async_update_from_spc(self, state, extra): """Update the state of the device.""" self._state = state yield from self.async_update_ha_state() diff --git a/homeassistant/components/binary_sensor/vultr.py b/homeassistant/components/binary_sensor/vultr.py new file mode 100644 index 00000000000..66b5a127be1 --- /dev/null +++ b/homeassistant/components/binary_sensor/vultr.py @@ -0,0 +1,103 @@ +""" +Support for monitoring the state of Vultr subscriptions (VPS). + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_CLASS = 'power' +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription (server) sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + add_devices([VultrBinarySensor(vultr, subscription, name)], True) + + +class VultrBinarySensor(BinarySensorDevice): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr sensor.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self.data['label']) + except (KeyError, TypeError): + return self._name + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.data['power_status'] == 'running' + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEFAULT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count') + } + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index aff1c14b252..f04e0af7be9 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -4,16 +4,17 @@ Support for BloomSky weather station. For more details about this component, please refer to the documentation at https://home-assistant.io/components/bloomsky/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol from homeassistant.const import CONF_API_KEY from homeassistant.helpers import discovery -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,7 @@ class BloomSky(object): """Use the API to retrieve a list of devices.""" _LOGGER.debug("Fetching BloomSky update") response = requests.get( - self.API_URL, headers={"Authorization": self._api_key}, timeout=10) + self.API_URL, headers={AUTHORIZATION: self._api_key}, timeout=10) if response.status_code == 401: raise RuntimeError("Invalid API_KEY") elif response.status_code != 200: diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index be58b61fb8c..4f597771726 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -19,7 +19,7 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(seconds=90) ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' @@ -31,6 +31,7 @@ ATTR_MOTION = 'motion_detection_sensitivity' ATTR_POWERSAVE = 'power_save_mode' ATTR_SIGNAL_STRENGTH = 'signal_strength' ATTR_UNSEEN_VIDEOS = 'unseen_videos' +ATTR_LAST_REFRESH = 'last_refresh' CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' @@ -73,6 +74,8 @@ class ArloCam(Camera): self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) + self._last_refresh = None + self._camera.base_station.refresh_rate = SCAN_INTERVAL.total_seconds() self.attrs = {} def camera_image(self): @@ -105,14 +108,17 @@ class ArloCam(Camera): def device_state_attributes(self): """Return the state attributes.""" return { - ATTR_BATTERY_LEVEL: self.attrs.get(ATTR_BATTERY_LEVEL), - ATTR_BRIGHTNESS: self.attrs.get(ATTR_BRIGHTNESS), - ATTR_FLIPPED: self.attrs.get(ATTR_FLIPPED), - ATTR_MIRRORED: self.attrs.get(ATTR_MIRRORED), - ATTR_MOTION: self.attrs.get(ATTR_MOTION), - ATTR_POWERSAVE: self.attrs.get(ATTR_POWERSAVE), - ATTR_SIGNAL_STRENGTH: self.attrs.get(ATTR_SIGNAL_STRENGTH), - ATTR_UNSEEN_VIDEOS: self.attrs.get(ATTR_UNSEEN_VIDEOS), + name: value for name, value in ( + (ATTR_BATTERY_LEVEL, self._camera.battery_level), + (ATTR_BRIGHTNESS, self._camera.brightness), + (ATTR_FLIPPED, self._camera.flip_state), + (ATTR_MIRRORED, self._camera.mirror_state), + (ATTR_MOTION, self._camera.motion_detection_sensitivity), + (ATTR_POWERSAVE, POWERSAVE_MODE_MAPPING.get( + self._camera.powersave_mode)), + (ATTR_SIGNAL_STRENGTH, self._camera.signal_strength), + (ATTR_UNSEEN_VIDEOS, self._camera.unseen_videos), + ) if value is not None } @property @@ -160,13 +166,4 @@ class ArloCam(Camera): 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 + self._camera.update() diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 61f5773356f..81a7adca1b7 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -9,12 +9,12 @@ from datetime import timedelta import logging import os import functools as ft -from numbers import Number import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.loader import bind_hass +from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -22,7 +22,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, - TEMP_CELSIUS) + TEMP_CELSIUS, PRECISION_WHOLE, PRECISION_TENTHS) DOMAIN = 'climate' @@ -71,11 +71,6 @@ ATTR_OPERATION_LIST = 'operation_list' ATTR_SWING_MODE = 'swing_mode' ATTR_SWING_LIST = 'swing_list' -# The degree of precision for each platform -PRECISION_WHOLE = 1 -PRECISION_HALVES = 0.5 -PRECISION_TENTHS = 0.1 - CONVERTIBLE_ATTRIBUTE = [ ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, @@ -456,12 +451,18 @@ class ClimateDevice(Entity): def state_attributes(self): """Return the optional state attributes.""" data = { - ATTR_CURRENT_TEMPERATURE: - self._convert_for_display(self.current_temperature), - ATTR_MIN_TEMP: self._convert_for_display(self.min_temp), - ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), - ATTR_TEMPERATURE: - self._convert_for_display(self.target_temperature), + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, self.current_temperature, self.temperature_unit, + self.precision), + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), } if self.target_temperature_step is not None: @@ -469,10 +470,12 @@ class ClimateDevice(Entity): target_temp_high = self.target_temperature_high if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + self.precision) + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + self.precision) humidity = self.target_humidity if humidity is not None: @@ -733,24 +736,3 @@ class ClimateDevice(Entity): def max_humidity(self): """Return the maximum humidity.""" return 99 - - def _convert_for_display(self, temp): - """Convert temperature into preferred units for display purposes.""" - if temp is None: - return temp - - # if the temperature is not a number this can cause issues - # with polymer components, so bail early there. - if not isinstance(temp, Number): - raise TypeError("Temperature is not a number: %s" % temp) - - if self.temperature_unit != self.unit_of_measurement: - temp = convert_temperature( - temp, self.temperature_unit, self.unit_of_measurement) - # Round in the units appropriate - if self.precision == PRECISION_HALVES: - return round(temp * 2) / 2.0 - elif self.precision == PRECISION_TENTHS: - return round(temp, 1) - # PRECISION_WHOLE as a fall back - return round(temp) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index d70890317fd..dba096bb632 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -9,12 +9,9 @@ import logging import voluptuous as vol from homeassistant.components.climate import ( - ClimateDevice, PLATFORM_SCHEMA, PRECISION_HALVES, - STATE_AUTO, STATE_ON, STATE_OFF, -) + STATE_ON, STATE_OFF, STATE_AUTO, PLATFORM_SCHEMA, ClimateDevice) from homeassistant.const import ( - CONF_MAC, TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE) - + CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['python-eq3bt==0.1.6'] @@ -58,15 +55,17 @@ class EQ3BTSmartThermostat(ClimateDevice): def __init__(self, _mac, _name): """Initialize the thermostat.""" - # we want to avoid name clash with this module.. + # We want to avoid name clash with this module. import eq3bt as eq3 - self.modes = {eq3.Mode.Open: STATE_ON, - eq3.Mode.Closed: STATE_OFF, - eq3.Mode.Auto: STATE_AUTO, - eq3.Mode.Manual: STATE_MANUAL, - eq3.Mode.Boost: STATE_BOOST, - eq3.Mode.Away: STATE_AWAY} + self.modes = { + eq3.Mode.Open: STATE_ON, + eq3.Mode.Closed: STATE_OFF, + eq3.Mode.Auto: STATE_AUTO, + eq3.Mode.Manual: STATE_MANUAL, + eq3.Mode.Boost: STATE_BOOST, + eq3.Mode.Away: STATE_AWAY, + } self.reverse_modes = {v: k for k, v in self.modes.items()} @@ -153,11 +152,11 @@ class EQ3BTSmartThermostat(ClimateDevice): def device_state_attributes(self): """Return the device specific state attributes.""" dev_specific = { + ATTR_STATE_AWAY_END: self._thermostat.away_end, ATTR_STATE_LOCKED: self._thermostat.locked, ATTR_STATE_LOW_BAT: self._thermostat.low_battery, ATTR_STATE_VALVE: self._thermostat.valve_state, ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open, - ATTR_STATE_AWAY_END: self._thermostat.away_end, } return dev_specific diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 191960d2848..0c0c837b850 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -163,6 +163,7 @@ class GenericThermostat(ClimateDevice): """Set operation mode.""" if operation_mode == STATE_AUTO: self._enabled = True + self._async_control_heating() elif operation_mode == STATE_OFF: self._enabled = False if self._is_device_active: diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index ce6e9580e54..5236c0788fd 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/climate.homematic/ import logging from homeassistant.components.climate import ClimateDevice, STATE_AUTO from homeassistant.components.homematic import HMDevice, ATTR_DISCOVER_DEVICES -from homeassistant.util.temperature import convert from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN, ATTR_TEMPERATURE DEPENDENCIES = ['homematic'] @@ -121,12 +120,12 @@ class HMThermostat(HMDevice, ClimateDevice): @property def min_temp(self): """Return the minimum temperature - 4.5 means off.""" - return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) + return 4.5 @property def max_temp(self): """Return the maximum temperature - 30.5 means on.""" - return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) + return 30.5 def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 784d8a4ed28..69c144985d6 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -13,9 +13,11 @@ from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE 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_SETPOINT_SHIFT_STEP = 'setpoint_shift_step' +CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max' +CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' @@ -28,15 +30,24 @@ CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' DEFAULT_NAME = 'KNX Climate' +DEFAULT_SETPOINT_SHIFT_STEP = 0.5 +DEFAULT_SETPOINT_SHIFT_MAX = 6 +DEFAULT_SETPOINT_SHIFT_MIN = -6 DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - 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_SETPOINT_SHIFT_STEP, + default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( + float, vol.Range(min=0, max=2)), + vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): + vol.All(int, vol.Range(min=-32, max=0)), + vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.All(int, vol.Range(min=0, max=32)), 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, @@ -77,6 +88,7 @@ def async_add_devices_discovery(hass, discovery_info, async_add_devices): def async_add_devices_config(hass, config, async_add_devices): """Set up climate for KNX platform configured within plattform.""" import xknx + climate = xknx.devices.Climate( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), @@ -84,12 +96,16 @@ def async_add_devices_config(hass, config, async_add_devices): CONF_TEMPERATURE_ADDRESS), group_address_target_temperature=config.get( 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), + setpoint_shift_step=config.get( + CONF_SETPOINT_SHIFT_STEP), + setpoint_shift_max=config.get( + CONF_SETPOINT_SHIFT_MAX), + setpoint_shift_min=config.get( + CONF_SETPOINT_SHIFT_MIN), group_address_operation_mode=config.get( CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( @@ -118,8 +134,6 @@ class KNXClimate(ClimateDevice): self.async_register_callbacks() self._unit_of_measurement = TEMP_CELSIUS - self._away = False # not yet supported - self._is_fan_on = False # not yet supported def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" @@ -150,28 +164,25 @@ class KNXClimate(ClimateDevice): """Return the current temperature.""" return self.device.temperature.value + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self.device.setpoint_shift_step + @property def target_temperature(self): """Return the temperature we try to reach.""" - return self.device.target_temperature_comfort + return self.device.target_temperature.value @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 + def min_temp(self): + """Return the minimum temperature.""" + return self.device.target_temperature_min @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 + def max_temp(self): + """Return the maximum temperature.""" + return self.device.target_temperature_max @asyncio.coroutine def async_set_temperature(self, **kwargs): @@ -179,7 +190,7 @@ class KNXClimate(ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - yield from self.device.set_target_temperature_comfort(temperature) + yield from self.device.set_target_temperature(temperature) yield from self.async_update_ha_state() @property diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index f72cefc0841..54d8d8617c7 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -4,46 +4,51 @@ Support for Wink thermostats, Air Conditioners, and Water Heaters. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.wink/ """ -import logging import asyncio +import logging -from homeassistant.components.wink import WinkDevice, DOMAIN from homeassistant.components.climate import ( - STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, STATE_FAN_ONLY, - ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC, - STATE_PERFORMANCE, STATE_HIGH_DEMAND, - STATE_HEAT_PUMP, STATE_GAS) + STATE_ECO, STATE_GAS, STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ELECTRIC, + STATE_FAN_ONLY, STATE_HEAT_PUMP, ATTR_TEMPERATURE, STATE_HIGH_DEMAND, + STATE_PERFORMANCE, ATTR_TARGET_TEMP_LOW, ATTR_CURRENT_HUMIDITY, + ATTR_TARGET_TEMP_HIGH, ClimateDevice) +from homeassistant.components.wink import DOMAIN, WinkDevice from homeassistant.const import ( - TEMP_CELSIUS, STATE_ON, - STATE_OFF, STATE_UNKNOWN) + STATE_ON, STATE_OFF, TEMP_CELSIUS, STATE_UNKNOWN, PRECISION_TENTHS) +from homeassistant.helpers.temperature import display_temp as show_temp _LOGGER = logging.getLogger(__name__) +ATTR_ECO_TARGET = 'eco_target' +ATTR_EXTERNAL_TEMPERATURE = 'external_temperature' +ATTR_OCCUPIED = 'occupied' +ATTR_RHEEM_TYPE = 'rheem_type' +ATTR_SCHEDULE_ENABLED = 'schedule_enabled' +ATTR_SMART_TEMPERATURE = 'smart_temperature' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_VACATION_MODE = 'vacation_mode' + DEPENDENCIES = ['wink'] SPEED_LOW = 'low' SPEED_MEDIUM = 'medium' SPEED_HIGH = 'high' -HA_STATE_TO_WINK = {STATE_AUTO: 'auto', - STATE_ECO: 'eco', - STATE_FAN_ONLY: 'fan_only', - STATE_HEAT: 'heat_only', - STATE_COOL: 'cool_only', - STATE_PERFORMANCE: 'performance', - STATE_HIGH_DEMAND: 'high_demand', - STATE_HEAT_PUMP: 'heat_pump', - STATE_ELECTRIC: 'electric_only', - STATE_GAS: 'gas', - STATE_OFF: 'off'} -WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} +HA_STATE_TO_WINK = { + STATE_AUTO: 'auto', + STATE_COOL: 'cool_only', + STATE_ECO: 'eco', + STATE_ELECTRIC: 'electric_only', + STATE_FAN_ONLY: 'fan_only', + STATE_GAS: 'gas', + STATE_HEAT: 'heat_only', + STATE_HEAT_PUMP: 'heat_pump', + STATE_HIGH_DEMAND: 'high_demand', + STATE_OFF: 'off', + STATE_PERFORMANCE: 'performance', +} -ATTR_EXTERNAL_TEMPERATURE = "external_temperature" -ATTR_SMART_TEMPERATURE = "smart_temperature" -ATTR_ECO_TARGET = "eco_target" -ATTR_OCCUPIED = "occupied" +WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -85,15 +90,18 @@ class WinkThermostat(WinkDevice, ClimateDevice): target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) if self.external_temperature: - data[ATTR_EXTERNAL_TEMPERATURE] = self._convert_for_display( - self.external_temperature) + data[ATTR_EXTERNAL_TEMPERATURE] = show_temp( + self.hass, self.external_temperature, self.temperature_unit, + PRECISION_TENTHS) if self.smart_temperature: data[ATTR_SMART_TEMPERATURE] = self.smart_temperature @@ -139,7 +147,7 @@ class WinkThermostat(WinkDevice, ClimateDevice): @property def eco_target(self): - """Return status of eco target (Is the termostat in eco mode).""" + """Return status of eco target (Is the thermostat in eco mode).""" return self.wink.eco_target() @property @@ -249,7 +257,7 @@ class WinkThermostat(WinkDevice, ClimateDevice): if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list @@ -297,7 +305,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): minimum = 7 # Default minimum min_min = self.wink.min_min_set_point() min_max = self.wink.min_max_set_point() - return_value = minimum if self.current_operation == STATE_HEAT: if min_min: return_value = min_min @@ -323,7 +330,6 @@ class WinkThermostat(WinkDevice, ClimateDevice): maximum = 35 # Default maximum max_min = self.wink.max_min_set_point() max_max = self.wink.max_max_set_point() - return_value = maximum if self.current_operation == STATE_HEAT: if max_min: return_value = max_min @@ -360,13 +366,15 @@ class WinkAC(WinkDevice, ClimateDevice): target_temp_high = self.target_temperature_high target_temp_low = self.target_temperature_low if target_temp_high is not None: - data[ATTR_TARGET_TEMP_HIGH] = self._convert_for_display( - self.target_temperature_high) + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, self.target_temperature_high, self.temperature_unit, + PRECISION_TENTHS) if target_temp_low is not None: - data[ATTR_TARGET_TEMP_LOW] = self._convert_for_display( - self.target_temperature_low) - data["total_consumption"] = self.wink.total_consumption() - data["schedule_enabled"] = self.wink.schedule_enabled() + data[ATTR_TARGET_TEMP_LOW] = show_temp( + self.hass, self.target_temperature_low, self.temperature_unit, + PRECISION_TENTHS) + data[ATTR_TOTAL_CONSUMPTION] = self.wink.total_consumption() + data[ATTR_SCHEDULE_ENABLED] = self.wink.schedule_enabled() return data @@ -377,11 +385,14 @@ class WinkAC(WinkDevice, ClimateDevice): @property def current_operation(self): - """Return current operation ie. heat, cool, idle.""" + """Return current operation ie. auto_eco, cool_only, fan_only.""" if not self.wink.is_on(): current_op = STATE_OFF else: - current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode()) + wink_mode = self.wink.current_mode() + if wink_mode == "auto_eco": + wink_mode = "eco" + current_op = WINK_STATE_TO_HA.get(wink_mode) if current_op is None: current_op = STATE_UNKNOWN return current_op @@ -392,11 +403,13 @@ class WinkAC(WinkDevice, ClimateDevice): op_list = ['off'] modes = self.wink.modes() for mode in modes: + if mode == "auto_eco": + mode = "eco" ha_mode = WINK_STATE_TO_HA.get(mode) if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list @@ -420,15 +433,19 @@ class WinkAC(WinkDevice, ClimateDevice): @property def current_fan_mode(self): - """Return the current fan mode.""" + """ + Return the current fan mode. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ speed = self.wink.current_fan_speed() - if speed <= 0.4 and speed > 0.3: + if speed <= 0.33: return SPEED_LOW - elif speed <= 0.8 and speed > 0.5: + elif speed <= 0.66: return SPEED_MEDIUM - elif speed <= 1.0 and speed > 0.8: + else: return SPEED_HIGH - return STATE_UNKNOWN @property def fan_list(self): @@ -436,11 +453,16 @@ class WinkAC(WinkDevice, ClimateDevice): return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] def set_fan_mode(self, fan): - """Set fan speed.""" + """ + Set fan speed. + + The official Wink app only supports 3 modes [low, medium, high] + which are equal to [0.33, 0.66, 1.0] respectively. + """ if fan == SPEED_LOW: - speed = 0.4 + speed = 0.33 elif fan == SPEED_MEDIUM: - speed = 0.8 + speed = 0.66 elif fan == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) @@ -459,8 +481,8 @@ class WinkWaterHeater(WinkDevice, ClimateDevice): def device_state_attributes(self): """Return the optional state attributes.""" data = {} - data["vacation_mode"] = self.wink.vacation_mode_enabled() - data["rheem_type"] = self.wink.rheem_type() + data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled() + data[ATTR_RHEEM_TYPE] = self.wink.rheem_type() return data @@ -492,7 +514,7 @@ class WinkWaterHeater(WinkDevice, ClimateDevice): if ha_mode is not None: op_list.append(ha_mode) else: - error = "Invaid operation mode mapping. " + mode + \ + error = "Invalid operation mode mapping. " + mode + \ " doesn't map. Please report this." _LOGGER.error(error) return op_list diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c5d709d60c3..e6da2de40f2 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,5 +1,6 @@ """Component to integrate the Home Assistant cloud.""" import asyncio +from datetime import datetime import json import logging import os @@ -8,6 +9,9 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE) +from homeassistant.helpers import entityfilter +from homeassistant.util import dt as dt_util +from homeassistant.components.alexa import smart_home from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -16,6 +20,8 @@ REQUIREMENTS = ['warrant==0.5.0'] _LOGGER = logging.getLogger(__name__) +CONF_ALEXA = 'alexa' +CONF_ALEXA_FILTER = 'filter' CONF_COGNITO_CLIENT_ID = 'cognito_client_id' CONF_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' @@ -24,6 +30,13 @@ MODE_DEV = 'development' DEFAULT_MODE = MODE_DEV DEPENDENCIES = ['http'] +ALEXA_SCHEMA = vol.Schema({ + vol.Optional( + CONF_ALEXA_FILTER, + default=lambda: entityfilter.generate_filter([], [], [], []) + ): entityfilter.FILTER_SCHEMA, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): @@ -33,6 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USER_POOL_ID): str, vol.Required(CONF_REGION): str, vol.Required(CONF_RELAYER): str, + vol.Optional(CONF_ALEXA): ALEXA_SCHEMA }), }, extra=vol.ALLOW_EXTRA) @@ -45,6 +59,10 @@ def async_setup(hass, config): else: kwargs = {CONF_MODE: DEFAULT_MODE} + if CONF_ALEXA not in kwargs: + kwargs[CONF_ALEXA] = ALEXA_SCHEMA({}) + + kwargs[CONF_ALEXA] = smart_home.Config(**kwargs[CONF_ALEXA]) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) @asyncio.coroutine @@ -62,11 +80,11 @@ 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): + region=None, relayer=None, alexa=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode - self.email = None + self.alexa_config = alexa self.id_token = None self.access_token = None self.refresh_token = None @@ -89,7 +107,29 @@ class Cloud: @property def is_logged_in(self): """Get if cloud is logged in.""" - return self.email is not None + return self.id_token is not None + + @property + def subscription_expired(self): + """Return a boolen if the subscription has expired.""" + # For now, don't enforce subscriptions to exist + if 'custom:sub-exp' not in self.claims: + return False + + return dt_util.utcnow() > self.expiration_date + + @property + def expiration_date(self): + """Return the subscription expiration as a UTC datetime object.""" + return datetime.combine( + dt_util.parse_date(self.claims['custom:sub-exp']), + datetime.min.time()).replace(tzinfo=dt_util.UTC) + + @property + def claims(self): + """Get the claims from the id token.""" + from jose import jwt + return jwt.get_unverified_claims(self.id_token) @property def user_info_path(self): @@ -110,18 +150,20 @@ class Cloud: 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: + if self.id_token is not None: yield from self.iot.connect() def path(self, *parts): - """Get config path inside cloud dir.""" + """Get config path inside cloud dir. + + Async friendly. + """ return self.hass.config.path(CONFIG_DIR, *parts) @asyncio.coroutine @@ -129,7 +171,6 @@ class Cloud: """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 @@ -141,7 +182,6 @@ class Cloud: """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, diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 50a88d4be4d..cb9fe15ab4a 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -113,7 +113,6 @@ def login(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() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 334e522f81b..440e4179eea 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,3 +12,8 @@ SERVERS = { # 'relayer': '' # } } + +MESSAGE_EXPIRATION = """ +It looks like your Home Assistant Cloud subscription has expired. Please check +your [account page](/config/cloud/account) to continue using the service. +""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index aa91f5a45e7..d16df130c48 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -79,8 +79,10 @@ class CloudLoginView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): yield from hass.async_add_job(auth_api.login, cloud, data['email'], data['password']) - hass.async_add_job(cloud.iot.connect) + hass.async_add_job(cloud.iot.connect) + # Allow cloud to start connecting. + yield from asyncio.sleep(0, loop=hass.loop) return self.json(_account_data(cloud)) @@ -222,6 +224,10 @@ class CloudConfirmForgotPasswordView(HomeAssistantView): def _account_data(cloud): """Generate the auth data JSON response.""" + claims = cloud.claims + return { - 'email': cloud.email + 'email': claims['email'], + 'sub_exp': claims.get('custom:sub-exp'), + 'cloud': cloud.iot.state, } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 1bb6668e0cc..91ad1cfc6ff 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -9,11 +9,16 @@ 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 +from .const import MESSAGE_EXPIRATION HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' + class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" @@ -25,27 +30,41 @@ class CloudIoT: def __init__(self, cloud): """Initialize the CloudIoT class.""" self.cloud = cloud + # The WebSocket client self.client = None + # Scheduled sleep task till next connection retry + self.retry_task = None + # Boolean to indicate if we wanted the connection to close self.close_requested = False + # The current number of attempts to connect, impacts wait time self.tries = 0 - - @property - def is_connected(self): - """Return if connected to the cloud.""" - return self.client is not None + # Current state of the connection + self.state = STATE_DISCONNECTED @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 + if self.cloud.subscription_expired: + # Try refreshing the token to see if it is still expired. + yield from hass.async_add_job(auth_api.check_token, self.cloud) + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Subscription expired', + 'cloud_subscription_expired') + self.state = STATE_DISCONNECTED + return + + if self.state == STATE_CONNECTED: + raise RuntimeError('Already connected') + + self.state = STATE_CONNECTING + self.close_requested = False + remove_hass_stop_listener = None session = async_get_clientsession(self.cloud.hass) + client = None + disconnect_warn = None @asyncio.coroutine def _handle_hass_stop(event): @@ -54,8 +73,6 @@ class CloudIoT: 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) @@ -70,13 +87,14 @@ class CloudIoT: EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) _LOGGER.info('Connected') + self.state = STATE_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' + disconnect_warn = 'Connection cancelled.' break elif msg.type != WSMsgType.TEXT: @@ -144,20 +162,33 @@ class CloudIoT: self.client = None yield from client.close() - if not self.close_requested: + if self.close_requested: + self.state = STATE_DISCONNECTED + + else: + self.state = STATE_CONNECTING 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()) + try: + # Sleep 0, 5, 10, 15 … up to 30 seconds between retries + self.retry_task = hass.async_add_job(asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + hass.async_add_job(self.connect()) + except asyncio.CancelledError: + # Happens if disconnect called + pass @asyncio.coroutine def disconnect(self): """Disconnect the client.""" self.close_requested = True - yield from self.client.close() + + if self.client is not None: + yield from self.client.close() + elif self.retry_task is not None: + self.retry_task.cancel() @asyncio.coroutine @@ -175,7 +206,9 @@ def async_handle_message(hass, cloud, handler_name, payload): @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)) + return (yield from smart_home.async_handle_message(hass, + cloud.alexa_config, + payload)) @HANDLERS.register('cloud') diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index 53fa200a1b1..c839ab7bc6e 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -1,17 +1,19 @@ """Provide configuration end points for Z-Wave.""" import asyncio +import logging +from collections import deque +from aiohttp.web import Response import homeassistant.core as ha -from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK from homeassistant.components.http import HomeAssistantView from homeassistant.components.config import EditKeyBasedConfigView from homeassistant.components.zwave import const, DEVICE_CONFIG_SCHEMA_ENTRY import homeassistant.helpers.config_validation as cv - +_LOGGER = logging.getLogger(__name__) CONFIG_PATH = 'zwave_device_config.yaml' OZW_LOG_FILENAME = 'OZW_Log.txt' -URL_API_OZW_LOG = '/api/zwave/ozwlog' @asyncio.coroutine @@ -25,12 +27,64 @@ def async_setup(hass): hass.http.register_view(ZWaveNodeGroupView) hass.http.register_view(ZWaveNodeConfigView) hass.http.register_view(ZWaveUserCodeView) - hass.http.register_static_path( - URL_API_OZW_LOG, hass.config.path(OZW_LOG_FILENAME), False) + hass.http.register_view(ZWaveLogView) + hass.http.register_view(ZWaveConfigWriteView) return True +class ZWaveLogView(HomeAssistantView): + """View to read the ZWave log file.""" + + url = "/api/zwave/ozwlog" + name = "api:zwave:ozwlog" + +# pylint: disable=no-self-use + @asyncio.coroutine + def get(self, request): + """Retrieve the lines from ZWave log.""" + try: + lines = int(request.query.get('lines', 0)) + except ValueError: + return Response(text='Invalid datetime', status=400) + + hass = request.app['hass'] + response = yield from hass.async_add_job(self._get_log, hass, lines) + + return Response(text='\n'.join(response)) + + def _get_log(self, hass, lines): + """Retrieve the logfile content.""" + logfilepath = hass.config.path(OZW_LOG_FILENAME) + with open(logfilepath, 'r') as logfile: + data = (line.rstrip() for line in logfile) + if lines == 0: + loglines = list(data) + else: + loglines = deque(data, lines) + return loglines + + +class ZWaveConfigWriteView(HomeAssistantView): + """View to save the ZWave configuration to zwcfg_xxxxx.xml.""" + + url = "/api/zwave/saveconfig" + name = "api:zwave:saveconfig" + + @ha.callback + def post(self, request): + """Save cache configuration to zwcfg_xxxxx.xml.""" + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + if network is None: + return self.json_message('No Z-Wave network data found', + HTTP_NOT_FOUND) + _LOGGER.info("Z-Wave configuration written to file.") + network.write_config() + return self.json_message('Z-Wave configuration saved to file.', + HTTP_OK) + + class ZWaveNodeValueView(HomeAssistantView): """View to return the node values.""" diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 2da8967bddf..7d1b1fd7ef1 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -207,7 +207,7 @@ class Configurator(object): self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) - @async_callback + @asyncio.coroutine def async_handle_service_call(self, call): """Handle a configure service call.""" request_id = call.data.get(ATTR_CONFIGURE_ID) @@ -220,7 +220,8 @@ class Configurator(object): # field validation goes here? if callback: - self.hass.async_add_job(callback, call.data.get(ATTR_FIELDS, {})) + yield from self.hass.async_add_job(callback, + call.data.get(ATTR_FIELDS, {})) def _generate_unique_id(self): """Generate a unique configurator ID.""" diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter/__init__.py similarity index 96% rename from homeassistant/components/counter.py rename to homeassistant/components/counter/__init__.py index 64421306644..aee94c069f6 100644 --- a/homeassistant/components/counter.py +++ b/homeassistant/components/counter/__init__.py @@ -140,13 +140,13 @@ def async_setup(hass, config): hass.services.async_register( DOMAIN, SERVICE_INCREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + descriptions[SERVICE_INCREMENT], SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_DECREMENT, async_handler_service, - descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + descriptions[SERVICE_DECREMENT], SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_RESET, async_handler_service, - descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + descriptions[SERVICE_RESET], SERVICE_SCHEMA) yield from component.async_add_entities(entities) return True diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml new file mode 100644 index 00000000000..ef76f9b9eac --- /dev/null +++ b/homeassistant/components/counter/services.yaml @@ -0,0 +1,20 @@ +# Describes the format for available counter services + +decrement: + description: Decrement a counter. + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' +increment: + description: Increment a counter. + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' +reset: + description: Reset a counter. + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' \ No newline at end of file diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 31e4f1e3cf2..6ad9b093ed8 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.cover import ( @@ -18,7 +19,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -27,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @@ -48,21 +50,25 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): """Return the current position of cover.""" return self._state['current_state'] - def close_cover(self, **kwargs): + @asyncio.coroutine + def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self, **kwargs): + @asyncio.coroutine + def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, **kwargs): + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index d10166a9469..0a49679b9c4 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -104,6 +104,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT Cover.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 05131a039cd..0b18cc72f6e 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -76,6 +76,7 @@ ATTR_LOCATION_NAME = 'location_name' ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' +ATTR_VENDOR = 'vendor' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' @@ -285,11 +286,6 @@ class DeviceTracker(object): if device.track: yield from device.async_update_ha_state() - self.hass.bus.async_fire(EVENT_NEW_DEVICE, { - ATTR_ENTITY_ID: device.entity_id, - ATTR_HOST_NAME: device.host_name, - }) - # During init, we ignore the group if self.group and self.track_new: self.group.async_set_group( @@ -299,6 +295,13 @@ class DeviceTracker(object): # lookup mac vendor string to be stored in config yield from device.set_vendor_for_mac() + self.hass.bus.async_fire(EVENT_NEW_DEVICE, { + ATTR_ENTITY_ID: device.entity_id, + ATTR_HOST_NAME: device.host_name, + ATTR_MAC: device.mac, + ATTR_VENDOR: device.vendor, + }) + # update known_devices.yaml self.hass.async_add_job( self.async_update_config( diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py new file mode 100644 index 00000000000..17dc34d1040 --- /dev/null +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -0,0 +1,138 @@ +""" +Support for the Hitron CODA-4582U, provided by Rogers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.hitron_coda/ +""" +import logging +from collections import namedtuple + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def get_scanner(_hass, config): + """Validate the configuration and return a Nmap scanner.""" + scanner = HitronCODADeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +Device = namedtuple('Device', ['mac', 'name']) + + +class HitronCODADeviceScanner(DeviceScanner): + """This class scans for devices using the CODA's web interface.""" + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + host = config[CONF_HOST] + self._url = 'http://{}/data/getConnectInfo.asp'.format(host) + self._loginurl = 'http://{}/goform/login'.format(host) + + self._username = config.get(CONF_USERNAME) + self._password = config.get(CONF_PASSWORD) + + self._userid = None + + self.success_init = self._update_info() + _LOGGER.info("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, mac): + """Return the name of the device with the given MAC address.""" + name = next(( + device.name for device in self.last_results + if device.mac == mac), None) + return name + + def _login(self): + """Log in to the router. This is required for subsequent api calls.""" + _LOGGER.info("Logging in to CODA...") + + try: + data = [ + ('user', self._username), + ('pws', self._password), + ] + res = requests.post(self._loginurl, data=data, timeout=10) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + self._userid = res.cookies['userid'] + return True + except KeyError: + _LOGGER.error("Failed to log in to router") + return False + + def _update_info(self): + """Get ARP from router.""" + _LOGGER.info("Fetching...") + + if self._userid is None: + if not self._login(): + _LOGGER.error("Could not obtain a user ID from the router") + return False + last_results = [] + + # doing a request + try: + res = requests.get(self._url, timeout=10, cookies={ + 'userid': self._userid + }) + except requests.exceptions.Timeout: + _LOGGER.error( + "Connection to the router timed out at URL %s", self._url) + return False + if res.status_code != 200: + _LOGGER.error( + "Connection failed with http code %s", res.status_code) + return False + try: + result = res.json() + except ValueError: + # If json decoder could not parse the response + _LOGGER.error("Failed to parse response from router") + return False + + # parsing response + for info in result: + mac = info['macAddr'] + name = info['hostName'] + # No address = no item :) + if mac is None: + continue + + last_results.append(Device(mac.upper(), name)) + + self.last_results = last_results + + _LOGGER.info("Request successful") + return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 77241e1a8ab..0c869dd4b57 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -367,6 +367,29 @@ def async_handle_transition_message(hass, context, message): message['event']) +@asyncio.coroutine +def async_handle_waypoint(hass, name_base, waypoint): + """Handle a waypoint.""" + name = waypoint['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = waypoint['lat'] + lon = waypoint['lon'] + rad = waypoint['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + return + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + yield from zone.async_update_ha_state() + + +@HANDLERS.register('waypoint') @HANDLERS.register('waypoints') @asyncio.coroutine def async_handle_waypoints_message(hass, context, message): @@ -380,30 +403,17 @@ def async_handle_waypoints_message(hass, context, message): if user not in context.waypoint_whitelist: return - wayps = message['waypoints'] + if 'waypoints' in message: + wayps = message['waypoints'] + else: + wayps = [message] _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) name_base = ' '.join(_parse_topic(message['topic'])) for wayp in wayps: - name = wayp['desc'] - pretty_name = '{} - {}'.format(name_base, name) - lat = wayp['lat'] - lon = wayp['lon'] - rad = wayp['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - continue - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - yield from zone.async_update_ha_state() + yield from async_handle_waypoint(hass, name_base, wayp) @HANDLERS.register('encrypted') @@ -423,10 +433,22 @@ def async_handle_encrypted_message(hass, context, message): @HANDLERS.register('lwt') +@HANDLERS.register('configuration') +@HANDLERS.register('beacon') +@HANDLERS.register('cmd') +@HANDLERS.register('steps') +@HANDLERS.register('card') @asyncio.coroutine -def async_handle_lwt_message(hass, context, message): - """Handle an lwt message.""" - _LOGGER.debug('Not handling lwt message: %s', message) +def async_handle_not_impl_msg(hass, context, message): + """Handle valid but not implemented message types.""" + _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) + + +@asyncio.coroutine +def async_handle_unsupported_msg(hass, context, message): + """Handle an unsupported or invalid message type.""" + _LOGGER.warning('Received unsupported message type: %s.', + message.get('_type')) @asyncio.coroutine @@ -434,11 +456,6 @@ def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') - handler = HANDLERS.get(msgtype) - - if handler is None: - _LOGGER.warning( - 'Received unsupported message type: %s.', msgtype) - return + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) yield from handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 8c1bf6dc67b..add027e1823 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,14 +14,14 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST +REQUIREMENTS = ['pysnmp==4.4.2'] + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.4.1'] - -CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' -CONF_PRIVKEY = 'privkey' CONF_BASEOID = 'baseoid' +CONF_COMMUNITY = 'community' +CONF_PRIVKEY = 'privkey' DEFAULT_COMMUNITY = 'public' diff --git a/homeassistant/components/device_tracker/swisscom.py b/homeassistant/components/device_tracker/swisscom.py index e64d30942ca..d5826ecedff 100644 --- a/homeassistant/components/device_tracker/swisscom.py +++ b/homeassistant/components/device_tracker/swisscom.py @@ -6,13 +6,14 @@ https://home-assistant.io/components/device_tracker.swisscom/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,7 @@ class SwisscomDeviceScanner(DeviceScanner): def get_swisscom_data(self): """Retrieve data from Swisscom and return parsed result.""" url = 'http://{}/ws'.format(self.host) - headers = {'Content-Type': 'application/x-sah-ws-4-call+json'} + headers = {CONTENT_TYPE: 'application/x-sah-ws-4-call+json'} data = """ {"service":"Devices", "method":"get", "parameters":{"expression":"lan and not self"}}""" diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py new file mode 100644 index 00000000000..f27a950a49f --- /dev/null +++ b/homeassistant/components/device_tracker/tile.py @@ -0,0 +1,124 @@ +""" +Support for Tile® Bluetooth trackers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tile/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify +from homeassistant.util.json import load_json, save_json + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pytile==1.0.0'] + +CLIENT_UUID_CONFIG_FILE = '.tile.conf' +DEFAULT_ICON = 'mdi:bluetooth' +DEVICE_TYPES = ['PHONE', 'TILE'] + +ATTR_ALTITUDE = 'altitude' +ATTR_CONNECTION_STATE = 'connection_state' +ATTR_IS_DEAD = 'is_dead' +ATTR_IS_LOST = 'is_lost' +ATTR_LAST_SEEN = 'last_seen' +ATTR_LAST_UPDATED = 'last_updated' +ATTR_RING_STATE = 'ring_state' +ATTR_VOIP_STATE = 'voip_state' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), +}) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Validate the configuration and return a Tile scanner.""" + TileDeviceScanner(hass, config, see) + return True + + +class TileDeviceScanner(DeviceScanner): + """Define a device scanner for Tiles.""" + + def __init__(self, hass, config, see): + """Initialize.""" + from pytile import Client + + _LOGGER.debug('Received configuration data: %s', config) + + # Load the client UUID (if it exists): + config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + _LOGGER.debug('Using existing client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + config_data['client_uuid']) + else: + _LOGGER.debug('Generating new client UUID') + self._client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD]) + + if not save_json( + hass.config.path(CLIENT_UUID_CONFIG_FILE), + {'client_uuid': self._client.client_uuid}): + _LOGGER.error("Failed to save configuration file") + + _LOGGER.debug('Client UUID: %s', self._client.client_uuid) + _LOGGER.debug('User UUID: %s', self._client.user_uuid) + + self._types = config.get(CONF_MONITORED_VARIABLES) + + self.devices = {} + self.see = see + + track_utc_time_change( + hass, self._update_info, second=range(0, 60, 30)) + + self._update_info() + + def _update_info(self, now=None) -> None: + """Update the device info.""" + device_data = self._client.get_tiles(type_whitelist=self._types) + + try: + self.devices = device_data['result'] + except KeyError: + _LOGGER.warning('No Tiles found') + _LOGGER.debug(device_data) + return + + for info in self.devices.values(): + dev_id = 'tile_{0}'.format(slugify(info['name'])) + lat = info['tileState']['latitude'] + lon = info['tileState']['longitude'] + + attrs = { + ATTR_ALTITUDE: info['tileState']['altitude'], + ATTR_CONNECTION_STATE: info['tileState']['connection_state'], + ATTR_IS_DEAD: info['is_dead'], + ATTR_IS_LOST: info['tileState']['is_lost'], + ATTR_LAST_SEEN: info['tileState']['timestamp'], + ATTR_LAST_UPDATED: device_data['timestamp_ms'], + ATTR_RING_STATE: info['tileState']['ring_state'], + ATTR_VOIP_STATE: info['tileState']['voip_state'], + } + + self.see( + dev_id=dev_id, + gps=(lat, lon), + attributes=attrs, + icon=DEFAULT_ICON + ) diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index a52de48d061..6c5fb697c07 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -5,21 +5,27 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.tplink/ """ import base64 +from datetime import datetime import hashlib import logging import re -from datetime import datetime +from aiohttp.hdrs import ( + ACCEPT, COOKIE, PRAGMA, REFERER, CONNECTION, KEEP_ALIVE, USER_AGENT, + CONTENT_TYPE, CACHE_CONTROL, ACCEPT_ENCODING, ACCEPT_LANGUAGE) import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) +HTTP_HEADER_NO_CACHE = 'no-cache' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string, @@ -78,7 +84,7 @@ class TplinkDeviceScanner(DeviceScanner): referer = 'http://{}'.format(self.host) page = requests.get( url, auth=(self.username, self.password), - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) result = self.parse_macs.findall(page.text) @@ -123,7 +129,7 @@ class Tplink2DeviceScanner(TplinkDeviceScanner): .format(b64_encoded_username_password) response = requests.post( - url, headers={'referer': referer, 'cookie': cookie}, + url, headers={REFERER: referer, COOKIE: cookie}, timeout=4) try: @@ -174,11 +180,11 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): .format(self.host) referer = 'http://{}/webpages/login.html'.format(self.host) - # If possible implement rsa encryption of password here. + # If possible implement RSA encryption of password here. response = requests.post( url, params={'operation': 'login', 'username': self.username, 'password': self.password}, - headers={'referer': referer}, timeout=4) + headers={REFERER: referer}, timeout=4) try: self.stok = response.json().get('data').get('stok') @@ -207,11 +213,9 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): 'form=statistics').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - response = requests.post(url, - params={'operation': 'load'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}, - timeout=5) + response = requests.post( + url, params={'operation': 'load'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}, timeout=5) try: json_response = response.json() @@ -248,10 +252,9 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): 'form=logout').format(self.host, self.stok) referer = 'http://{}/webpages/index.html'.format(self.host) - requests.post(url, - params={'operation': 'write'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}) + requests.post( + url, params={'operation': 'write'}, headers={REFERER: referer}, + cookies={'sysauth': self.sysauth}) self.stok = '' self.sysauth = '' @@ -292,7 +295,7 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): # Create the authorization cookie. cookie = 'Authorization=Basic {}'.format(self.credentials) - response = requests.get(url, headers={'cookie': cookie}) + response = requests.get(url, headers={COOKIE: cookie}) try: result = re.search(r'window.parent.location.href = ' @@ -326,8 +329,8 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): cookie = 'Authorization=Basic {}'.format(self.credentials) page = requests.get(url, headers={ - 'cookie': cookie, - 'referer': referer + COOKIE: cookie, + REFERER: referer, }) mac_results.extend(self.parse_macs.findall(page.text)) @@ -361,31 +364,31 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): base_url = 'http://{}'.format(self.host) header = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" - " rv:53.0) Gecko/20100101 Firefox/53.0", - "Accept": "application/json, text/javascript, */*; q=0.01", - "Accept-Language": "Accept-Language: en-US,en;q=0.5", - "Accept-Encoding": "gzip, deflate", - "Content-Type": "application/x-www-form-urlencoded; " - "charset=UTF-8", - "X-Requested-With": "XMLHttpRequest", - "Referer": "http://" + self.host + "/", - "Connection": "keep-alive", - "Pragma": "no-cache", - "Cache-Control": "no-cache" + USER_AGENT: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" + " rv:53.0) Gecko/20100101 Firefox/53.0", + ACCEPT: "application/json, text/javascript, */*; q=0.01", + ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5", + ACCEPT_ENCODING: "gzip, deflate", + CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8", + HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", + REFERER: "http://{}/".format(self.host), + CONNECTION: KEEP_ALIVE, + PRAGMA: HTTP_HEADER_NO_CACHE, + CACHE_CONTROL: HTTP_HEADER_NO_CACHE, } password_md5 = hashlib.md5( self.password.encode('utf')).hexdigest().upper() - # create a session to handle cookie easier + # Create a session to handle cookie easier session = requests.session() session.get(base_url, headers=header) login_data = {"username": self.username, "password": password_md5} session.post(base_url, login_data, headers=header) - # a timestamp is required to be sent as get parameter + # A timestamp is required to be sent as get parameter timestamp = int(datetime.now().timestamp() * 1e3) client_list_url = '{}/data/monitor.client.client.json'.format( @@ -393,18 +396,17 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): get_params = { 'operation': 'load', - '_': timestamp + '_': timestamp, } - response = session.get(client_list_url, - headers=header, - params=get_params) + response = session.get( + client_list_url, headers=header, params=get_params) session.close() try: list_of_devices = response.json() except ValueError: _LOGGER.error("AP didn't respond with JSON. " - "Check if credentials are correct.") + "Check if credentials are correct") return False if list_of_devices: diff --git a/homeassistant/components/device_tracker/upc_connect.py b/homeassistant/components/device_tracker/upc_connect.py index 338ce34048e..fbcd753713c 100644 --- a/homeassistant/components/device_tracker/upc_connect.py +++ b/homeassistant/components/device_tracker/upc_connect.py @@ -8,28 +8,28 @@ import asyncio import logging import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, HTTP_HEADER_X_REQUESTED_WITH from homeassistant.helpers.aiohttp_client import async_get_clientsession - +import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['defusedxml==0.5.0'] _LOGGER = logging.getLogger(__name__) +CMD_DEVICES = 123 + DEFAULT_IP = '192.168.0.1' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, }) -CMD_DEVICES = 123 - @asyncio.coroutine def async_get_scanner(hass, config): @@ -52,11 +52,11 @@ class UPCDeviceScanner(DeviceScanner): self.token = None self.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Referer': "http://{}/index.html".format(self.host), - 'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36") + HTTP_HEADER_X_REQUESTED_WITH: 'XMLHttpRequest', + REFERER: "http://{}/index.html".format(self.host), + USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36") } self.websession = async_get_clientsession(hass) @@ -95,8 +95,7 @@ class UPCDeviceScanner(DeviceScanner): with async_timeout.timeout(10, loop=self.hass.loop): response = yield from self.websession.get( "http://{}/common_page/login.html".format(self.host), - headers=self.headers - ) + headers=self.headers) yield from response.text() @@ -118,17 +117,15 @@ class UPCDeviceScanner(DeviceScanner): response = yield from self.websession.post( "http://{}/xml/getter.xml".format(self.host), data="token={}&fun={}".format(self.token, function), - headers=self.headers, - allow_redirects=False - ) + headers=self.headers, allow_redirects=False) - # error? + # Error? if response.status != 200: _LOGGER.warning("Receive http code %d", response.status) self.token = None return - # load data, store token for next request + # Load data, store token for next request self.token = response.cookies['sessionToken'].value return (yield from response.text()) diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 3f2cae112f5..726b8d99e01 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol -from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST +from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import intent, template from homeassistant.components.http import HomeAssistantView @@ -26,6 +26,8 @@ DOMAIN = 'dialogflow' INTENTS_API_ENDPOINT = '/api/dialogflow' +SOURCE = "Home Assistant Dialogflow" + CONFIG_SCHEMA = vol.Schema({ DOMAIN: {} }, extra=vol.ALLOW_EXTRA) @@ -128,5 +130,5 @@ class DialogflowResponse(object): return { 'speech': self.speech, 'displayText': self.speech, - 'source': PROJECT_NAME, + 'source': SOURCE, } diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py index 07f432a5218..d832bbdfdd1 100644 --- a/homeassistant/components/downloader.py +++ b/homeassistant/components/downloader.py @@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_FILENAME = 'filename' ATTR_SUBDIR = 'subdir' ATTR_URL = 'url' +ATTR_OVERWRITE = 'overwrite' CONF_DOWNLOAD_DIR = 'download_dir' @@ -31,6 +32,7 @@ SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({ vol.Required(ATTR_URL): cv.url, vol.Optional(ATTR_SUBDIR): cv.string, vol.Optional(ATTR_FILENAME): cv.string, + vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean, }) CONFIG_SCHEMA = vol.Schema({ @@ -66,6 +68,8 @@ def setup(hass, config): filename = service.data.get(ATTR_FILENAME) + overwrite = service.data.get(ATTR_OVERWRITE) + if subdir: subdir = sanitize_filename(subdir) @@ -73,8 +77,13 @@ def setup(hass, config): req = requests.get(url, stream=True, timeout=10) - if req.status_code == 200: + if req.status_code != 200: + _LOGGER.warning( + "downloading '%s' failed, stauts_code=%d", + url, + req.status_code) + else: if filename is None and \ 'content-disposition' in req.headers: match = re.findall(r"filename=(\S+)", @@ -109,20 +118,21 @@ def setup(hass, config): # If file exist append a number. # We test filename, filename_2.. - tries = 1 - final_path = path + ext - while os.path.isfile(final_path): - tries += 1 + if not overwrite: + tries = 1 + final_path = path + ext + while os.path.isfile(final_path): + tries += 1 - final_path = "{}_{}.{}".format(path, tries, ext) + final_path = "{}_{}.{}".format(path, tries, ext) - _LOGGER.info("%s -> %s", url, final_path) + _LOGGER.debug("%s -> %s", url, final_path) with open(final_path, 'wb') as fil: for chunk in req.iter_content(1024): fil.write(chunk) - _LOGGER.info("Downloading of %s done", url) + _LOGGER.debug("Downloading of %s done", url) except requests.exceptions.ConnectionError: _LOGGER.exception("ConnectionError occurred for %s", url) diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 3c3eefe54cc..879f6a61899 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -72,6 +72,7 @@ class EnOceanDongle: """ from enocean.protocol.packet import RadioPacket if isinstance(temp, RadioPacket): + _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None if temp.data[6] == 0x30: @@ -94,20 +95,20 @@ class EnOceanDongle: value = temp.data[2] for device in self.__devices: if rxtype == "wallswitch" and device.stype == "listener": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value, temp.data[1]) if rxtype == "power" and device.stype == "powersensor": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "power" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) if rxtype == "switch_status" and device.stype == "switch": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": - if temp.sender == self._combine_hex(device.dev_id): + if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 3b0e0385f13..8fc77d1bf5e 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f9c9e2ddcaf..e7cfcf8d88c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -9,9 +9,11 @@ import hashlib import json import logging import os +from urllib.parse import urlparse from aiohttp import web import voluptuous as vol +import jinja2 import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView @@ -21,21 +23,19 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20171105.0'] +REQUIREMENTS = ['home-assistant-frontend==20171118.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] -URL_PANEL_COMPONENT = '/frontend/panels/{}.html' URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' -POLYMER_PATH = os.path.join(os.path.dirname(__file__), - 'home-assistant-polymer/') -FINAL_PATH = os.path.join(POLYMER_PATH, 'final') - CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' CONF_FRONTEND_REPO = 'development_repo' +CONF_JS_VERSION = 'javascript_version' +JS_DEFAULT_OPTION = 'es5' +JS_OPTIONS = ['es5', 'latest', 'auto'] DEFAULT_THEME_COLOR = '#03A9F4' @@ -61,6 +61,7 @@ for size in (192, 384, 512, 1024): DATA_FINALIZE_PANEL = 'frontend_finalize_panel' DATA_PANELS = 'frontend_panels' +DATA_JS_VERSION = 'frontend_js_version' DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -68,8 +69,6 @@ DEFAULT_THEME = 'default' PRIMARY_COLOR = 'primary-color' -# To keep track we don't register a component twice (gives a warning) -# _REGISTERED_COMPONENTS = set() _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ @@ -80,6 +79,8 @@ CONFIG_SCHEMA = vol.Schema({ }), vol.Optional(CONF_EXTRA_HTML_URL): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_JS_VERSION, default=JS_DEFAULT_OPTION): + vol.In(JS_OPTIONS) }), }, extra=vol.ALLOW_EXTRA) @@ -102,8 +103,9 @@ class AbstractPanel: # Title to show in the sidebar (optional) sidebar_title = None - # Url to the webcomponent - webcomponent_url = None + # Url to the webcomponent (depending on JS version) + webcomponent_url_es5 = None + webcomponent_url_latest = None # Url to show the panel in the frontend frontend_url_path = None @@ -135,16 +137,20 @@ class AbstractPanel: 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), index_view.get) - def as_dict(self): + def to_response(self, hass, request): """Panel as dictionary.""" - return { + result = { 'component_name': self.component_name, 'icon': self.sidebar_icon, 'title': self.sidebar_title, - 'url': self.webcomponent_url, 'url_path': self.frontend_url_path, 'config': self.config, } + if _is_latest(hass.data[DATA_JS_VERSION], request): + result['url'] = self.webcomponent_url_latest + else: + result['url'] = self.webcomponent_url_es5 + return result class BuiltInPanel(AbstractPanel): @@ -166,19 +172,21 @@ class BuiltInPanel(AbstractPanel): If frontend_repository_path is set, will be prepended to path of built-in components. """ - panel_path = 'panels/ha-panel-{}.html'.format(self.component_name) - if frontend_repository_path is None: import hass_frontend + import hass_frontend_es5 - self.webcomponent_url = \ - '/static/panels/ha-panel-{}-{}.html'.format( + self.webcomponent_url_latest = \ + '/frontend_latest/panels/ha-panel-{}-{}.html'.format( self.component_name, - hass_frontend.FINGERPRINTS[panel_path]) - + hass_frontend.FINGERPRINTS[self.component_name]) + self.webcomponent_url_es5 = \ + '/frontend_es5/panels/ha-panel-{}-{}.html'.format( + self.component_name, + hass_frontend_es5.FINGERPRINTS[self.component_name]) else: # Dev mode - self.webcomponent_url = \ + self.webcomponent_url_es5 = self.webcomponent_url_latest = \ '/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format( self.component_name, self.component_name) @@ -208,18 +216,20 @@ class ExternalPanel(AbstractPanel): """ try: if self.md5 is None: - yield from hass.async_add_job(_fingerprint, self.path) + self.md5 = yield from hass.async_add_job( + _fingerprint, self.path) except OSError: _LOGGER.error('Cannot find or access %s at %s', self.component_name, self.path) hass.data[DATA_PANELS].pop(self.frontend_url_path) + return - self.webcomponent_url = \ + self.webcomponent_url_es5 = self.webcomponent_url_latest = \ URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5) if self.component_name not in self.REGISTERED_COMPONENTS: hass.http.register_static_path( - self.webcomponent_url, self.path, + self.webcomponent_url_latest, self.path, # if path is None, we're in prod mode, so cache static assets frontend_repository_path is None) self.REGISTERED_COMPONENTS.add(self.component_name) @@ -281,31 +291,50 @@ def async_setup(hass, config): repo_path = conf.get(CONF_FRONTEND_REPO) is_dev = repo_path is not None + hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: hass.http.register_static_path( "/home-assistant-polymer", repo_path, False) hass.http.register_static_path( "/static/translations", - os.path.join(repo_path, "build/translations"), False) - sw_path = os.path.join(repo_path, "build/service_worker.js") + os.path.join(repo_path, "build-translations"), False) + sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") + sw_path_latest = os.path.join(repo_path, "build/service_worker.js") static_path = os.path.join(repo_path, 'hass_frontend') + frontend_es5_path = os.path.join(repo_path, 'build-es5') + frontend_latest_path = os.path.join(repo_path, 'build') else: import hass_frontend - frontend_path = hass_frontend.where() - sw_path = os.path.join(frontend_path, "service_worker.js") - static_path = frontend_path + import hass_frontend_es5 + sw_path_es5 = os.path.join(hass_frontend_es5.where(), + "service_worker.js") + sw_path_latest = os.path.join(hass_frontend.where(), + "service_worker.js") + # /static points to dir with files that are JS-type agnostic. + # ES5 files are served from /frontend_es5. + # ES6 files are served from /frontend_latest. + static_path = hass_frontend.where() + frontend_es5_path = hass_frontend_es5.where() + frontend_latest_path = static_path - hass.http.register_static_path("/service_worker.js", sw_path, False) + hass.http.register_static_path( + "/service_worker_es5.js", sw_path_es5, False) + hass.http.register_static_path( + "/service_worker.js", sw_path_latest, False) hass.http.register_static_path( "/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev) hass.http.register_static_path("/static", static_path, not is_dev) + hass.http.register_static_path( + "/frontend_latest", frontend_latest_path, not is_dev) + hass.http.register_static_path( + "/frontend_es5", frontend_es5_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(is_dev) + index_view = IndexView(repo_path, js_version) hass.http.register_view(index_view) @asyncio.coroutine @@ -405,40 +434,40 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, use_repo): + def __init__(self, repo_path, js_option): """Initialize the frontend view.""" - from jinja2 import FileSystemLoader, Environment + self.repo_path = repo_path + self.js_option = js_option + self._template_cache = {} - self.use_repo = use_repo - self.templates = Environment( - autoescape=True, - loader=FileSystemLoader( - os.path.join(os.path.dirname(__file__), 'templates/') - ) - ) + def get_template(self, latest): + """Get template.""" + if self.repo_path is not None: + root = self.repo_path + elif latest: + import hass_frontend + root = hass_frontend.where() + else: + import hass_frontend_es5 + root = hass_frontend_es5.where() + + tpl = self._template_cache.get(root) + + if tpl is None: + with open(os.path.join(root, 'index.html')) as file: + tpl = jinja2.Template(file.read()) + + # Cache template if not running from repository + if self.repo_path is None: + self._template_cache[root] = tpl + + return tpl @asyncio.coroutine def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] - - if self.use_repo: - core_url = '/home-assistant-polymer/build/core.js' - compatibility_url = \ - '/home-assistant-polymer/build/compatibility.js' - ui_url = '/home-assistant-polymer/src/home-assistant.html' - icons_fp = '' - icons_url = '/static/mdi.html' - else: - import hass_frontend - core_url = '/static/core-{}.js'.format( - hass_frontend.FINGERPRINTS['core.js']) - compatibility_url = '/static/compatibility-{}.js'.format( - hass_frontend.FINGERPRINTS['compatibility.js']) - ui_url = '/static/frontend-{}.html'.format( - hass_frontend.FINGERPRINTS['frontend.html']) - icons_fp = '-{}'.format(hass_frontend.FINGERPRINTS['mdi.html']) - icons_url = '/static/mdi{}.html'.format(icons_fp) + latest = _is_latest(self.js_option, request) if request.path == '/': panel = 'states' @@ -447,28 +476,27 @@ class IndexView(HomeAssistantView): if panel == 'states': panel_url = '' + elif latest: + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_latest else: - panel_url = hass.data[DATA_PANELS][panel].webcomponent_url + panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 no_auth = 'true' if hass.config.api.api_password and not is_trusted_ip(request): # do not try to auto connect on load no_auth = 'false' - template = yield from hass.async_add_job( - self.templates.get_template, 'index.html') + template = yield from hass.async_add_job(self.get_template, latest) - # pylint is wrong - # pylint: disable=no-member - # This is a jinja2 template, not a HA template so we call 'render'. resp = template.render( - core_url=core_url, ui_url=ui_url, - compatibility_url=compatibility_url, no_auth=no_auth, - icons_url=icons_url, icons=icons_fp, - panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=self.use_repo, + no_auth=no_auth, + panel_url=panel_url, + panels=hass.data[DATA_PANELS], + dev_mode=self.repo_path is not None, theme_color=MANIFEST_JSON['theme_color'], - extra_urls=hass.data[DATA_EXTRA_HTML_URL]) + extra_urls=hass.data[DATA_EXTRA_HTML_URL], + latest=latest, + ) return web.Response(text=resp, content_type='text/html') @@ -483,8 +511,8 @@ class ManifestJSONView(HomeAssistantView): @asyncio.coroutine def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" - msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') - return web.Response(body=msg, content_type="application/manifest+json") + msg = json.dumps(MANIFEST_JSON, sort_keys=True) + return web.Response(text=msg, content_type="application/manifest+json") class ThemesView(HomeAssistantView): @@ -509,3 +537,20 @@ def _fingerprint(path): """Fingerprint a file.""" with open(path) as fil: return hashlib.md5(fil.read().encode('utf-8')).hexdigest() + + +def _is_latest(js_option, request): + """ + Return whether we should serve latest untranspiled code. + + Set according to user's preference and URL override. + """ + if request is None: + return js_option == 'latest' + latest_in_query = 'latest' in request.query or ( + request.headers.get('Referer') and + 'latest' in urlparse(request.headers['Referer']).query) + es5_in_query = 'es5' in request.query or ( + request.headers.get('Referer') and + 'es5' in urlparse(request.headers['Referer']).query) + return latest_in_query or (not es5_in_query and js_option == 'latest') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html deleted file mode 100644 index c941fbc15ae..00000000000 --- a/homeassistant/components/frontend/templates/index.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - Home Assistant - - - - - - {% if not dev_mode %} - - {% for panel in panels.values() -%} - - {% endfor -%} - {% endif %} - - - - - - - - - - - - - -
-
- Home Assistant had trouble
connecting to the server.

- TRY AGAIN -
-
- - {# #} - - - {% if not dev_mode %} - - {% endif %} - - - {% if panel_url -%} - - {% endif -%} - - {% for extra_url in extra_urls -%} - - {% endfor -%} - - diff --git a/homeassistant/components/gc100.py b/homeassistant/components/gc100.py index 7c772e345ae..bc627d44417 100644 --- a/homeassistant/components/gc100.py +++ b/homeassistant/components/gc100.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-gc100==1.0.1a'] +REQUIREMENTS = ['python-gc100==1.0.3a'] _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def setup(hass, base_config): gc_device = gc100.GC100SocketClient(host, port) - def cleanup_gc100(): + def cleanup_gc100(event): """Stuff to do before stopping.""" gc_device.quit() diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 53de8764a12..2db36d8829f 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -4,9 +4,13 @@ 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 os import asyncio import logging +import aiohttp +import async_timeout + import voluptuous as vol # Typing imports @@ -15,11 +19,16 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA +from homeassistant import config as conf_util from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.loader import bind_hass from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_CLIENT_ID, CONF_ACCESS_TOKEN, - CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS + CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, + CONF_AGENT_USER_ID, CONF_API_KEY, + SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL ) from .auth import GoogleAssistantAuthView from .http import GoogleAssistantView @@ -28,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] +DEFAULT_AGENT_USER_ID = 'home-assistant' + CONFIG_SCHEMA = vol.Schema( { DOMAIN: { @@ -36,17 +47,57 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean, vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list, + vol.Optional(CONF_AGENT_USER_ID, + default=DEFAULT_AGENT_USER_ID): cv.string, + vol.Optional(CONF_API_KEY): cv.string } }, extra=vol.ALLOW_EXTRA) +@bind_hass +def request_sync(hass): + """Request sync.""" + hass.services.call(DOMAIN, SERVICE_REQUEST_SYNC) + + @asyncio.coroutine def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Google Actions component.""" config = yaml_config.get(DOMAIN, {}) - + agent_user_id = config.get(CONF_AGENT_USER_ID) + api_key = config.get(CONF_API_KEY) + if api_key is not None: + descriptions = yield from hass.async_add_job( + conf_util.load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) hass.http.register_view(GoogleAssistantAuthView(hass, config)) hass.http.register_view(GoogleAssistantView(hass, config)) + @asyncio.coroutine + def request_sync_service_handler(call): + """Handle request sync service calls.""" + websession = async_get_clientsession(hass) + try: + with async_timeout.timeout(5, loop=hass.loop): + res = yield from websession.post( + REQUEST_SYNC_BASE_URL, + params={'key': api_key}, + json={'agent_user_id': agent_user_id}) + _LOGGER.info("Submitted request_sync request to Google") + res.raise_for_status() + except aiohttp.ClientResponseError: + body = yield from res.read() + _LOGGER.error( + 'request_sync request failed: %d %s', res.status, body) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Could not contact Google for request_sync") + +# Register service only if api key is provided + if api_key is not None: + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler, + descriptions.get(SERVICE_REQUEST_SYNC)) + return True diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 80afad82938..c15f14bccdb 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -13,6 +13,8 @@ CONF_PROJECT_ID = 'project_id' CONF_ACCESS_TOKEN = 'access_token' CONF_CLIENT_ID = 'client_id' CONF_ALIASES = 'aliases' +CONF_AGENT_USER_ID = 'agent_user_id' +CONF_API_KEY = 'api_key' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ @@ -44,3 +46,7 @@ TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' TYPE_SCENE = PREFIX_TYPES + 'SCENE' TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' + +SERVICE_REQUEST_SYNC = 'request_sync' +HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' +REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index adc626f73b7..ab9705432fb 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -7,17 +7,18 @@ https://home-assistant.io/components/google_assistant/ import asyncio import logging +from typing import Any, Dict # NOQA + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response # NOQA + # 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 homeassistant.const import HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED +from homeassistant.core import HomeAssistant # NOQA +from homeassistant.helpers.entity import Entity # NOQA from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, @@ -26,7 +27,9 @@ from .const import ( DEFAULT_EXPOSED_DOMAINS, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, - ATTR_GOOGLE_ASSISTANT) + ATTR_GOOGLE_ASSISTANT, + CONF_AGENT_USER_ID + ) from .smart_home import entity_to_device, query_device, determine_service _LOGGER = logging.getLogger(__name__) @@ -48,6 +51,7 @@ class GoogleAssistantView(HomeAssistantView): DEFAULT_EXPOSE_BY_DEFAULT) self.exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + self.agent_user_id = cfg.get(CONF_AGENT_USER_ID) def is_entity_exposed(self, entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -77,7 +81,7 @@ class GoogleAssistantView(HomeAssistantView): if not self.is_entity_exposed(entity): continue - device = entity_to_device(entity) + device = entity_to_device(entity, hass.config.units) if device is None: _LOGGER.warning("No mapping for %s domain", entity.domain) continue @@ -85,7 +89,9 @@ class GoogleAssistantView(HomeAssistantView): devices.append(device) return self.json( - make_actions_response(request_id, {'devices': devices})) + _make_actions_response(request_id, + {'agentUserId': self.agent_user_id, + 'devices': devices})) @asyncio.coroutine def handle_query(self, @@ -106,10 +112,10 @@ class GoogleAssistantView(HomeAssistantView): # If we can't find a state, the device is offline devices[devid] = {'online': False} - devices[devid] = query_device(state) + devices[devid] = query_device(state, hass.config.units) return self.json( - make_actions_response(request_id, {'devices': devices})) + _make_actions_response(request_id, {'devices': devices})) @asyncio.coroutine def handle_execute(self, @@ -122,9 +128,11 @@ class GoogleAssistantView(HomeAssistantView): ent_ids = [ent.get('id') for ent in command.get('devices', [])] execution = command.get('execution')[0] for eid in ent_ids: + success = False domain = eid.split('.')[0] (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params')) + eid, execution.get('command'), execution.get('params'), + hass.config.units) success = yield from hass.services.async_call( domain, service, service_data, blocking=True) result = {"ids": [eid], "states": {}} @@ -135,12 +143,12 @@ class GoogleAssistantView(HomeAssistantView): commands.append(result) return self.json( - make_actions_response(request_id, {'commands': commands})) + _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) + auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: return self.json_message( "missing authorization", status_code=HTTP_UNAUTHORIZED) @@ -175,6 +183,5 @@ class GoogleAssistantView(HomeAssistantView): "invalid intent", status_code=HTTP_BAD_REQUEST) -def make_actions_response(request_id: str, payload: dict) -> dict: - """Helper to simplify format for response.""" +def _make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml new file mode 100644 index 00000000000..6019b75bd98 --- /dev/null +++ b/homeassistant/components/google_assistant/services.yaml @@ -0,0 +1,2 @@ +request_sync: + description: Send a request_sync command to Google. \ No newline at end of file diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1c8adf3d8f7..cd1583fb377 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -5,21 +5,26 @@ import logging # 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 typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA +from homeassistant.util import color +from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_OFF, - SERVICE_TURN_OFF, SERVICE_TURN_ON + SERVICE_TURN_OFF, SERVICE_TURN_ON, + TEMP_FAHRENHEIT, TEMP_CELSIUS, ) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate ) +from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( - ATTR_GOOGLE_ASSISTANT_NAME, ATTR_GOOGLE_ASSISTANT_TYPE, + ATTR_GOOGLE_ASSISTANT_NAME, COMMAND_COLOR, + ATTR_GOOGLE_ASSISTANT_TYPE, COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, @@ -65,7 +70,7 @@ def make_actions_response(request_id: str, payload: dict) -> dict: return {'requestId': request_id, 'payload': payload} -def entity_to_device(entity: Entity): +def entity_to_device(entity: Entity, units: UnitSystem): """Convert a hass entity into an google actions device.""" class_data = MAPPING_COMPONENT.get( entity.attributes.get(ATTR_GOOGLE_ASSISTANT_TYPE) or entity.domain) @@ -75,6 +80,7 @@ def entity_to_device(entity: Entity): device = { 'id': entity.entity_id, 'name': {}, + 'attributes': {}, 'traits': [], 'willReportState': False, } @@ -99,20 +105,62 @@ def entity_to_device(entity: Entity): for feature, trait in class_data[2].items(): if feature & supported > 0: device['traits'].append(trait) + + # Actions require this attributes for a device + # supporting temperature + # For IKEA trådfri, these attributes only seem to + # be set only if the device is on? + if trait == TRAIT_COLOR_TEMP: + if entity.attributes.get( + light.ATTR_MAX_MIREDS) is not None: + device['attributes']['temperatureMinK'] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_MAX_MIREDS)))) + if entity.attributes.get( + light.ATTR_MIN_MIREDS) is not None: + device['attributes']['temperatureMaxK'] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_MIN_MIREDS)))) + if entity.domain == climate.DOMAIN: modes = ','.join( m for m in entity.attributes.get(climate.ATTR_OPERATION_LIST, []) if m in CLIMATE_SUPPORTED_MODES) device['attributes'] = { 'availableThermostatModes': modes, - 'thermostatTemperatureUnit': 'C', + 'thermostatTemperatureUnit': + 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', } return device -def query_device(entity: Entity) -> dict: +def query_device(entity: Entity, units: UnitSystem) -> dict: """Take an entity and return a properly formatted device object.""" + def celsius(deg: Optional[float]) -> Optional[float]: + """Convert a float to Celsius and rounds to one decimal place.""" + if deg is None: + return None + return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) + if entity.domain == climate.DOMAIN: + mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) + if mode not in CLIMATE_SUPPORTED_MODES: + mode = 'on' + response = { + 'thermostatMode': mode, + 'thermostatTemperatureSetpoint': + celsius(entity.attributes.get(climate.ATTR_TEMPERATURE)), + 'thermostatTemperatureAmbient': + celsius(entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)), + 'thermostatTemperatureSetpointHigh': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)), + 'thermostatTemperatureSetpointLow': + celsius(entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)), + 'thermostatHumidityAmbient': + entity.attributes.get(climate.ATTR_CURRENT_HUMIDITY), + } + return {k: v for k, v in response.items() if v is not None} + final_state = entity.state != STATE_OFF final_brightness = entity.attributes.get(light.ATTR_BRIGHTNESS, 255 if final_state else 0) @@ -128,18 +176,42 @@ def query_device(entity: Entity) -> dict: final_brightness = 100 * (final_brightness / 255) - return { + query_response = { "on": final_state, "online": True, "brightness": int(final_brightness) } + supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported_features & \ + (light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR): + query_response["color"] = {} + + if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None: + query_response["color"]["temperature"] = \ + int(round(color.color_temperature_mired_to_kelvin( + entity.attributes.get(light.ATTR_COLOR_TEMP)))) + + if entity.attributes.get(light.ATTR_COLOR_NAME) is not None: + query_response["color"]["name"] = \ + entity.attributes.get(light.ATTR_COLOR_NAME) + + if entity.attributes.get(light.ATTR_RGB_COLOR) is not None: + color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR) + if color_rgb is not None: + query_response["color"]["spectrumRGB"] = \ + int(color.color_rgb_to_hex( + color_rgb[0], color_rgb[1], color_rgb[2]), 16) + + return query_response + # 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]: +def determine_service( + entity_id: str, command: str, params: dict, + units: UnitSystem) -> Tuple[str, dict]: """ Determine service and service_data. @@ -166,14 +238,17 @@ def determine_service(entity_id: str, command: str, # special climate handling if domain == climate.DOMAIN: if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = params.get( - 'thermostatTemperatureSetpoint', 25) + service_data['temperature'] = units.temperature( + params.get('thermostatTemperatureSetpoint', 25), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = params.get( - 'thermostatTemperatureSetpointHigh', 25) - service_data['target_temp_low'] = params.get( - 'thermostatTemperatureSetpointLow', 18) + service_data['target_temp_high'] = units.temperature( + params.get('thermostatTemperatureSetpointHigh', 25), + TEMP_CELSIUS) + service_data['target_temp_low'] = units.temperature( + params.get('thermostatTemperatureSetpointLow', 18), + TEMP_CELSIUS) return (climate.SERVICE_SET_TEMPERATURE, service_data) if command == COMMAND_THERMOSTAT_SET_MODE: service_data['operation_mode'] = params.get( @@ -185,7 +260,27 @@ def determine_service(entity_id: str, command: str, 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): + _LOGGER.debug("Handling command %s with data %s", command, params) + if command == COMMAND_COLOR: + color_data = params.get('color') + if color_data is not None: + if color_data.get('temperature', 0) > 0: + service_data[light.ATTR_KELVIN] = color_data.get('temperature') + return (SERVICE_TURN_ON, service_data) + if color_data.get('spectrumRGB', 0) > 0: + # blue is 255 so pad up to 6 chars + hex_value = \ + ('%0x' % int(color_data.get('spectrumRGB'))).zfill(6) + service_data[light.ATTR_RGB_COLOR] = \ + color.rgb_hex_to_rgb_list(hex_value) + return (SERVICE_TURN_ON, service_data) + + if command == COMMAND_ACTIVATESCENE: return (SERVICE_TURN_ON, service_data) - return (SERVICE_TURN_OFF, service_data) + + if COMMAND_ONOFF == command: + if params.get('on') is True: + return (SERVICE_TURN_ON, service_data) + return (SERVICE_TURN_OFF, service_data) + + return (None, service_data) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 940de2ba12f..048a7d531f4 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -49,7 +49,7 @@ NO_TIMEOUT = { } NO_AUTH = { - re.compile(r'^panel$'), re.compile(r'^addons/[^/]*/logo$') + re.compile(r'^panel_(es5|latest)$'), re.compile(r'^addons/[^/]*/logo$') } SCHEMA_ADDON = vol.Schema({ diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index f402a9d6892..17ceccfd218 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -5,37 +5,43 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ import asyncio -import json from functools import wraps -import logging -import ssl from ipaddress import ip_network - +import json +import logging import os -import voluptuous as vol -from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +import ssl +from aiohttp import web +from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE +from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +import voluptuous as vol + +from homeassistant.const import ( + SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, + HTTP_HEADER_X_REQUESTED_WITH) +from homeassistant.core import is_callback import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util -from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -from homeassistant.core import is_callback from homeassistant.util.logging import HideSensitiveDataFilter from .auth import auth_middleware from .ban import ban_middleware from .const import ( - KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, KEY_BANS_ENABLED, - KEY_LOGIN_THRESHOLD, KEY_AUTHENTICATED) + KEY_BANS_ENABLED, KEY_AUTHENTICATED, KEY_LOGIN_THRESHOLD, + KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR) from .static import ( - staticresource_middleware, CachingFileResponse, CachingStaticResource) + CachingFileResponse, CachingStaticResource, staticresource_middleware) from .util import get_real_ip REQUIREMENTS = ['aiohttp_cors==0.5.3'] +ALLOWED_CORS_HEADERS = [ + ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] + DOMAIN = 'http' CONF_API_PASSWORD = 'api_password' @@ -176,8 +182,6 @@ class HomeAssistantWSGI(object): use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): """Initialize the WSGI Home Assistant server.""" - import aiohttp_cors - middlewares = [auth_middleware, staticresource_middleware] if is_ban_enabled: @@ -200,6 +204,8 @@ class HomeAssistantWSGI(object): self.server = None if cors_origins: + import aiohttp_cors + self.cors = aiohttp_cors.setup(self.app, defaults={ host: aiohttp_cors.ResourceOptions( allow_headers=ALLOWED_CORS_HEADERS, @@ -256,7 +262,6 @@ class HomeAssistantWSGI(object): resource = CachingStaticResource else: resource = web.StaticResource - self.app.router.register_resource(resource(url_path, path)) return @@ -329,7 +334,9 @@ class HomeAssistantWSGI(object): _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - self.app._frozen = False # pylint: disable=protected-access + # pylint: disable=protected-access + self.app._middlewares = tuple(self.app._prepare_middleware()) + self.app._frozen = False @asyncio.coroutine def stop(self): @@ -339,7 +346,7 @@ class HomeAssistantWSGI(object): yield from self.server.wait_closed() yield from self.app.shutdown() if self._handler: - yield from self._handler.finish_connections(60.0) + yield from self._handler.shutdown(10) yield from self.app.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 4b971c883d3..ce5bfca3ac1 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -5,6 +5,7 @@ import hmac import logging from aiohttp import hdrs +from aiohttp.web import middleware from homeassistant.const import HTTP_HEADER_HA_AUTH from .util import get_real_ip @@ -15,47 +16,37 @@ DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) +@middleware @asyncio.coroutine -def auth_middleware(app, handler): +def auth_middleware(request, handler): """Authenticate as middleware.""" # If no password set, just always set authenticated=True - if app['hass'].http.api_password is None: - @asyncio.coroutine - def no_auth_middleware_handler(request): - """Auth middleware to approve all requests.""" - request[KEY_AUTHENTICATED] = True - return handler(request) - - return no_auth_middleware_handler - - @asyncio.coroutine - def auth_middleware_handler(request): - """Auth middleware to check authentication.""" - # Auth code verbose on purpose - authenticated = False - - if (HTTP_HEADER_HA_AUTH in request.headers and - validate_password( - request, request.headers[HTTP_HEADER_HA_AUTH])): - # A valid auth header has been set - authenticated = True - - elif (DATA_API_PASSWORD in request.query and - validate_password(request, request.query[DATA_API_PASSWORD])): - authenticated = True - - elif (hdrs.AUTHORIZATION in request.headers and - validate_authorization_header(request)): - authenticated = True - - elif is_trusted_ip(request): - authenticated = True - - request[KEY_AUTHENTICATED] = authenticated - + if request.app['hass'].http.api_password is None: + request[KEY_AUTHENTICATED] = True return handler(request) - return auth_middleware_handler + # Check authentication + authenticated = False + + if (HTTP_HEADER_HA_AUTH in request.headers and + validate_password( + request, request.headers[HTTP_HEADER_HA_AUTH])): + # A valid auth header has been set + authenticated = True + + elif (DATA_API_PASSWORD in request.query and + validate_password(request, request.query[DATA_API_PASSWORD])): + authenticated = True + + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(request)): + authenticated = True + + elif is_trusted_ip(request): + authenticated = True + + request[KEY_AUTHENTICATED] = authenticated + return handler(request) def is_trusted_ip(request): diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index aa01ccde8d7..f636ad80c36 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -6,6 +6,7 @@ from ipaddress import ip_address import logging import os +from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol @@ -32,35 +33,32 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ }) +@middleware @asyncio.coroutine -def ban_middleware(app, handler): +def ban_middleware(request, handler): """IP Ban middleware.""" - if not app[KEY_BANS_ENABLED]: - return handler + if not request.app[KEY_BANS_ENABLED]: + return (yield from handler(request)) - if KEY_BANNED_IPS not in app: - hass = app['hass'] - app[KEY_BANNED_IPS] = yield from hass.async_add_job( + if KEY_BANNED_IPS not in request.app: + hass = request.app['hass'] + request.app[KEY_BANNED_IPS] = yield from hass.async_add_job( load_ip_bans_config, hass.config.path(IP_BANS_FILE)) - @asyncio.coroutine - def ban_middleware_handler(request): - """Verify if IP is not banned.""" - ip_address_ = get_real_ip(request) + # Verify if IP is not banned + ip_address_ = get_real_ip(request) - is_banned = any(ip_ban.ip_address == ip_address_ - for ip_ban in request.app[KEY_BANNED_IPS]) + is_banned = any(ip_ban.ip_address == ip_address_ + for ip_ban in request.app[KEY_BANNED_IPS]) - if is_banned: - raise HTTPForbidden() + if is_banned: + raise HTTPForbidden() - try: - return (yield from handler(request)) - except HTTPUnauthorized: - yield from process_wrong_login(request) - raise - - return ban_middleware_handler + try: + return (yield from handler(request)) + except HTTPUnauthorized: + yield from process_wrong_login(request) + raise @asyncio.coroutine diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index f991a4ee0fc..c9b094e3f2e 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -3,7 +3,7 @@ import asyncio import re from aiohttp import hdrs -from aiohttp.web import FileResponse +from aiohttp.web import FileResponse, middleware from aiohttp.web_exceptions import HTTPNotFound from aiohttp.web_urldispatcher import StaticResource from yarl import unquote @@ -61,21 +61,18 @@ class CachingFileResponse(FileResponse): self._sendfile = sendfile +@middleware @asyncio.coroutine -def staticresource_middleware(app, handler): +def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" - @asyncio.coroutine - def static_middleware_handler(request): - """Strip out fingerprints from resource names.""" - if not request.path.startswith('/static/'): - return handler(request) - - fingerprinted = _FINGERPRINT.match(request.match_info['filename']) - - if fingerprinted: - request.match_info['filename'] = \ - '{}.{}'.format(*fingerprinted.groups()) - + path = request.path + if not path.startswith('/static/') and not path.startswith('/frontend'): return handler(request) - return static_middleware_handler + fingerprinted = _FINGERPRINT.match(request.match_info['filename']) + + if fingerprinted: + request.match_info['filename'] = \ + '{}.{}'.format(*fingerprinted.groups()) + + return handler(request) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 1c261d5ec3e..b41deb5e5e3 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -5,9 +5,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/influxdb/ """ import logging - import re +import requests.exceptions import voluptuous as vol from homeassistant.const import ( @@ -123,10 +123,12 @@ def setup(hass, config): try: influx = InfluxDBClient(**kwargs) influx.query("SHOW SERIES LIMIT 1;", database=conf[CONF_DB_NAME]) - except exceptions.InfluxDBClientError as exc: + except (exceptions.InfluxDBClientError, + requests.exceptions.ConnectionError) as exc: _LOGGER.error("Database host is not accessible due to '%s', please " - "check your entries in the configuration file and that " - "the database exists and is READ/WRITE.", exc) + "check your entries in the configuration file (host, " + "port, etc.) and verify that the database exists and is " + "READ/WRITE.", exc) return False def influx_event_listener(event): diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index 9dd09f2c245..fecc31f14ae 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -46,7 +46,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_HAS_DATE): cv.boolean, vol.Required(CONF_HAS_TIME): cv.boolean, vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_INITIAL): cv.datetime, + vol.Optional(CONF_INITIAL): cv.string, }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), (CONF_HAS_TIME, True)))}) }, extra=vol.ALLOW_EXTRA) @@ -137,15 +137,15 @@ class InputDatetime(Entity): old_state = yield from async_get_last_state(self.hass, self.entity_id) if old_state is not None: - restore_val = dt_util.parse_datetime(old_state.state) + restore_val = old_state.state if restore_val is not None: if not self._has_date: - self._current_datetime = restore_val.time() + self._current_datetime = dt_util.parse_time(restore_val) elif not self._has_time: - self._current_datetime = restore_val.date() + self._current_datetime = dt_util.parse_date(restore_val) else: - self._current_datetime = restore_val + self._current_datetime = dt_util.parse_datetime(restore_val) def has_date(self): """Return whether the input datetime carries a date.""" diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index b86574c1d2e..3966b490f52 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -36,7 +36,7 @@ ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['xknx==0.7.16'] +REQUIREMENTS = ['xknx==0.7.18'] TUNNELING_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index b11d874127f..49b4f73ea17 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -38,46 +38,26 @@ def setup(hass, config): conf = config[DOMAIN] hlmn = HassLaMetricManager(client_id=conf[CONF_CLIENT_ID], client_secret=conf[CONF_CLIENT_SECRET]) - devices = hlmn.manager().get_devices() + devices = hlmn.manager.get_devices() + if not devices: + _LOGGER.error("No LaMetric devices found") + return False - found = False hass.data[DOMAIN] = hlmn for dev in devices: _LOGGER.debug("Discovered LaMetric device: %s", dev) - found = True - return found + return True class HassLaMetricManager(): - """ - A class that encapsulated requests to the LaMetric manager. - - As the original class does not have a re-connect feature that is needed - for applications running for a long time as the OAuth tokens expire. This - class implements this reconnect() feature. - """ + """A class that encapsulated requests to the LaMetric manager.""" def __init__(self, client_id, client_secret): """Initialize HassLaMetricManager and connect to LaMetric.""" from lmnotify import LaMetricManager _LOGGER.debug("Connecting to LaMetric") - self.lmn = LaMetricManager(client_id, client_secret) + self.manager = LaMetricManager(client_id, client_secret) self._client_id = client_id self._client_secret = client_secret - - def reconnect(self): - """ - Reconnect to LaMetric. - - This is usually necessary when the OAuth token is expired. - """ - from lmnotify import LaMetricManager - _LOGGER.debug("Reconnecting to LaMetric") - self.lmn = LaMetricManager(self._client_id, - self._client_secret) - - def manager(self): - """Return the global LaMetricManager instance.""" - return self.lmn diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d69d6991ff0..e4fb4542205 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_restore_state import homeassistant.util.color as color_util DOMAIN = "light" @@ -140,14 +139,6 @@ PROFILE_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -def extract_info(state): - """Extract light parameters from a state object.""" - params = {key: state.attributes[key] for key in PROP_TO_ATTR - if key in state.attributes} - params['is_on'] = state.state == STATE_ON - return params - - @bind_hass def is_on(hass, entity_id=None): """Return if the lights are on based on the statemachine.""" @@ -431,9 +422,3 @@ class Light(ToggleEntity): def supported_features(self): """Flag supported features.""" return 0 - - @asyncio.coroutine - def async_added_to_hass(self): - """Component added, restore_state using platforms.""" - if hasattr(self, 'async_restore_state'): - yield from async_restore_state(self, extract_info) diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index 22ab404a3b2..d01611716eb 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -4,7 +4,6 @@ Demo light platform that implements lights. For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import asyncio import random from homeassistant.components.light import ( @@ -150,26 +149,3 @@ class DemoLight(Light): # As we have disabled polling, we need to inform # Home Assistant about updates in our state ourselves. self.schedule_update_ha_state() - - @asyncio.coroutine - def async_restore_state(self, is_on, **kwargs): - """Restore the demo state.""" - self._state = is_on - - if 'brightness' in kwargs: - self._brightness = kwargs['brightness'] - - if 'color_temp' in kwargs: - self._ct = kwargs['color_temp'] - - if 'rgb_color' in kwargs: - self._rgb = kwargs['rgb_color'] - - if 'xy_color' in kwargs: - self._xy_color = kwargs['xy_color'] - - if 'white_value' in kwargs: - self._white = kwargs['white_value'] - - if 'effect' in kwargs: - self._effect = kwargs['effect'] diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index c11b3da6f75..e4e1baf6c58 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.light import ( @@ -19,7 +20,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -28,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaLight(light_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaLight(LutronCasetaDevice, Light): @@ -44,7 +46,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): """Return the brightness of the light.""" return to_hass_level(self._state["current_state"]) - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -53,7 +56,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): self._smartbridge.set_value(self._device_id, to_lutron_level(brightness)) - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Turn the light off.""" self._smartbridge.set_value(self._device_id, 0) @@ -62,7 +66,8 @@ class LutronCasetaLight(LutronCasetaDevice, Light): """Return true if device is on.""" return self._state["current_state"] > 0 - def update(self): + @asyncio.coroutine + def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index 63441e6d8af..c3632351e5f 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -8,6 +8,7 @@ import asyncio import logging from homeassistant.core import callback +from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, @@ -181,14 +182,12 @@ class TradfriLight(Light): def device_state_attributes(self): """Return the devices' state attributes.""" info = self._light.device_info - attrs = { - 'manufacturer': info.manufacturer, - 'model_number': info.model_number, - 'serial': info.serial, - 'firmware_version': info.firmware_version, - 'power_source': info.power_source_str, - 'battery_level': info.battery_level - } + + attrs = {} + + if info.battery_level is not None: + attrs[ATTR_BATTERY_LEVEL] = info.battery_level + return attrs @asyncio.coroutine diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index b25f2745365..df716bcf1e9 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -64,14 +64,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): light = PhilipsEyecare(host, token) device = XiaomiPhilipsEyecareLamp(name, light, device_info) devices.append(device) - elif device_info.model == 'philips.light.ceil': + elif device_info.model == 'philips.light.ceiling': from miio import Ceil light = Ceil(host, token) device = XiaomiPhilipsCeilingLamp(name, light, device_info) devices.append(device) elif device_info.model == 'philips.light.bulb': - from miio import Ceil - light = Ceil(host, token) + from miio import PhilipsBulb + light = PhilipsBulb(host, token) device = XiaomiPhilipsLightBall(name, light, device_info) devices.append(device) else: diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 8660546c910..63f0315f35c 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.8'] +REQUIREMENTS = ['pylutron-caseta==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -22,9 +22,16 @@ LUTRON_CASETA_SMARTBRIDGE = 'lutron_smartbridge' DOMAIN = 'lutron_caseta' +CONF_KEYFILE = 'keyfile' +CONF_CERTFILE = 'certfile' +CONF_CA_CERTS = 'ca_certs' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_KEYFILE): cv.string, + vol.Required(CONF_CERTFILE): cv.string, + vol.Required(CONF_CA_CERTS): cv.string }) }, extra=vol.ALLOW_EXTRA) @@ -33,14 +40,21 @@ LUTRON_CASETA_COMPONENTS = [ ] -def setup(hass, base_config): +@asyncio.coroutine +def async_setup(hass, base_config): """Set up the Lutron component.""" from pylutron_caseta.smartbridge import Smartbridge config = base_config.get(DOMAIN) - hass.data[LUTRON_CASETA_SMARTBRIDGE] = Smartbridge( - hostname=config[CONF_HOST] - ) + keyfile = hass.config.path(config[CONF_KEYFILE]) + certfile = hass.config.path(config[CONF_CERTFILE]) + ca_certs = hass.config.path(config[CONF_CA_CERTS]) + bridge = Smartbridge.create_tls(hostname=config[CONF_HOST], + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs) + hass.data[LUTRON_CASETA_SMARTBRIDGE] = bridge + yield from bridge.connect() if not hass.data[LUTRON_CASETA_SMARTBRIDGE].is_connected(): _LOGGER.error("Unable to connect to Lutron smartbridge at %s", config[CONF_HOST]) @@ -49,7 +63,8 @@ def setup(hass, base_config): _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST]) for component in LUTRON_CASETA_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) + hass.async_add_job(discovery.async_load_platform(hass, component, + DOMAIN, {}, config)) return True @@ -73,13 +88,8 @@ class LutronCasetaDevice(Entity): @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" - self.hass.async_add_job( - self._smartbridge.add_subscriber, self._device_id, - self._update_callback - ) - - def _update_callback(self): - self.schedule_update_ha_state() + self._smartbridge.add_subscriber(self._device_id, + self.async_schedule_update_ha_state) @property def name(self): diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 9cee62c39f7..d1f7f89863c 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -16,7 +16,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.29'] +REQUIREMENTS = ['youtube_dl==2017.11.15'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f037dfb708e..89686c312bd 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -7,32 +7,33 @@ https://home-assistant.io/components/media_player/ import asyncio from datetime import timedelta import functools as ft +import collections import hashlib import logging import os from random import SystemRandom -from aiohttp import web, hdrs +from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE, CACHE_CONTROL import async_timeout import voluptuous as vol +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config import load_yaml_config_file -from homeassistant.loader import bind_hass -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.const import ( + STATE_OFF, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, ATTR_ENTITY_ID, + SERVICE_TOGGLE, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_VOLUME_UP, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, + SERVICE_VOLUME_SET, SERVICE_MEDIA_PAUSE, SERVICE_SHUFFLE_SET, + SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass from homeassistant.util.async import run_coroutine_threadsafe -from homeassistant.const import ( - STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE, - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, - SERVICE_VOLUME_MUTE, SERVICE_TOGGLE, SERVICE_MEDIA_STOP, - SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, - SERVICE_SHUFFLE_SET) _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -44,17 +45,16 @@ SCAN_INTERVAL = timedelta(seconds=10) ENTITY_ID_FORMAT = DOMAIN + '.{}' ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}&cache={2}' -ATTR_CACHE_IMAGES = 'images' -ATTR_CACHE_URLS = 'urls' -ATTR_CACHE_MAXSIZE = 'maxsize' +CACHE_IMAGES = 'images' +CACHE_MAXSIZE = 'maxsize' +CACHE_LOCK = 'lock' +CACHE_URL = 'url' +CACHE_CONTENT = 'content' ENTITY_IMAGE_CACHE = { - ATTR_CACHE_IMAGES: {}, - ATTR_CACHE_URLS: [], - ATTR_CACHE_MAXSIZE: 16 + CACHE_IMAGES: collections.OrderedDict(), + CACHE_MAXSIZE: 16 } -CONTENT_TYPE_HEADER = 'Content-Type' - SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' @@ -896,43 +896,36 @@ def _async_fetch_image(hass, url): Images are cached in memory (the images are typically 10-100kB in size). """ - cache_images = ENTITY_IMAGE_CACHE[ATTR_CACHE_IMAGES] - cache_urls = ENTITY_IMAGE_CACHE[ATTR_CACHE_URLS] - cache_maxsize = ENTITY_IMAGE_CACHE[ATTR_CACHE_MAXSIZE] + cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] + cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] - if url in cache_images: - return cache_images[url] + if url not in cache_images: + cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} - content, content_type = (None, None) - websession = async_get_clientsession(hass) - try: - with async_timeout.timeout(10, loop=hass.loop): - response = yield from websession.get(url) + with (yield from cache_images[url][CACHE_LOCK]): + if CACHE_CONTENT in cache_images[url]: + return cache_images[url][CACHE_CONTENT] - if response.status == 200: - content = yield from response.read() - content_type = response.headers.get(CONTENT_TYPE_HEADER) - if content_type: - content_type = content_type.split(';')[0] + content, content_type = (None, None) + websession = async_get_clientsession(hass) + try: + with async_timeout.timeout(10, loop=hass.loop): + response = yield from websession.get(url) - except asyncio.TimeoutError: - pass + if response.status == 200: + content = yield from response.read() + content_type = response.headers.get(CONTENT_TYPE) + if content_type: + content_type = content_type.split(';')[0] + cache_images[url][CACHE_CONTENT] = content, content_type - if not content: - return (None, None) + except asyncio.TimeoutError: + pass - cache_images[url] = (content, content_type) - cache_urls.append(url) + while len(cache_images) > cache_maxsize: + cache_images.popitem(last=False) - while len(cache_urls) > cache_maxsize: - # remove oldest item from cache - oldest_url = cache_urls[0] - if oldest_url in cache_images: - del cache_images[oldest_url] - - cache_urls = cache_urls[1:] - - return content, content_type + return content, content_type class MediaPlayerImageView(HomeAssistantView): @@ -965,8 +958,6 @@ class MediaPlayerImageView(HomeAssistantView): if data is None: return web.Response(status=500) - headers = {hdrs.CACHE_CONTROL: 'max-age=3600'} + headers = {CACHE_CONTROL: 'max-age=3600'} return web.Response( - body=data, - content_type=content_type, - headers=headers) + body=data, content_type=content_type, headers=headers) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index c1b9bab6937..1f86056efb5 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -4,33 +4,37 @@ Bluesound. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.bluesound/ """ -import logging -from datetime import timedelta -from asyncio.futures import CancelledError import asyncio -import voluptuous as vol -from aiohttp.client_exceptions import ClientError +from asyncio.futures import CancelledError +from datetime import timedelta +import logging + import aiohttp +from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE import async_timeout -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.core import callback -from homeassistant.util import Throttle -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.util.dt as dt_util +import voluptuous as vol from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, - SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP) + SUPPORT_PLAY, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_PAUSE, PLATFORM_SCHEMA, + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_PREVIOUS_TRACK, + MediaPlayerDevice) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_PLAYING, STATE_PAUSED, STATE_IDLE, CONF_HOSTS, - CONF_HOST, CONF_PORT, CONF_NAME) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_HOSTS, STATE_IDLE, STATE_PAUSED, + STATE_PLAYING, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util REQUIREMENTS = ['xmltodict==0.11.0'] +_LOGGER = logging.getLogger(__name__) + STATE_OFFLINE = 'offline' ATTR_MODEL = 'model' ATTR_MODEL_NAME = 'model_name' @@ -46,8 +50,6 @@ UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) NODE_OFFLINE_CHECK_TIMEOUT = 180 NODE_RETRY_INITIATION = timedelta(minutes=3) -_LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, @@ -80,20 +82,15 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): def _add_player_cb(): """Add player after first sync fetch.""" async_add_devices([player]) - _LOGGER.info('Added Bluesound device with name: %s', player.name) + _LOGGER.info("Added device with name: %s", player.name) if hass.is_running: _start_polling() else: hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _start_polling - ) + EVENT_HOMEASSISTANT_START, _start_polling) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_polling - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) player = BluesoundPlayer(hass, host, port, name, _add_player_cb) hass.data[DATA_BLUESOUND].append(player) @@ -101,10 +98,7 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): if hass.is_running: _init_player() else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _init_player - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) @asyncio.coroutine @@ -121,11 +115,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hosts = config.get(CONF_HOSTS, None) if hosts: for host in hosts: - _add_player(hass, - async_add_devices, - host.get(CONF_HOST), - host.get(CONF_PORT, None), - host.get(CONF_NAME, None)) + _add_player( + hass, async_add_devices, host.get(CONF_HOST), + host.get(CONF_PORT), host.get(CONF_NAME, None)) class BluesoundPlayer(MediaPlayerDevice): @@ -137,7 +129,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._hass = hass self._port = port self._polling_session = async_get_clientsession(hass) - self._polling_task = None # The actuall polling task. + self._polling_task = None # The actual polling task. self._name = name self._brand = None self._model = None @@ -156,7 +148,6 @@ class BluesoundPlayer(MediaPlayerDevice): if self._port is None: self._port = DEFAULT_PORT -# Internal methods @staticmethod def _try_get_index(string, seach_string): try: @@ -165,13 +156,12 @@ class BluesoundPlayer(MediaPlayerDevice): return -1 @asyncio.coroutine - def _internal_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + def _internal_update_sync_status( + self, on_updated_cb=None, raise_timeout=False): resp = None try: resp = yield from self.send_bluesound_command( - 'SyncStatus', - raise_timeout, raise_timeout) + 'SyncStatus', raise_timeout, raise_timeout) except: raise @@ -193,9 +183,7 @@ class BluesoundPlayer(MediaPlayerDevice): if on_updated_cb: on_updated_cb() return True -# END Internal methods -# Poll functionality @asyncio.coroutine def _start_poll_command(self): """"Loop which polls the status of the player.""" @@ -204,14 +192,13 @@ class BluesoundPlayer(MediaPlayerDevice): yield from self.async_update_status() except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self._name) - yield from asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT, - loop=self._hass.loop) + _LOGGER.info("Node %s is offline, retrying later", self._name) + yield from asyncio.sleep( + NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping bluesound polling of node %s", self._name) + _LOGGER.debug("Stopping the polling of node %s", self._name) except: _LOGGER.exception("Unexpected error in %s", self._name) raise @@ -224,9 +211,7 @@ class BluesoundPlayer(MediaPlayerDevice): def stop_polling(self): """Stop the polling task.""" self._polling_task.cancel() -# END Poll functionality -# Initiator @asyncio.coroutine def async_init(self): """Initiate the player async.""" @@ -235,22 +220,17 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove() self._retry_remove = None - yield from self._internal_update_sync_status(self._init_callback, - True) + yield from self._internal_update_sync_status( + self._init_callback, True) except (asyncio.TimeoutError, ClientError): - _LOGGER.info("Bluesound node %s is offline, retrying later", - self.host) + _LOGGER.info("Node %s is offline, retrying later", self.host) self._retry_remove = async_track_time_interval( - self._hass, - self.async_init, - NODE_RETRY_INITIATION) + self._hass, self.async_init, NODE_RETRY_INITIATION) except: _LOGGER.exception("Unexpected when initiating error in %s", self.host) raise -# END Initiator -# Status updates fetchers @asyncio.coroutine def async_update(self): """Update internal status of the entity.""" @@ -275,7 +255,7 @@ class BluesoundPlayer(MediaPlayerDevice): method = method[1:] url = "http://{}:{}/{}".format(self.host, self._port, method) - _LOGGER.info("calling URL: %s", url) + _LOGGER.debug("Calling URL: %s", url) response = None try: websession = async_get_clientsession(self._hass) @@ -294,11 +274,10 @@ class BluesoundPlayer(MediaPlayerDevice): except (asyncio.TimeoutError, aiohttp.ClientError): if raise_timeout: - _LOGGER.info("Timeout with Bluesound: %s", self.host) + _LOGGER.info("Timeout: %s", self.host) raise else: - _LOGGER.debug("Failed communicating with Bluesound: %s", - self.host) + _LOGGER.debug("Failed communicating: %s", self.host) return None return data @@ -315,17 +294,17 @@ class BluesoundPlayer(MediaPlayerDevice): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag='+etag+'&timeout=60.0' + url = 'Status?etag={}&timeout=60.0'.format(etag) url = "http://{}:{}/{}".format(self.host, self._port, url) - _LOGGER.debug("calling URL: %s", url) + _LOGGER.debug("Calling URL: %s", url) try: with async_timeout.timeout(65, loop=self._hass.loop): response = yield from self._polling_session.get( url, - headers={'connection': 'keep-alive'}) + headers={CONNECTION: KEEP_ALIVE}) if response.status != 200: _LOGGER.error("Error %s on %s", response.status, url) @@ -350,8 +329,8 @@ class BluesoundPlayer(MediaPlayerDevice): def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" - yield from self._internal_update_sync_status(on_updated_cb, - raise_timeout=False) + yield from self._internal_update_sync_status( + on_updated_cb, raise_timeout=False) @asyncio.coroutine @Throttle(UPDATE_CAPTURE_INTERVAL) @@ -436,9 +415,7 @@ class BluesoundPlayer(MediaPlayerDevice): _create_service_item(resp['services']['service']) return self._services_items -# END Status updates fetchers -# Media player (and core) properties @property def should_poll(self): """No need to poll information.""" @@ -611,17 +588,17 @@ class BluesoundPlayer(MediaPlayerDevice): stream_url = self._status.get('streamUrl', '') if self._status.get('is_preset', '') == '1' and stream_url != '': - # this check doesn't work with all presets, for example playlists. - # But it works with radio service_items will catch playlists + # This check doesn't work with all presets, for example playlists. + # But it works with radio service_items will catch playlists. items = [x for x in self._preset_items if 'url2' in x and parse.unquote(x['url2']) == stream_url] if len(items) > 0: return items[0]['title'] - # this could be a bit difficult to detect. Bluetooth could be named + # This could be a bit difficult to detect. Bluetooth could be named # different things and there is not any way to match chooses in # capture list to current playing. It's a bit of guesswork. - # This method will be needing some tweaking over time + # This method will be needing some tweaking over time. title = self._status.get('title1', '').lower() if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': items = [x for x in self._capture_items @@ -660,7 +637,7 @@ class BluesoundPlayer(MediaPlayerDevice): return items[0]['title'] if self._status.get('streamUrl', '') != '': - _LOGGER.debug("Couldn't find source of stream url: %s", + _LOGGER.debug("Couldn't find source of stream URL: %s", self._status.get('streamUrl', '')) return None @@ -695,9 +672,7 @@ class BluesoundPlayer(MediaPlayerDevice): ATTR_MODEL_NAME: self._model_name, ATTR_BRAND: self._brand, } -# END Media player (and core) properties -# Media player commands @asyncio.coroutine def async_select_source(self, source): """Select input source.""" @@ -712,8 +687,8 @@ class BluesoundPlayer(MediaPlayerDevice): return selected_source = items[0] - url = 'Play?url={}&preset_id&image={}'.format(selected_source['url'], - selected_source['image']) + url = 'Play?url={}&preset_id&image={}'.format( + selected_source['url'], selected_source['image']) if 'is_raw_url' in selected_source and selected_source['is_raw_url']: url = selected_source['url'] @@ -806,4 +781,3 @@ class BluesoundPlayer(MediaPlayerDevice): else: return self.send_bluesound_command( 'Volume?level=' + str(float(self._lastvol) * 100)) -# END Media player commands diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 3f1607831e5..54015bec277 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -20,7 +20,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['snapcast==2.0.7'] +REQUIREMENTS = ['snapcast==2.0.8'] _LOGGER = logging.getLogger(__name__) @@ -80,7 +80,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): try: server = yield from snapcast.control.create_server( - hass.loop, host, port) + hass.loop, host, port, reconnect=True) except socket.gaierror: _LOGGER.error('Could not connect to Snapcast server at %s:%d', host, port) diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 9647f04f5c3..a7173e35a48 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -9,28 +9,30 @@ import logging # pylint: disable=import-error from copy import copy +import voluptuous as vol + from homeassistant.core import callback from homeassistant.components.media_player import ( - ATTR_APP_ID, ATTR_APP_NAME, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, - ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, - ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, - ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_POSITION_UPDATED_AT, DOMAIN, SERVICE_PLAY_MEDIA, + ATTR_APP_ID, ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SEASON, + ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, DOMAIN, MediaPlayerDevice, PLATFORM_SCHEMA, + SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_SHUFFLE_SET, ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, - SERVICE_CLEAR_PLAYLIST, MediaPlayerDevice) + SUPPORT_VOLUME_STEP) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK, - SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, - SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, - SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, - SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, SERVICE_SHUFFLE_SET, STATE_IDLE, - STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP, ATTR_SUPPORTED_FEATURES) -from homeassistant.helpers.event import async_track_state_change + ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, CONF_NAME, + CONF_STATE_TEMPLATE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, + SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_call_from_config ATTR_ACTIVE_CHILD = 'active_child' @@ -48,113 +50,75 @@ OFF_STATES = [STATE_IDLE, STATE_OFF] REQUIREMENTS = [] _LOGGER = logging.getLogger(__name__) +ATTRS_SCHEMA = vol.Schema({cv.slug: cv.string}) +CMD_SCHEMA = vol.Schema({cv.slug: cv.SERVICE_SCHEMA}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_CHILDREN, default=[]): cv.entity_ids, + vol.Optional(CONF_COMMANDS, default={}): CMD_SCHEMA, + vol.Optional(CONF_ATTRS, default={}): + vol.Or(cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA), + vol.Optional(CONF_STATE_TEMPLATE): cv.template +}, extra=vol.REMOVE_EXTRA) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the universal media players.""" - if not validate_config(config): - return - player = UniversalMediaPlayer( hass, - config[CONF_NAME], - config[CONF_CHILDREN], - config[CONF_COMMANDS], - config[CONF_ATTRS] + config.get(CONF_NAME), + config.get(CONF_CHILDREN), + config.get(CONF_COMMANDS), + config.get(CONF_ATTRS), + config.get(CONF_STATE_TEMPLATE) ) async_add_devices([player]) -def validate_config(config): - """Validate universal media player configuration.""" - del config[CONF_PLATFORM] - - # Validate name - if CONF_NAME not in config: - _LOGGER.error("Universal Media Player configuration requires name") - return False - - validate_children(config) - validate_commands(config) - validate_attributes(config) - - del_keys = [] - for key in config: - if key not in [CONF_NAME, CONF_CHILDREN, CONF_COMMANDS, CONF_ATTRS]: - _LOGGER.warning( - "Universal Media Player (%s) unrecognized parameter %s", - config[CONF_NAME], key) - del_keys.append(key) - for key in del_keys: - del config[key] - - return True - - -def validate_children(config): - """Validate children.""" - if CONF_CHILDREN not in config: - _LOGGER.info( - "No children under Universal Media Player (%s)", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - elif not isinstance(config[CONF_CHILDREN], list): - _LOGGER.warning( - "Universal Media Player (%s) children not list in config. " - "They will be ignored", config[CONF_NAME]) - config[CONF_CHILDREN] = [] - - -def validate_commands(config): - """Validate commands.""" - if CONF_COMMANDS not in config: - config[CONF_COMMANDS] = {} - elif not isinstance(config[CONF_COMMANDS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified commands not dict in " - "config. They will be ignored", config[CONF_NAME]) - config[CONF_COMMANDS] = {} - - -def validate_attributes(config): - """Validate attributes.""" - if CONF_ATTRS not in config: - config[CONF_ATTRS] = {} - elif not isinstance(config[CONF_ATTRS], dict): - _LOGGER.warning( - "Universal Media Player (%s) specified attributes " - "not dict in config. They will be ignored", config[CONF_NAME]) - config[CONF_ATTRS] = {} - - for key, val in config[CONF_ATTRS].items(): - attr = val.split('|', 1) - if len(attr) == 1: - attr.append(None) - config[CONF_ATTRS][key] = attr - - class UniversalMediaPlayer(MediaPlayerDevice): """Representation of an universal media player.""" - def __init__(self, hass, name, children, commands, attributes): + def __init__(self, hass, name, children, + commands, attributes, state_template=None): """Initialize the Universal media device.""" self.hass = hass self._name = name self._children = children self._cmds = commands - self._attrs = attributes + self._attrs = {} + for key, val in attributes.items(): + attr = val.split('|', 1) + if len(attr) == 1: + attr.append(None) + self._attrs[key] = attr self._child_state = None + self._state_template = state_template + if state_template is not None: + self._state_template.hass = hass + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to children and template state changes. + + This method must be run in the event loop and returns a coroutine. + """ @callback def async_on_dependency_update(*_): """Update ha state when dependencies update.""" self.async_schedule_update_ha_state(True) - depend = copy(children) - for entity in attributes.values(): + depend = copy(self._children) + for entity in self._attrs.values(): depend.append(entity[0]) + if self._state_template is not None: + for entity in self._state_template.extract_entities(): + depend.append(entity) - async_track_state_change(hass, depend, async_on_dependency_update) + self.hass.helpers.event.async_track_state_change( + list(set(depend)), async_on_dependency_update) def _entity_lkp(self, entity_id, state_attr=None): """Look up an entity state.""" @@ -211,6 +175,8 @@ class UniversalMediaPlayer(MediaPlayerDevice): @property def master_state(self): """Return the master state for entity or None.""" + if self._state_template is not None: + return self._state_template.async_render() if CONF_STATE in self._attrs: master_state = self._entity_lkp( self._attrs[CONF_STATE][0], self._attrs[CONF_STATE][1]) @@ -232,8 +198,8 @@ class UniversalMediaPlayer(MediaPlayerDevice): else master state or off """ master_state = self.master_state # avoid multiple lookups - if master_state == STATE_OFF: - return STATE_OFF + if (master_state == STATE_OFF) or (self._state_template is not None): + return master_state active_child = self._child_state if active_child: diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 8df8ceb0a8e..3215ad82a7c 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -57,7 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, - vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=8): cv.positive_int, vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, }) @@ -202,29 +202,25 @@ class LgWebOSDevice(MediaPlayerDevice): for app in self._client.get_apps(): self._app_list[app['id']] = app - if conf_sources: - if app['id'] == self._current_source_id: - self._current_source = app['title'] - self._source_list[app['title']] = app - elif (app['id'] in conf_sources or - any(word in app['title'] - for word in conf_sources) or - any(word in app['id'] - for word in conf_sources)): - self._source_list[app['title']] = app - else: + if app['id'] == self._current_source_id: self._current_source = app['title'] self._source_list[app['title']] = app + elif (not conf_sources or + app['id'] in conf_sources or + any(word in app['title'] + for word in conf_sources) or + any(word in app['id'] + for word in conf_sources)): + self._source_list[app['title']] = app for source in self._client.get_inputs(): - if conf_sources: - if source['id'] == self._current_source_id: - self._source_list[source['label']] = source - elif (source['label'] in conf_sources or - any(source['label'].find(word) != -1 - for word in conf_sources)): - self._source_list[source['label']] = source - else: + if source['id'] == self._current_source_id: + self._current_source = source['label'] + self._source_list[source['label']] = source + elif (not conf_sources or + source['label'] in conf_sources or + any(source['label'].find(word) != -1 + for word in conf_sources)): self._source_list[source['label']] = source except (OSError, ConnectionClosed, TypeError, asyncio.TimeoutError): diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py index 27efc4f3814..bfcffff6bb4 100644 --- a/homeassistant/components/media_player/yamaha_musiccast.py +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -10,10 +10,11 @@ media_player: import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.const import ( CONF_HOST, CONF_PORT, - STATE_UNKNOWN, STATE_ON + STATE_UNKNOWN, STATE_ON, STATE_PLAYING, STATE_PAUSED, STATE_IDLE ) from homeassistant.components.media_player import ( MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, @@ -35,7 +36,7 @@ SUPPORTED_FEATURES = ( KNOWN_HOSTS_KEY = 'data_yamaha_musiccast' INTERVAL_SECONDS = 'interval_seconds' -REQUIREMENTS = ['pymusiccast==0.1.3'] +REQUIREMENTS = ['pymusiccast==0.1.5'] DEFAULT_PORT = 5005 DEFAULT_INTERVAL = 480 @@ -111,6 +112,7 @@ class YamahaDevice(MediaPlayerDevice): self._zone = zone self.mute = False self.media_status = None + self.media_status_received = None self.power = STATE_UNKNOWN self.status = STATE_UNKNOWN self.volume = 0 @@ -202,12 +204,34 @@ class YamahaDevice(MediaPlayerDevice): """Title of current playing media.""" return self.media_status.media_title if self.media_status else None + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self.media_status and self.state in \ + [STATE_PLAYING, STATE_PAUSED, STATE_IDLE]: + return self.media_status.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self.media_status_received if self.media_status else None + def update(self): """Get the latest details from the device.""" _LOGGER.debug("update: %s", self.entity_id) self._recv.update_status() self._zone.update_status() + def update_hass(self): + """Push updates to HASS.""" + if self.entity_id: + _LOGGER.debug("update_hass: pushing updates") + self.schedule_update_ha_state() + return True + def turn_on(self): """Turn on specified media player or all.""" _LOGGER.debug("Turn device: on") @@ -259,3 +283,9 @@ class YamahaDevice(MediaPlayerDevice): _LOGGER.debug("select_source: %s", source) self.status = STATE_UNKNOWN self._zone.set_input(source) + + def new_media_status(self, status): + """Handle updates of the media status.""" + _LOGGER.debug("new media_status arrived") + self.media_status = status + self.media_status_received = dt_util.utcnow() diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9decc9a14aa..3a6abec0ddf 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -438,7 +438,8 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.topics = {} + self.wanted_topics = {} + self.subscribed_topics = {} self.progress = {} self.birth_message = birth_message self._mqttc = None @@ -526,15 +527,14 @@ class MQTT(object): raise HomeAssistantError("topic need to be a string!") with (yield from self._paho_lock): - if topic in self.topics: + if topic in self.subscribed_topics: return - + self.wanted_topics[topic] = qos result, mid = yield from self.hass.async_add_job( self._mqttc.subscribe, topic, qos) _raise_on_error(result) self.progress[mid] = topic - self.topics[topic] = None @asyncio.coroutine def async_unsubscribe(self, topic): @@ -542,6 +542,7 @@ class MQTT(object): This method is a coroutine. """ + self.wanted_topics.pop(topic, None) result, mid = yield from self.hass.async_add_job( self._mqttc.unsubscribe, topic) @@ -562,15 +563,10 @@ class MQTT(object): self._mqttc.disconnect() return - old_topics = self.topics - - self.topics = {key: value for key, value in self.topics.items() - if value is None} - - for topic, qos in old_topics.items(): - # qos is None if we were in process of subscribing - if qos is not None: - self.hass.add_job(self.async_subscribe, topic, qos) + self.progress = {} + self.subscribed_topics = {} + for topic, qos in self.wanted_topics.items(): + self.hass.add_job(self.async_subscribe, topic, qos) if self.birth_message: self.hass.add_job(self.async_publish( @@ -584,7 +580,7 @@ class MQTT(object): topic = self.progress.pop(mid, None) if topic is None: return - self.topics[topic] = granted_qos[0] + self.subscribed_topics[topic] = granted_qos[0] def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" @@ -598,18 +594,12 @@ class MQTT(object): topic = self.progress.pop(mid, None) if topic is None: return - self.topics.pop(topic, None) + self.subscribed_topics.pop(topic, None) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): """Disconnected callback.""" self.progress = {} - self.topics = {key: value for key, value in self.topics.items() - if value is not None} - - # Remove None values from topic list - for key in list(self.topics): - if self.topics[key] is None: - self.topics.pop(key) + self.subscribed_topics = {} # When disconnected because of calling disconnect() if result_code == 0: diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 7140423633e..b6f6a1c5a92 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -20,10 +20,12 @@ TOPIC_MATCHER = re.compile( r'(?P\w+)/(?P\w+)/' r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config') -SUPPORTED_COMPONENTS = ['binary_sensor', 'fan', 'light', 'sensor', 'switch'] +SUPPORTED_COMPONENTS = [ + 'binary_sensor', 'cover', 'fan', 'light', 'sensor', 'switch'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], + 'cover': ['mqtt'], 'fan': ['mqtt'], 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], 'sensor': ['mqtt'], diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 0e866723b34..db251ab4180 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hbmqtt==0.8'] +REQUIREMENTS = ['hbmqtt==0.9.1'] DEPENDENCIES = ['http'] # None allows custom config to be created through generate_config diff --git a/homeassistant/components/mqtt_statestream.py b/homeassistant/components/mqtt_statestream.py index d24361637e9..4427870c294 100644 --- a/homeassistant/components/mqtt_statestream.py +++ b/homeassistant/components/mqtt_statestream.py @@ -9,9 +9,11 @@ import json import voluptuous as vol -from homeassistant.const import MATCH_ALL +from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, MATCH_ALL) from homeassistant.core import callback from homeassistant.components.mqtt import valid_publish_topic +from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.event import async_track_state_change from homeassistant.remote import JSONEncoder import homeassistant.helpers.config_validation as cv @@ -24,6 +26,16 @@ DOMAIN = 'mqtt_statestream' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), vol.Required(CONF_BASE_TOPIC): valid_publish_topic, vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean, vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean @@ -36,8 +48,14 @@ def async_setup(hass, config): """Set up the MQTT state feed.""" conf = config.get(DOMAIN, {}) base_topic = conf.get(CONF_BASE_TOPIC) + pub_include = conf.get(CONF_INCLUDE, {}) + pub_exclude = conf.get(CONF_EXCLUDE, {}) publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + publish_filter = generate_filter(pub_include.get(CONF_DOMAINS, []), + pub_include.get(CONF_ENTITIES, []), + pub_exclude.get(CONF_DOMAINS, []), + pub_exclude.get(CONF_ENTITIES, [])) if not base_topic.endswith('/'): base_topic = base_topic + '/' @@ -45,6 +63,10 @@ def async_setup(hass, config): def _state_publisher(entity_id, old_state, new_state): if new_state is None: return + + if not publish_filter(entity_id): + return + payload = new_state.state mybase = base_topic + entity_id.replace('.', '/') + '/' diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 2401bc6604f..e10878833e4 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -90,7 +90,7 @@ def setup(hass, config): _LOGGER.debug("Failed to login to Neato API") return False hub.update_robots() - for component in ('camera', 'sensor', 'switch'): + for component in ('camera', 'vacuum', 'switch'): discovery.load_platform(hass, component, DOMAIN, {}, config) return True diff --git a/homeassistant/components/no_ip.py b/homeassistant/components/no_ip.py index d92cd752aef..6051fa85f55 100644 --- a/homeassistant/components/no_ip.py +++ b/homeassistant/components/no_ip.py @@ -6,23 +6,26 @@ https://home-assistant.io/components/no_ip/ """ import asyncio import base64 -import logging from datetime import timedelta +import logging import aiohttp +from aiohttp.hdrs import USER_AGENT, AUTHORIZATION import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, HTTP_HEADER_AUTH, - HTTP_HEADER_USER_AGENT, PROJECT_EMAIL) + CONF_DOMAIN, CONF_TIMEOUT, CONF_PASSWORD, CONF_USERNAME) from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DOMAIN = 'no_ip' +# We should set a dedicated address for the user agent. +EMAIL = 'hello@home-assistant.io' + INTERVAL = timedelta(minutes=5) DEFAULT_TIMEOUT = 10 @@ -38,7 +41,7 @@ NO_IP_ERRORS = { } UPDATE_URL = 'https://dynupdate.noip.com/nic/update' -USER_AGENT = "{} {}".format(SERVER_SOFTWARE, PROJECT_EMAIL) +HA_USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -89,8 +92,8 @@ def _update_no_ip(hass, session, domain, auth_str, timeout): } headers = { - HTTP_HEADER_AUTH: "Basic {}".format(auth_str.decode('utf-8')), - HTTP_HEADER_USER_AGENT: USER_AGENT, + AUTHORIZATION: "Basic {}".format(auth_str.decode('utf-8')), + USER_AGENT: HA_USER_AGENT, } try: diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 250ef5c50c8..f6f7cc71f14 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_NAME, CONF_PLATFORM import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template as template_helper -REQUIREMENTS = ['apns2==0.1.1'] +REQUIREMENTS = ['apns2==0.3.0'] APNS_DEVICES = 'apns.yaml' CONF_CERTFILE = 'cert_file' diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 663f689a975..543ce434a8d 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -6,22 +6,22 @@ https://home-assistant.io/components/notify.clicksend/ """ import json import logging -import requests +from aiohttp.hdrs import CONTENT_TYPE +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, - CONTENT_TYPE_JSON) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_USERNAME, CONF_RECIPIENT, CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' -HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, diff --git a/homeassistant/components/notify/clicksend_tts.py b/homeassistant/components/notify/clicksend_tts.py index f951dd00307..26a29993290 100644 --- a/homeassistant/components/notify/clicksend_tts.py +++ b/homeassistant/components/notify/clicksend_tts.py @@ -8,22 +8,22 @@ https://home-assistant.io/components/notify.clicksend_tts/ """ import json import logging -import requests +from aiohttp.hdrs import CONTENT_TYPE +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_USERNAME, CONF_API_KEY, CONF_RECIPIENT, HTTP_HEADER_CONTENT_TYPE, - CONTENT_TYPE_JSON) from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ( + CONF_API_KEY, CONF_USERNAME, CONF_RECIPIENT, CONTENT_TYPE_JSON) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) BASE_API_URL = 'https://rest.clicksend.com/v3' -HEADERS = {HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} CONF_LANGUAGE = 'language' CONF_VOICE = 'voice' diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index db175c6b0a6..791440fdb5b 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -6,14 +6,14 @@ https://home-assistant.io/components/notify.facebook/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE import requests - import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + ATTR_DATA, ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -70,7 +70,7 @@ class FacebookNotificationService(BaseNotificationService): import json resp = requests.post(BASE_URL, data=json.dumps(body), params=payload, - headers={'Content-Type': CONTENT_TYPE_JSON}, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10) if resp.status_code != 200: obj = resp.json() diff --git a/homeassistant/components/notify/free_mobile.py b/homeassistant/components/notify/free_mobile.py index 92ea75a79dc..a27d0495193 100644 --- a/homeassistant/components/notify/free_mobile.py +++ b/homeassistant/components/notify/free_mobile.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['freesms==0.1.1'] +REQUIREMENTS = ['freesms==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 1b44ec60722..2314722a2ab 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -5,27 +5,29 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.html5/ """ import asyncio -import os -import logging -import json -import time import datetime +import json +import logging +import time import uuid +from aiohttp.hdrs import AUTHORIZATION import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, - HTTP_UNAUTHORIZED, URL_ROOT) -from homeassistant.util import ensure_unique_string -from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, - BaseNotificationService, PLATFORM_SCHEMA) -from homeassistant.components.http import HomeAssistantView +from homeassistant.util.json import load_json, save_json +from homeassistant.exceptions import HomeAssistantError from homeassistant.components.frontend import add_manifest_json_key +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.notify import ( + ATTR_DATA, ATTR_TITLE, ATTR_TARGET, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, + BaseNotificationService) +from homeassistant.const import ( + URL_ROOT, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_INTERNAL_SERVER_ERROR) from homeassistant.helpers import config_validation as cv +from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.1.0', 'PyJWT==1.5.3'] +REQUIREMENTS = ['pywebpush==1.3.0', 'PyJWT==1.5.3'] DEPENDENCIES = ['frontend'] @@ -62,24 +64,25 @@ ATTR_JWT = 'jwt' # is valid. JWT_VALID_DAYS = 7 -KEYS_SCHEMA = vol.All(dict, - vol.Schema({ - vol.Required(ATTR_AUTH): cv.string, - vol.Required(ATTR_P256DH): cv.string - })) +KEYS_SCHEMA = vol.All( + dict, vol.Schema({ + vol.Required(ATTR_AUTH): cv.string, + vol.Required(ATTR_P256DH): cv.string, + }) +) -SUBSCRIPTION_SCHEMA = vol.All(dict, - vol.Schema({ - # pylint: disable=no-value-for-parameter - vol.Required(ATTR_ENDPOINT): vol.Url(), - vol.Required(ATTR_KEYS): KEYS_SCHEMA, - vol.Optional(ATTR_EXPIRATIONTIME): - vol.Any(None, cv.positive_int) - })) +SUBSCRIPTION_SCHEMA = vol.All( + dict, vol.Schema({ + # pylint: disable=no-value-for-parameter + vol.Required(ATTR_ENDPOINT): vol.Url(), + vol.Required(ATTR_KEYS): KEYS_SCHEMA, + vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int), + }) +) REGISTER_SCHEMA = vol.Schema({ vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA, - vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']) + vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']), }) CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({ @@ -123,21 +126,11 @@ def get_service(hass, config, discovery_info=None): def _load_config(filename): """Load configuration.""" - if not os.path.isfile(filename): - return {} - try: - with open(filename, 'r') as fdesc: - inp = fdesc.read() - - # In case empty file - if not inp: - return {} - - return json.loads(inp) - except (IOError, ValueError) as error: - _LOGGER.error("Reading config file %s failed: %s", filename, error) - return None + return load_json(filename) + except HomeAssistantError: + pass + return {} class JSONBytesDecoder(json.JSONEncoder): @@ -145,24 +138,12 @@ class JSONBytesDecoder(json.JSONEncoder): # pylint: disable=method-hidden def default(self, obj): - """Decode object if it's a bytes object, else defer to baseclass.""" + """Decode object if it's a bytes object, else defer to base class.""" if isinstance(obj, bytes): return obj.decode() return json.JSONEncoder.default(self, obj) -def _save_config(filename, config): - """Save configuration.""" - try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps( - config, cls=JSONBytesDecoder, indent=4, sort_keys=True)) - except (IOError, TypeError) as error: - _LOGGER.error("Saving config file failed: %s", error) - return False - return True - - class HTML5PushRegistrationView(HomeAssistantView): """Accepts push registrations from a browser.""" @@ -192,7 +173,7 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations[name] = data - if not _save_config(self.json_path, self.registrations): + if not save_json(self.json_path, self.registrations): return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) @@ -221,7 +202,7 @@ class HTML5PushRegistrationView(HomeAssistantView): reg = self.registrations.pop(found) - if not _save_config(self.json_path, self.registrations): + if not save_json(self.json_path, self.registrations): self.registrations[found] = reg return self.json_message( 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) @@ -266,7 +247,7 @@ class HTML5PushCallbackView(HomeAssistantView): def check_authorization_header(self, request): """Check the authorization header.""" import jwt - auth = request.headers.get('Authorization', None) + auth = request.headers.get(AUTHORIZATION, None) if not auth: return self.json_message('Authorization header is expected', status_code=HTTP_UNAUTHORIZED) @@ -323,8 +304,7 @@ class HTML5PushCallbackView(HomeAssistantView): event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT, event_payload[ATTR_TYPE]) request.app['hass'].bus.fire(event_name, event_payload) - return self.json({'status': 'ok', - 'event': event_payload[ATTR_TYPE]}) + return self.json({'status': 'ok', 'event': event_payload[ATTR_TYPE]}) class HTML5NotificationService(BaseNotificationService): @@ -410,9 +390,9 @@ class HTML5NotificationService(BaseNotificationService): if response.status_code == 410: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) - if not _save_config(self.registrations_json_path, - self.registrations): + if not save_json(self.registrations_json_path, + self.registrations): self.registrations[target] = reg - _LOGGER.error("Error saving registration.") + _LOGGER.error("Error saving registration") else: _LOGGER.info("Configuration saved") diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py index 39cdf0fc475..e792045ec80 100644 --- a/homeassistant/components/notify/instapush.py +++ b/homeassistant/components/notify/instapush.py @@ -7,14 +7,14 @@ https://home-assistant.io/components/notify.instapush/ import json import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) + ATTR_TITLE, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, BaseNotificationService) +from homeassistant.const import CONF_API_KEY, CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://api.instapush.im/v1/' @@ -76,7 +76,7 @@ class InstapushNotificationService(BaseNotificationService): self._headers = { HTTP_HEADER_APPID: self._api_key, HTTP_HEADER_APPSECRET: self._app_secret, - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, + CONTENT_TYPE: CONTENT_TYPE_JSON, } def send_message(self, message="", **kwargs): diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index a3af1eb1914..56030afb30c 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -13,9 +13,10 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_ICON import homeassistant.helpers.config_validation as cv -from homeassistant.components.lametric import DOMAIN +from homeassistant.components.lametric import DOMAIN as LAMETRIC_DOMAIN REQUIREMENTS = ['lmnotify==0.0.4'] +DEPENDENCIES = ['lametric'] _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" - hlmn = hass.data.get(DOMAIN) + hlmn = hass.data.get(LAMETRIC_DOMAIN) return LaMetricNotificationService(hlmn, config[CONF_ICON], config[CONF_DISPLAY_TIME] * 1000) @@ -49,6 +50,7 @@ class LaMetricNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to some LaMetric deviced.""" from lmnotify import SimpleFrame, Sound, Model + from oauthlib.oauth2 import TokenExpiredError targets = kwargs.get(ATTR_TARGET) data = kwargs.get(ATTR_DATA) @@ -76,16 +78,16 @@ class LaMetricNotificationService(BaseNotificationService): frames = [text_frame] - if sound is not None: - frames.append(sound) - - _LOGGER.debug(frames) - - model = Model(frames=frames) - lmn = self.hasslametricmanager.manager() - devices = lmn.get_devices() + model = Model(frames=frames, sound=sound) + lmn = self.hasslametricmanager.manager + try: + devices = lmn.get_devices() + except TokenExpiredError: + _LOGGER.debug("Token expired, fetching new token") + lmn.get_token() + devices = lmn.get_devices() for dev in devices: - if (targets is None) or (dev["name"] in targets): + if targets is None or dev["name"] in targets: lmn.set_device(dev) lmn.send_notification(model, lifetime=self._display_time) _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index b0185218846..89117397a53 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) +from homeassistant.const import ( + CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT, CONTENT_TYPE_TEXT_PLAIN) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['sendgrid==5.3.0'] @@ -67,7 +68,7 @@ class SendgridNotificationService(BaseNotificationService): }, "content": [ { - "type": "text/plain", + "type": CONTENT_TYPE_TEXT_PLAIN, "value": message } ] diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py index b4c65d116c4..9d5c58fc5b1 100644 --- a/homeassistant/components/notify/simplepush.py +++ b/homeassistant/components/notify/simplepush.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_PASSWORD -REQUIREMENTS = ['simplepush==1.1.3'] +REQUIREMENTS = ['simplepush==1.1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index fb453263dd8..899ccf9b09a 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -21,6 +21,7 @@ DEPENDENCIES = [DOMAIN] ATTR_KEYBOARD = 'keyboard' ATTR_INLINE_KEYBOARD = 'inline_keyboard' ATTR_PHOTO = 'photo' +ATTR_VIDEO = 'video' ATTR_DOCUMENT = 'document' CONF_CHAT_ID = 'chat_id' @@ -63,7 +64,7 @@ class TelegramNotificationService(BaseNotificationService): keys = keys if isinstance(keys, list) else [keys] service_data.update(inline_keyboard=keys) - # Send a photo, a document or a location + # Send a photo, video, document, or location if data is not None and ATTR_PHOTO in data: photos = data.get(ATTR_PHOTO, None) photos = photos if isinstance(photos, list) else [photos] @@ -72,6 +73,14 @@ class TelegramNotificationService(BaseNotificationService): self.hass.services.call( DOMAIN, 'send_photo', service_data=service_data) return + elif data is not None and ATTR_VIDEO in data: + videos = data.get(ATTR_VIDEO, None) + videos = videos if isinstance(videos, list) else [videos] + for video_data in videos: + service_data.update(video_data) + self.hass.services.call( + DOMAIN, 'send_video', service_data=service_data) + return elif data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py index 7fabb51eac8..82ac914a647 100644 --- a/homeassistant/components/notify/telstra.py +++ b/homeassistant/components/notify/telstra.py @@ -6,12 +6,13 @@ https://home-assistant.io/components/notify.telstra/ """ import logging +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION import requests import voluptuous as vol from homeassistant.components.notify import ( - BaseNotificationService, ATTR_TITLE, PLATFORM_SCHEMA) -from homeassistant.const import CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE + ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -73,8 +74,8 @@ class TelstraNotificationService(BaseNotificationService): } message_resource = 'https://api.telstra.com/v1/sms/messages' message_headers = { - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, - 'Authorization': 'Bearer ' + token_response['access_token'], + CONTENT_TYPE: CONTENT_TYPE_JSON, + AUTHORIZATION: 'Bearer {}'.format(token_response['access_token']), } message_response = requests.post( message_resource, headers=message_headers, json=message_data, diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index fdf237d7180..086242ab070 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -9,6 +9,7 @@ import time import requests import voluptuous as vol +from aiohttp.hdrs import CONTENT_TYPE from homeassistant.const import CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON import homeassistant.helpers.config_validation as cv @@ -55,8 +56,10 @@ class OctoPrintAPI(object): def __init__(self, api_url, key, bed, number_of_tools): """Initialize OctoPrint API and set headers needed later.""" self.api_url = api_url - self.headers = {'content-type': CONTENT_TYPE_JSON, - 'X-Api-Key': key} + self.headers = { + CONTENT_TYPE: CONTENT_TYPE_JSON, + 'X-Api-Key': key, + } self.printer_last_reading = [{}, None] self.job_last_reading = [{}, None] self.job_available = False diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 75b2a1fed71..85f12a18afd 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -140,6 +140,7 @@ def execute(hass, filename, source, data=None): builtins = safe_builtins.copy() builtins.update(utility_builtins) builtins['datetime'] = datetime + builtins['sorted'] = sorted builtins['time'] = TimeWrapper() builtins['dt_util'] = dt_util restricted_globals = { diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e9b08941b83..f8ae9e9d0be 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -36,7 +36,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.14'] +REQUIREMENTS = ['sqlalchemy==1.1.15'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 90a69f8f2a1..719f65abb47 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -28,5 +28,10 @@ def purge_old_data(instance, purge_days): # Execute sqlite vacuum command to free up space on disk _LOGGER.debug("DB engine driver: %s", instance.engine.driver) if instance.engine.driver == 'pysqlite': + from sqlalchemy import exc + _LOGGER.info("Vacuuming SQLite to free space") - instance.engine.execute("VACUUM") + try: + instance.engine.execute("VACUUM") + except exc.OperationalError as err: + _LOGGER.error("Error vacuuming SQLite: %s.", err) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 41dbec851b5..3f1086c46c7 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -61,8 +61,7 @@ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ vol.Optional(ATTR_DEVICE): cv.string, vol.Optional( ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int, - vol.Optional( - ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float) + vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float), }) @@ -141,25 +140,18 @@ def async_setup(hass, config): def async_handle_remote_service(service): """Handle calls to the remote services.""" target_remotes = component.async_extract_from_service(service) - - activity_id = service.data.get(ATTR_ACTIVITY) - device = service.data.get(ATTR_DEVICE) - command = service.data.get(ATTR_COMMAND) - num_repeats = service.data.get(ATTR_NUM_REPEATS) - delay_secs = service.data.get(ATTR_DELAY_SECS) + kwargs = service.data.copy() update_tasks = [] for remote in target_remotes: if service.service == SERVICE_TURN_ON: - yield from remote.async_turn_on(activity=activity_id) + yield from remote.async_turn_on(**kwargs) elif service.service == SERVICE_TOGGLE: - yield from remote.async_toggle(activity=activity_id) + yield from remote.async_toggle(**kwargs) elif service.service == SERVICE_SEND_COMMAND: - yield from remote.async_send_command( - device=device, command=command, - num_repeats=num_repeats, delay_secs=delay_secs) + yield from remote.async_send_command(**kwargs) else: - yield from remote.async_turn_off(activity=activity_id) + yield from remote.async_turn_off(**kwargs) if not remote.should_poll: continue diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index b25741207de..7a398def5f9 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -5,22 +5,23 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/remote.harmony/ """ import logging +import asyncio from os import path -import urllib.parse +import time import voluptuous as vol import homeassistant.components.remote as remote import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID) + CONF_NAME, CONF_HOST, CONF_PORT, ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP) from homeassistant.components.remote import ( PLATFORM_SCHEMA, DOMAIN, ATTR_DEVICE, ATTR_ACTIVITY, ATTR_NUM_REPEATS, - ATTR_DELAY_SECS) + ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) from homeassistant.util import slugify from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyharmony==1.0.16'] +REQUIREMENTS = ['pyharmony==1.0.18'] _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(ATTR_ACTIVITY, default=None): cv.string, + vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): + vol.Coerce(float), }) HARMONY_SYNC_SCHEMA = vol.Schema({ @@ -44,8 +47,6 @@ HARMONY_SYNC_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Harmony platform.""" - import pyharmony - host = None activity = None @@ -61,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = DEFAULT_PORT if override: activity = override.get(ATTR_ACTIVITY) + delay_secs = override.get(ATTR_DELAY_SECS) port = override.get(CONF_PORT, DEFAULT_PORT) host = ( @@ -79,6 +81,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_PORT), ) activity = config.get(ATTR_ACTIVITY) + delay_secs = config.get(ATTR_DELAY_SECS) else: hass.data[CONF_DEVICE_CACHE].append(config) return @@ -86,26 +89,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name, address, port = host _LOGGER.info("Loading Harmony Platform: %s at %s:%s, startup activity: %s", name, address, port, activity) - try: - _LOGGER.debug("Calling pyharmony.ha_get_token for remote at: %s:%s", - address, port) - token = urllib.parse.quote_plus(pyharmony.ha_get_token(address, port)) - _LOGGER.debug("Received token: %s", token) - except ValueError as err: - _LOGGER.warning("%s for remote: %s", err.args[0], name) - return False harmony_conf_file = hass.config.path( '{}{}{}'.format('harmony_', slugify(name), '.conf')) - device = HarmonyRemote( - name, address, port, - activity, harmony_conf_file, token) - - DEVICES.append(device) - - add_devices([device]) - register_services(hass) - return True + try: + device = HarmonyRemote( + name, address, port, activity, harmony_conf_file, delay_secs) + DEVICES.append(device) + add_devices([device]) + register_services(hass) + except ValueError: + _LOGGER.warning("Failed to initialize remote: %s", name) def register_services(hass): @@ -140,7 +134,7 @@ def _sync_service(service): class HarmonyRemote(remote.RemoteDevice): """Remote representation used to control a Harmony device.""" - def __init__(self, name, host, port, activity, out_path, token): + def __init__(self, name, host, port, activity, out_path, delay_secs): """Initialize HarmonyRemote class.""" import pyharmony from pathlib import Path @@ -152,20 +146,35 @@ class HarmonyRemote(remote.RemoteDevice): self._state = None self._current_activity = None self._default_activity = activity - self._token = token + self._client = pyharmony.get_client(host, port, self.new_activity) self._config_path = out_path - _LOGGER.debug("Retrieving harmony config using token: %s", token) - self._config = pyharmony.ha_get_config(self._token, host, port) + self._config = self._client.get_config() if not Path(self._config_path).is_file(): _LOGGER.debug("Writing harmony configuration to file: %s", out_path) pyharmony.ha_write_config_file(self._config, self._config_path) + self._delay_secs = delay_secs + + @asyncio.coroutine + def async_added_to_hass(self): + """Complete the initialization.""" + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + lambda event: self._client.disconnect(wait=True)) + + # Poll for initial state + self.new_activity(self._client.get_current_activity()) @property def name(self): """Return the Harmony device's name.""" return self._name + @property + def should_poll(self): + """Return the fact that we should not be polled.""" + return False + @property def device_state_attributes(self): """Add platform specific attributes.""" @@ -176,60 +185,51 @@ class HarmonyRemote(remote.RemoteDevice): """Return False if PowerOff is the current activity, otherwise True.""" return self._current_activity not in [None, 'PowerOff'] - def update(self): - """Return current activity.""" + def new_activity(self, activity_id): + """Callback for updating the current activity.""" import pyharmony - name = self._name - _LOGGER.debug("Polling %s for current activity", name) - state = pyharmony.ha_get_current_activity( - self._token, self._config, self.host, self._port) - _LOGGER.debug("%s current activity reported as: %s", name, state) - self._current_activity = state - self._state = bool(state != 'PowerOff') + activity_name = pyharmony.activity_name(self._config, activity_id) + _LOGGER.debug("%s activity reported as: %s", self._name, activity_name) + self._current_activity = activity_name + self._state = bool(self._current_activity != 'PowerOff') + self.schedule_update_ha_state() def turn_on(self, **kwargs): """Start an activity from the Harmony device.""" import pyharmony - if kwargs[ATTR_ACTIVITY]: - activity = kwargs[ATTR_ACTIVITY] - else: - activity = self._default_activity + activity = kwargs.get(ATTR_ACTIVITY, self._default_activity) if activity: - pyharmony.ha_start_activity( - self._token, self.host, self._port, self._config, activity) + activity_id = pyharmony.activity_id(self._config, activity) + self._client.start_activity(activity_id) self._state = True else: _LOGGER.error("No activity specified with turn_on service") def turn_off(self, **kwargs): """Start the PowerOff activity.""" - import pyharmony - pyharmony.ha_power_off(self._token, self.host, self._port) + self._client.power_off() - def send_command(self, command, **kwargs): - """Send a set of commands to one device.""" - import pyharmony - device = kwargs.pop(ATTR_DEVICE, None) + def send_command(self, commands, **kwargs): + """Send a list of commands to one device.""" + device = kwargs.get(ATTR_DEVICE) if device is None: _LOGGER.error("Missing required argument: device") return - params = {} - num_repeats = kwargs.pop(ATTR_NUM_REPEATS, None) - if num_repeats is not None: - params['repeat_num'] = num_repeats - delay_secs = kwargs.pop(ATTR_DELAY_SECS, None) - if delay_secs is not None: - params['delay_secs'] = delay_secs - pyharmony.ha_send_commands( - self._token, self.host, self._port, device, command, **params) + + num_repeats = kwargs.get(ATTR_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + + for _ in range(num_repeats): + for command in commands: + self._client.send_command(device, command) + time.sleep(delay_secs) def sync(self): """Sync the Harmony device with the web service.""" import pyharmony _LOGGER.debug("Syncing hub with Harmony servers") - pyharmony.ha_sync(self._token, self.host, self._port) - self._config = pyharmony.ha_get_config( - self._token, self.host, self._port) + self._client.sync() + self._config = self._client.get_config() _LOGGER.debug("Writing hub config to file: %s", self._config_path) pyharmony.ha_write_config_file(self._config, self._config_path) diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 701889d60b5..c16164d7700 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from requests.exceptions import HTTPError, ConnectTimeout -REQUIREMENTS = ['ring_doorbell==0.1.6'] +REQUIREMENTS = ['ring_doorbell==0.1.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scene/lifx_cloud.py b/homeassistant/components/scene/lifx_cloud.py index e6f5be71a80..ffbb10cba4e 100644 --- a/homeassistant/components/scene/lifx_cloud.py +++ b/homeassistant/components/scene/lifx_cloud.py @@ -7,15 +7,15 @@ https://home-assistant.io/components/scene.lifx_cloud/ import asyncio import logging +import aiohttp +from aiohttp.hdrs import AUTHORIZATION +import async_timeout import voluptuous as vol -import aiohttp -import async_timeout - from homeassistant.components.scene import Scene -from homeassistant.const import (CONF_PLATFORM, CONF_TOKEN, CONF_TIMEOUT) +from homeassistant.const import CONF_TOKEN, CONF_TIMEOUT, CONF_PLATFORM +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.aiohttp_client import (async_get_clientsession) _LOGGER = logging.getLogger(__name__) @@ -37,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) headers = { - "Authorization": "Bearer %s" % token, + AUTHORIZATION: "Bearer {}".format(token), } url = LIFX_API_URL.format('scenes') diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index b98f7f3e6ea..53df0da7617 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -4,17 +4,19 @@ Support for Lutron Caseta scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.lutron_caseta/ """ +import asyncio import logging -from homeassistant.components.scene import Scene from homeassistant.components.lutron_caseta import LUTRON_CASETA_SMARTBRIDGE +from homeassistant.components.scene import Scene _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['lutron_caseta'] -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -23,7 +25,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaScene(scenes[scene], bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) class LutronCasetaScene(Scene): @@ -50,6 +52,7 @@ class LutronCasetaScene(Scene): """There is no way of detecting if a scene is active (yet).""" return False - def activate(self, **kwargs): + @asyncio.coroutine + def async_activate(self, **kwargs): """Activate the scene.""" self._bridge.activate_scene(self._scene_id) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 56ddf7adcab..5ea24dab823 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -126,7 +126,7 @@ class AirVisualBaseSensor(Entity): def __init__(self, data, name, icon, locale): """Initialize the sensor.""" - self._data = data + self.data = data self._icon = icon self._locale = locale self._name = name @@ -136,20 +136,17 @@ class AirVisualBaseSensor(Entity): @property def device_state_attributes(self): """Return the device state attributes.""" - attrs = { + attrs = merge_two_dicts({ ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_CITY: self._data.city, - ATTR_COUNTRY: self._data.country, - ATTR_REGION: self._data.state, - ATTR_TIMESTAMP: self._data.pollution_info.get('ts') - } + ATTR_TIMESTAMP: self.data.pollution_info.get('ts') + }, self.data.attrs) - if self._data.show_on_map: - attrs[ATTR_LATITUDE] = self._data.latitude - attrs[ATTR_LONGITUDE] = self._data.longitude + 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 + attrs['lati'] = self.data.latitude + attrs['long'] = self.data.longitude return attrs @@ -174,9 +171,9 @@ class AirPollutionLevelSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + aqi = self.data.pollution_info.get('aqi{0}'.format(self._locale)) try: [level] = [ i for i in POLLUTANT_LEVEL_MAPPING @@ -199,9 +196,9 @@ class AirQualityIndexSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - self._state = self._data.pollution_info.get( + self._state = self.data.pollution_info.get( 'aqi{0}'.format(self._locale)) @@ -224,9 +221,9 @@ class MainPollutantSensor(AirVisualBaseSensor): def update(self): """Update the status of the sensor.""" - self._data.update() + self.data.update() - symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + symbol = self.data.pollution_info.get('main{0}'.format(self._locale)) pollution_info = POLLUTANT_MAPPING.get(symbol, {}) self._state = pollution_info.get('label') self._unit = pollution_info.get('unit') @@ -239,6 +236,7 @@ class AirVisualData(object): def __init__(self, client, **kwargs): """Initialize the AirVisual data element.""" self._client = client + self.attrs = {} self.pollution_info = None self.city = kwargs.get(CONF_CITY) @@ -260,17 +258,20 @@ class AirVisualData(object): 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') else: 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', {}) + + self.attrs = { + ATTR_CITY: resp.get('city'), + ATTR_REGION: resp.get('state'), + ATTR_COUNTRY: resp.get('country') + } except exceptions.HTTPError as exc_info: _LOGGER.error("Unable to retrieve data on this location: %s", self.__dict__) diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index f665d8e70ab..97b7ac22909 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -29,7 +29,8 @@ SENSOR_TYPES = { 'last_capture': ['Last', None, 'run-fast'], 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], - 'battery_level': ['Battery Level', '%', 'battery-50'] + 'battery_level': ['Battery Level', '%', 'battery-50'], + 'signal_strength': ['Signal Strength', None, 'signal'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -97,6 +98,16 @@ class ArloSensor(Entity): def update(self): """Get the latest data and updates the state.""" + try: + base_station = self._data.base_station + except (AttributeError, IndexError): + return + + if not base_station: + return + + base_station.refresh_rate = SCAN_INTERVAL.total_seconds() + self._data.update() if self._sensor_type == 'total_cameras': @@ -114,7 +125,13 @@ class ArloSensor(Entity): elif self._sensor_type == 'battery_level': try: - self._state = self._data.get_battery_level + self._state = self._data.battery_level + except TypeError: + self._state = None + + elif self._sensor_type == 'signal_strength': + try: + self._state = self._data.signal_strength except TypeError: self._state = None @@ -128,7 +145,8 @@ class ArloSensor(Entity): if self._sensor_type == 'last_capture' or \ self._sensor_type == 'captured_today' or \ - self._sensor_type == 'battery_level': + self._sensor_type == 'battery_level' or \ + self._sensor_type == 'signal_strength': attrs['model'] = self._data.model_id return attrs diff --git a/homeassistant/components/sensor/haveibeenpwned.py b/homeassistant/components/sensor/haveibeenpwned.py index 1c28db9a9df..3b041127a5b 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -7,24 +7,28 @@ https://home-assistant.io/components/sensor.haveibeenpwned/ from datetime import timedelta import logging -import voluptuous as vol +from aiohttp.hdrs import USER_AGENT import requests +import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (STATE_UNKNOWN, CONF_EMAIL) -from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_EMAIL import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_point_in_time from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -from homeassistant.helpers.event import track_point_in_time _LOGGER = logging.getLogger(__name__) DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +HA_USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component" + MIN_TIME_BETWEEN_FORCED_UPDATES = timedelta(seconds=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +URL = 'https://haveibeenpwned.com/api/v2/breachedaccount/' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]), @@ -33,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the HaveIBeenPwnedSensor sensor.""" + """Set up the HaveIBeenPwned sensor.""" emails = config.get(CONF_EMAIL) data = HaveIBeenPwnedData(emails) @@ -50,11 +54,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class HaveIBeenPwnedSensor(Entity): - """Implementation of a HaveIBeenPwnedSensor.""" + """Implementation of a HaveIBeenPwned sensor.""" def __init__(self, data, hass, email): - """Initialize the HaveIBeenPwnedSensor sensor.""" - self._state = STATE_UNKNOWN + """Initialize the HaveIBeenPwned sensor.""" + self._state = None self._data = data self._hass = hass self._email = email @@ -77,7 +81,7 @@ class HaveIBeenPwnedSensor(Entity): @property def device_state_attributes(self): - """Return the atrributes of the sensor.""" + """Return the attributes of the sensor.""" val = {} if self._email not in self._data.data: return val @@ -143,17 +147,16 @@ class HaveIBeenPwnedData(object): def update(self, **kwargs): """Get the latest data for current email from REST service.""" try: - url = "https://haveibeenpwned.com/api/v2/breachedaccount/{}". \ - format(self._email) + url = "{}{}".format(URL, self._email) - _LOGGER.info("Checking for breaches for email %s", self._email) + _LOGGER.debug("Checking for breaches for email: %s", self._email) - req = requests.get(url, headers={"User-agent": USER_AGENT}, - allow_redirects=True, timeout=5) + req = requests.get( + url, headers={USER_AGENT: HA_USER_AGENT}, allow_redirects=True, + timeout=5) except requests.exceptions.RequestException: - _LOGGER.error("Failed fetching HaveIBeenPwned Data for %s", - self._email) + _LOGGER.error("Failed fetching data for %s", self._email) return if req.status_code == 200: @@ -161,7 +164,7 @@ class HaveIBeenPwnedData(object): key=lambda k: k["AddedDate"], reverse=True) - # only goto next email if we had data so that + # Only goto next email if we had data so that # the forced updates try this current email again self.set_next_email() @@ -173,6 +176,6 @@ class HaveIBeenPwnedData(object): self.set_next_email() else: - _LOGGER.error("Failed fetching HaveIBeenPwned Data for %s" + _LOGGER.error("Failed fetching data for %s" "(HTTP Status_code = %d)", self._email, req.status_code) diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py new file mode 100755 index 00000000000..28cba7da0b4 --- /dev/null +++ b/homeassistant/components/sensor/lacrosse.py @@ -0,0 +1,217 @@ +""" +Support for LaCrosse sensor components. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.lacrosse/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.components.sensor import (ENTITY_ID_FORMAT, PLATFORM_SCHEMA) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_ID, + CONF_SENSORS, CONF_TYPE, TEMP_CELSIUS) +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +REQUIREMENTS = ['pylacrosse==0.2.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_BAUD = 'baud' +CONF_EXPIRE_AFTER = 'expire_after' + +DEFAULT_DEVICE = '/dev/ttyUSB0' +DEFAULT_BAUD = '57600' +DEFAULT_EXPIRE_AFTER = 300 + +TYPES = ['battery', 'humidity', 'temperature'] + +SENSOR_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Required(CONF_TYPE): vol.In(TYPES), + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}), + vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the LaCrosse sensors.""" + import pylacrosse + from serial import SerialException + + usb_device = config.get(CONF_DEVICE) + baud = int(config.get(CONF_BAUD)) + expire_after = config.get(CONF_EXPIRE_AFTER) + + _LOGGER.debug("%s %s", usb_device, baud) + + try: + lacrosse = pylacrosse.LaCrosse(usb_device, baud) + lacrosse.open() + except SerialException as exc: + _LOGGER.warning("Unable to open serial port: %s", exc) + return False + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lacrosse.close) + + sensors = [] + for device, device_config in config[CONF_SENSORS].items(): + _LOGGER.debug("%s %s", device, device_config) + + typ = device_config.get(CONF_TYPE) + sensor_class = TYPE_CLASSES[typ] + name = device_config.get(CONF_NAME, device) + + sensors.append( + sensor_class( + hass, lacrosse, device, name, expire_after, device_config + ) + ) + + add_devices(sensors) + + +class LaCrosseSensor(Entity): + """Implementation of a Lacrosse sensor.""" + + _temperature = None + _humidity = None + _low_battery = None + _new_battery = None + + def __init__(self, hass, lacrosse, device_id, name, expire_after, config): + """Initialize the sensor.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._config = config + self._name = name + self._value = None + self._expire_after = expire_after + self._expiration_trigger = None + + lacrosse.register_callback( + int(self._config['id']), self._callback_lacrosse, None) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + def update(self, *args): + """Get the latest data.""" + pass + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + 'low_battery': self._low_battery, + 'new_battery': self._new_battery, + } + return attributes + + def _callback_lacrosse(self, lacrosse_sensor, user_data): + """Callback function that is called from pylacrosse with new values.""" + if self._expire_after is not None and self._expire_after > 0: + # Reset old trigger + if self._expiration_trigger: + self._expiration_trigger() + self._expiration_trigger = None + + # Set new trigger + expiration_at = ( + dt_util.utcnow() + timedelta(seconds=self._expire_after)) + + self._expiration_trigger = async_track_point_in_utc_time( + self.hass, self.value_is_expired, expiration_at) + + self._temperature = round(lacrosse_sensor.temperature * 2) / 2 + self._humidity = lacrosse_sensor.humidity + self._low_battery = lacrosse_sensor.low_battery + self._new_battery = lacrosse_sensor.new_battery + + @callback + def value_is_expired(self, *_): + """Triggered when value is expired.""" + self._expiration_trigger = None + self._value = None + self.async_schedule_update_ha_state() + + +class LaCrosseTemperature(LaCrosseSensor): + """Implementation of a Lacrosse temperature sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def state(self): + """Return the state of the sensor.""" + return self._temperature + + +class LaCrosseHumidity(LaCrosseSensor): + """Implementation of a Lacrosse humidity sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return '%' + + @property + def state(self): + """Return the state of the sensor.""" + return self._humidity + + @property + def icon(self): + """Icon to use in the frontend.""" + return 'mdi:water-percent' + + +class LaCrosseBattery(LaCrosseSensor): + """Implementation of a Lacrosse battery sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + if self._low_battery is None: + state = None + elif self._low_battery is True: + state = 'low' + else: + state = 'ok' + return state + + @property + def icon(self): + """Icon to use in the frontend.""" + if self._low_battery is None: + icon = 'mdi:battery-unknown' + elif self._low_battery is True: + icon = 'mdi:battery-alert' + else: + icon = 'mdi:battery' + return icon + + +TYPE_CLASSES = { + 'temperature': LaCrosseTemperature, + 'humidity': LaCrosseHumidity, + 'battery': LaCrosseBattery +} diff --git a/homeassistant/components/sensor/london_air.py b/homeassistant/components/sensor/london_air.py index 7a8ad4087b0..848e1255833 100644 --- a/homeassistant/components/sensor/london_air.py +++ b/homeassistant/components/sensor/london_air.py @@ -31,7 +31,6 @@ AUTHORITIES = [ 'Enfield', 'Greenwich', 'Hackney', - 'Hammersmith and Fulham', 'Haringey', 'Harrow', 'Havering', diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index 0b2198bd396..c4014fbd1dd 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -11,7 +11,8 @@ import voluptuous as vol import homeassistant.components.modbus as modbus from homeassistant.const import ( - CONF_NAME, CONF_OFFSET, CONF_UNIT_OF_MEASUREMENT, CONF_SLAVE) + CONF_NAME, CONF_OFFSET, CONF_UNIT_OF_MEASUREMENT, CONF_SLAVE, + CONF_STRUCTURE) from homeassistant.helpers.entity import Entity from homeassistant.helpers import config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -21,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['modbus'] CONF_COUNT = 'count' +CONF_REVERSE_ORDER = 'reverse_order' CONF_PRECISION = 'precision' CONF_REGISTER = 'register' CONF_REGISTERS = 'registers' @@ -32,7 +34,9 @@ REGISTER_TYPE_HOLDING = 'holding' REGISTER_TYPE_INPUT = 'input' DATA_TYPE_INT = 'int' +DATA_TYPE_UINT = 'uint' DATA_TYPE_FLOAT = 'float' +DATA_TYPE_CUSTOM = 'custom' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_REGISTERS): [{ @@ -41,12 +45,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean, vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION, default=0): cv.positive_int, vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): - vol.In([DATA_TYPE_INT, DATA_TYPE_FLOAT]), + vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, + DATA_TYPE_CUSTOM]), + vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string }] }) @@ -55,7 +62,37 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Modbus sensors.""" sensors = [] + data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'}} + data_types[DATA_TYPE_UINT] = {1: 'H', 2: 'I', 4: 'Q'} + data_types[DATA_TYPE_FLOAT] = {1: 'e', 2: 'f', 4: 'd'} + for register in config.get(CONF_REGISTERS): + structure = '>i' + if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM: + try: + structure = '>{}'.format(data_types[ + register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)]) + except KeyError: + _LOGGER.error("Unable to detect data type for %s sensor, " + "try a custom type.", register.get(CONF_NAME)) + continue + else: + structure = register.get(CONF_STRUCTURE) + + try: + size = struct.calcsize(structure) + except struct.error as err: + _LOGGER.error( + "Error in sensor %s structure: %s", + register.get(CONF_NAME), err) + continue + + if register.get(CONF_COUNT) * 2 != size: + _LOGGER.error( + "Structure size (%d bytes) mismatch registers count " + "(%d words)", size, register.get(CONF_COUNT)) + continue + sensors.append(ModbusRegisterSensor( register.get(CONF_NAME), register.get(CONF_SLAVE), @@ -63,10 +100,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): register.get(CONF_REGISTER_TYPE), register.get(CONF_UNIT_OF_MEASUREMENT), register.get(CONF_COUNT), + register.get(CONF_REVERSE_ORDER), register.get(CONF_SCALE), register.get(CONF_OFFSET), - register.get(CONF_DATA_TYPE), + structure, register.get(CONF_PRECISION))) + + if not sensors: + return False add_devices(sensors) @@ -74,8 +115,8 @@ class ModbusRegisterSensor(Entity): """Modbus register sensor.""" def __init__(self, name, slave, register, register_type, - unit_of_measurement, count, scale, offset, data_type, - precision): + unit_of_measurement, count, reverse_order, scale, offset, + structure, precision): """Initialize the modbus register sensor.""" self._name = name self._slave = int(slave) if slave else None @@ -83,10 +124,11 @@ class ModbusRegisterSensor(Entity): self._register_type = register_type self._unit_of_measurement = unit_of_measurement self._count = int(count) + self._reverse_order = reverse_order self._scale = scale self._offset = offset self._precision = precision - self._data_type = data_type + self._structure = structure self._value = None @property @@ -120,17 +162,15 @@ class ModbusRegisterSensor(Entity): try: registers = result.registers + if self._reverse_order: + registers.reverse() except AttributeError: - _LOGGER.error("No response from modbus slave %s register %s", + _LOGGER.error("No response from modbus slave %s, register %s", self._slave, self._register) return - if self._data_type == DATA_TYPE_FLOAT: - byte_string = b''.join( - [x.to_bytes(2, byteorder='big') for x in registers] - ) - val = struct.unpack(">f", byte_string)[0] - elif self._data_type == DATA_TYPE_INT: - for i, res in enumerate(registers): - val += res * (2**(i*16)) + byte_string = b''.join( + [x.to_bytes(2, byteorder='big') for x in registers] + ) + val = struct.unpack(self._structure, byte_string)[0] self._value = format( self._scale * val + self._offset, '.{}f'.format(self._precision)) diff --git a/homeassistant/components/sensor/neato.py b/homeassistant/components/sensor/neato.py deleted file mode 100644 index 5179816eb35..00000000000 --- a/homeassistant/components/sensor/neato.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Support for Neato Connected Vaccums sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.neato/ -""" -import logging -import requests -from homeassistant.helpers.entity import Entity -from homeassistant.components.neato import ( - NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) - -_LOGGER = logging.getLogger(__name__) - -DEPENDENCIES = ['neato'] - -SENSOR_TYPE_STATUS = 'status' -SENSOR_TYPE_BATTERY = 'battery' - -SENSOR_TYPES = { - SENSOR_TYPE_STATUS: ['Status'], - SENSOR_TYPE_BATTERY: ['Battery'] -} - -ATTR_CLEAN_START = 'clean_start' -ATTR_CLEAN_STOP = 'clean_stop' -ATTR_CLEAN_AREA = 'clean_area' -ATTR_CLEAN_BATTERY_START = 'battery_level_at_clean_start' -ATTR_CLEAN_BATTERY_END = 'battery_level_at_clean_end' -ATTR_CLEAN_SUSP_COUNT = 'clean_suspension_count' -ATTR_CLEAN_SUSP_TIME = 'clean_suspension_time' - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Neato sensor platform.""" - dev = [] - for robot in hass.data[NEATO_ROBOTS]: - for type_name in SENSOR_TYPES: - dev.append(NeatoConnectedSensor(hass, robot, type_name)) - _LOGGER.debug("Adding sensors %s", dev) - add_devices(dev) - - -class NeatoConnectedSensor(Entity): - """Neato Connected Sensor.""" - - def __init__(self, hass, robot, sensor_type): - """Initialize the Neato Connected sensor.""" - self.type = sensor_type - self.robot = robot - self.neato = hass.data[NEATO_LOGIN] - self._robot_name = self.robot.name + ' ' + SENSOR_TYPES[self.type][0] - self._status_state = None - try: - self._state = self.robot.state - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError) as ex: - self._state = None - _LOGGER.warning("Neato connection error: %s", ex) - self._mapdata = hass.data[NEATO_MAP_DATA] - self.clean_time_start = None - self.clean_time_stop = None - self.clean_area = None - self.clean_battery_start = None - self.clean_battery_end = None - self.clean_suspension_charge_count = None - self.clean_suspension_time = None - self._battery_state = None - - def update(self): - """Update the properties of sensor.""" - _LOGGER.debug('Update of sensor') - self.neato.update_robots() - self._mapdata = self.hass.data[NEATO_MAP_DATA] - try: - self._state = self.robot.state - except (requests.exceptions.ConnectionError, - requests.exceptions.HTTPError) as ex: - self._state = None - self._status_state = 'Offline' - _LOGGER.warning("Neato connection error: %s", ex) - return - if not self._state: - return - _LOGGER.debug('self._state=%s', self._state) - if self.type == SENSOR_TYPE_STATUS: - if self._state['state'] == 1: - if self._state['details']['isCharging']: - self._status_state = 'Charging' - elif (self._state['details']['isDocked'] and - not self._state['details']['isCharging']): - self._status_state = 'Docked' - else: - self._status_state = 'Stopped' - elif self._state['state'] == 2: - if ALERTS.get(self._state['error']) is None: - self._status_state = ( - MODE.get(self._state['cleaning']['mode']) - + ' ' + ACTION.get(self._state['action'])) - else: - self._status_state = ALERTS.get(self._state['error']) - elif self._state['state'] == 3: - self._status_state = 'Paused' - elif self._state['state'] == 4: - self._status_state = ERRORS.get(self._state['error']) - if self.type == SENSOR_TYPE_BATTERY: - self._battery_state = self._state['details']['charge'] - if not self._mapdata.get(self.robot.serial, {}).get('maps', []): - return - self.clean_time_start = ( - (self._mapdata[self.robot.serial]['maps'][0]['start_at'] - .strip('Z')) - .replace('T', ' ')) - self.clean_time_stop = ( - (self._mapdata[self.robot.serial]['maps'][0]['end_at'].strip('Z')) - .replace('T', ' ')) - self.clean_area = ( - self._mapdata[self.robot.serial]['maps'][0]['cleaned_area']) - self.clean_suspension_charge_count = ( - self._mapdata[self.robot.serial]['maps'][0] - ['suspended_cleaning_charging_count']) - self.clean_suspension_time = ( - self._mapdata[self.robot.serial]['maps'][0] - ['time_in_suspended_cleaning']) - self.clean_battery_start = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_start']) - self.clean_battery_end = ( - self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_end']) - - @property - def unit_of_measurement(self): - """Return unit for the sensor.""" - if self.type == SENSOR_TYPE_BATTERY: - return '%' - - @property - def available(self): - """Return True if sensor data is available.""" - return self._state - - @property - def state(self): - """Return the sensor state.""" - if self.type == SENSOR_TYPE_STATUS: - return self._status_state - if self.type == SENSOR_TYPE_BATTERY: - return self._battery_state - - @property - def name(self): - """Return the name of the sensor.""" - return self._robot_name - - @property - def device_state_attributes(self): - """Return the device specific attributes.""" - data = {} - if self.type is SENSOR_TYPE_STATUS: - if self.clean_time_start: - data[ATTR_CLEAN_START] = self.clean_time_start - if self.clean_time_stop: - data[ATTR_CLEAN_STOP] = self.clean_time_stop - if self.clean_area: - data[ATTR_CLEAN_AREA] = self.clean_area - if self.clean_suspension_charge_count: - data[ATTR_CLEAN_SUSP_COUNT] = ( - self.clean_suspension_charge_count) - if self.clean_suspension_time: - data[ATTR_CLEAN_SUSP_TIME] = self.clean_suspension_time - if self.clean_battery_start: - data[ATTR_CLEAN_BATTERY_START] = self.clean_battery_start - if self.clean_battery_end: - data[ATTR_CLEAN_BATTERY_END] = self.clean_battery_end - return data diff --git a/homeassistant/components/sensor/nederlandse_spoorwegen.py b/homeassistant/components/sensor/nederlandse_spoorwegen.py index e8d3aa41c6c..3535e00d79b 100644 --- a/homeassistant/components/sensor/nederlandse_spoorwegen.py +++ b/homeassistant/components/sensor/nederlandse_spoorwegen.py @@ -135,6 +135,10 @@ class NSDepartureSensor(Entity): 'departure_delay': self._trips[0].departure_time_planned != self._trips[0].departure_time_actual, + 'departure_platform': + self._trips[0].trip_parts[0].stops[0].platform, + 'departure_platform_changed': + self._trips[0].trip_parts[0].stops[0].platform_changed, 'arrival_time_planned': self._trips[0].arrival_time_planned.strftime('%H:%M'), 'arrival_time_actual': @@ -142,6 +146,10 @@ class NSDepartureSensor(Entity): 'arrival_delay': self._trips[0].arrival_time_planned != self._trips[0].arrival_time_actual, + 'arrival_platform': + self._trips[0].trip_parts[0].stops[-1].platform, + 'arrival_platform_changed': + self._trips[0].trip_parts[0].stops[-1].platform_changed, 'next': self._trips[1].departure_time_actual.strftime('%H:%M'), 'status': self._trips[0].status.lower(), diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index a440074b81b..b140d02af04 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -4,19 +4,20 @@ Support for monitoring NZBGet NZB client. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nzbget/ """ -import logging from datetime import timedelta +import logging +from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_SSL, CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -145,7 +146,7 @@ class NZBGetAPI(object): """Initialize NZBGet API and set headers needed later.""" self.api_url = api_url self.status = None - self.headers = {'content-type': CONTENT_TYPE_JSON} + self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if username is not None and password is not None: self.auth = (username, password) @@ -155,7 +156,7 @@ class NZBGetAPI(object): def post(self, method, params=None): """Send a POST request and return the response as a dict.""" - payload = {"method": method} + payload = {'method': method} if params: payload['params'] = params diff --git a/homeassistant/components/sensor/pyload.py b/homeassistant/components/sensor/pyload.py new file mode 100644 index 00000000000..9e1c0875169 --- /dev/null +++ b/homeassistant/components/sensor/pyload.py @@ -0,0 +1,170 @@ +""" +Support for monitoring pyLoad. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.pyload/ +""" +from datetime import timedelta +import logging + +from aiohttp.hdrs import CONTENT_TYPE +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'pyLoad' +DEFAULT_PORT = 8000 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) + +SENSOR_TYPES = { + 'speed': ['speed', 'Speed', 'MB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_MONITORED_VARIABLES, default=['speed']): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_USERNAME): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the pyLoad sensors.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + ssl = 's' if config.get(CONF_SSL) else '' + name = config.get(CONF_NAME) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + monitored_types = config.get(CONF_MONITORED_VARIABLES) + url = "http{}://{}:{}/api/".format(ssl, host, port) + + try: + pyloadapi = PyLoadAPI( + api_url=url, username=username, password=password) + pyloadapi.update() + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as conn_err: + _LOGGER.error("Error setting up pyLoad API: %s", conn_err) + return False + + devices = [] + for ng_type in monitored_types: + new_sensor = PyLoadSensor( + api=pyloadapi, sensor_type=SENSOR_TYPES.get(ng_type), + client_name=name) + devices.append(new_sensor) + + add_devices(devices, True) + + +class PyLoadSensor(Entity): + """Representation of a pyLoad sensor.""" + + def __init__(self, api, sensor_type, client_name): + """Initialize a new pyLoad sensor.""" + self._name = '{} {}'.format(client_name, sensor_type[1]) + self.type = sensor_type[0] + self.api = api + self._state = None + self._unit_of_measurement = sensor_type[2] + + @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 unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + def update(self): + """Update state of sensor.""" + try: + self.api.update() + except requests.exceptions.ConnectionError: + # Error calling the API, already logged in api.update() + return + + if self.api.status is None: + _LOGGER.debug("Update of %s requested, but no status is available", + self._name) + return + + value = self.api.status.get(self.type) + if value is None: + _LOGGER.warning("Unable to locate value for %s", self.type) + return + + if "speed" in self.type and value > 0: + # Convert download rate from Bytes/s to MBytes/s + self._state = round(value / 2**20, 2) + else: + self._state = value + + +class PyLoadAPI(object): + """Simple wrapper for pyLoad's API.""" + + def __init__(self, api_url, username=None, password=None): + """Initialize pyLoad API and set headers needed later.""" + self.api_url = api_url + self.status = None + self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} + + if username is not None and password is not None: + self.payload = {'username': username, 'password': password} + self.login = requests.post( + '{}{}'.format(api_url, 'login'), data=self.payload, timeout=5) + self.update() + + def post(self, method, params=None): + """Send a POST request and return the response as a dict.""" + payload = {'method': method} + + if params: + payload['params'] = params + + try: + response = requests.post( + '{}{}'.format(self.api_url, 'statusServer'), json=payload, + cookies=self.login.cookies, headers=self.headers, timeout=5) + response.raise_for_status() + _LOGGER.debug("JSON Response: %s", response.json()) + return response.json() + + except requests.exceptions.ConnectionError as conn_exc: + _LOGGER.error("Failed to update pyLoad status. Error: %s", + conn_exc) + raise + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update cached response.""" + try: + self.status = self.post('speed') + except requests.exceptions.ConnectionError: + # Failed to update status - exception already logged in self.post + raise diff --git a/homeassistant/components/sensor/serial.py b/homeassistant/components/sensor/serial.py index ffa8bcc3070..df0f1e21625 100644 --- a/homeassistant/components/sensor/serial.py +++ b/homeassistant/components/sensor/serial.py @@ -6,12 +6,14 @@ https://home-assistant.io/components/sensor.serial/ """ import asyncio import logging +import json 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.const import ( + CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['pyserial-asyncio==0.4'] @@ -19,12 +21,17 @@ REQUIREMENTS = ['pyserial-asyncio==0.4'] _LOGGER = logging.getLogger(__name__) CONF_SERIAL_PORT = 'serial_port' +CONF_BAUDRATE = 'baudrate' DEFAULT_NAME = "Serial Sensor" +DEFAULT_BAUDRATE = 9600 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): + cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) @@ -33,8 +40,13 @@ 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) + baudrate = config.get(CONF_BAUDRATE) - sensor = SerialSensor(name, port) + value_template = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + + sensor = SerialSensor(name, port, baudrate, value_template) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, sensor.stop_serial_read()) @@ -44,28 +56,44 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SerialSensor(Entity): """Representation of a Serial sensor.""" - def __init__(self, name, port): + def __init__(self, name, port, baudrate, value_template): """Initialize the Serial sensor.""" self._name = name self._state = None self._port = port + self._baudrate = baudrate self._serial_loop_task = None + self._template = value_template + self._attributes = [] @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)) + self.serial_read(self._port, self._baudrate)) @asyncio.coroutine - def serial_read(self, device, **kwargs): + def serial_read(self, device, rate, **kwargs): """Read the data from the port.""" import serial_asyncio reader, _ = yield from serial_asyncio.open_serial_connection( - url=device, **kwargs) + url=device, baudrate=rate, **kwargs) while True: line = yield from reader.readline() - self._state = line.decode('utf-8').strip() + line = line.decode('utf-8').strip() + + try: + data = json.loads(line) + if isinstance(data, dict): + self._attributes = data + except ValueError: + pass + + if self._template is not None: + line = self._template.async_render_with_possible_json_value( + line) + + self._state = line self.async_schedule_update_ha_state() @asyncio.coroutine @@ -84,6 +112,11 @@ class SerialSensor(Entity): """No polling needed.""" return False + @property + def device_state_attributes(self): + """Return the attributes of the entity (if any JSON present).""" + return self._attributes + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 841ff107826..982e7d9559b 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.4.1'] +REQUIREMENTS = ['pysnmp==4.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 0c9a21447a8..324d3029c99 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.0'] +REQUIREMENTS = ['psutil==5.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/thethingsnetwork.py b/homeassistant/components/sensor/thethingsnetwork.py index 90b21cc19e5..28a3b48892b 100644 --- a/homeassistant/components/sensor/thethingsnetwork.py +++ b/homeassistant/components/sensor/thethingsnetwork.py @@ -8,15 +8,16 @@ import asyncio import logging import aiohttp +from aiohttp.hdrs import ACCEPT, AUTHORIZATION import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.thethingsnetwork import ( DATA_TTN, TTN_APP_ID, TTN_ACCESS_KEY, TTN_DATA_STORAGE_URL) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -122,8 +123,8 @@ class TtnDataStorage(object): self._url = TTN_DATA_STORAGE_URL.format( app_id=app_id, endpoint='api/v2/query', device_id=device_id) self._headers = { - 'Accept': CONTENT_TYPE_JSON, - 'Authorization': 'key {}'.format(access_key), + ACCEPT: CONTENT_TYPE_JSON, + AUTHORIZATION: 'key {}'.format(access_key), } @asyncio.coroutine diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index f1edaa37f77..dd09b9f7891 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.1.1'] +REQUIREMENTS = ['pyTibber==0.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/viaggiatreno.py b/homeassistant/components/sensor/viaggiatreno.py new file mode 100644 index 00000000000..37e7e020cc9 --- /dev/null +++ b/homeassistant/components/sensor/viaggiatreno.py @@ -0,0 +1,187 @@ +""" +Support for information about the Italian train system using ViaggiaTreno API. + +For more details about this platform please refer to the documentation at +https://home-assistant.io/components/sensor.viaggiatreno +""" +import logging + +import asyncio +import async_timeout +import aiohttp + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Powered by ViaggiaTreno Data" +VIAGGIATRENO_ENDPOINT = ("http://www.viaggiatreno.it/viaggiatrenonew/" + "resteasy/viaggiatreno/andamentoTreno/" + "{station_id}/{train_id}") + +REQUEST_TIMEOUT = 5 # seconds +ICON = 'mdi:train' +MONITORED_INFO = [ + 'categoria', + 'compOrarioArrivoZeroEffettivo', + 'compOrarioPartenzaZeroEffettivo', + 'destinazione', + 'numeroTreno', + 'orarioArrivo', + 'orarioPartenza', + 'origine', + 'subTitle', + ] + +DEFAULT_NAME = "Train {}" + +CONF_NAME = 'train_name' +CONF_STATION_ID = 'station_id' +CONF_STATION_NAME = 'station_name' +CONF_TRAIN_ID = 'train_id' + +ARRIVED_STRING = 'Arrived' +CANCELLED_STRING = 'Cancelled' +NOT_DEPARTED_STRING = "Not departed yet" +NO_INFORMATION_STRING = "No information for this train now" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TRAIN_ID): cv.string, + vol.Required(CONF_STATION_ID): cv.string, + vol.Optional(CONF_NAME): cv.string, + }) + + +@asyncio.coroutine +def async_setup_platform(hass, config, + async_add_devices, discovery_info=None): + """Setup the ViaggiaTreno platform.""" + train_id = config.get(CONF_TRAIN_ID) + station_id = config.get(CONF_STATION_ID) + name = config.get(CONF_NAME) + if not name: + name = DEFAULT_NAME.format(train_id) + async_add_devices([ViaggiaTrenoSensor(train_id, station_id, name)]) + + +@asyncio.coroutine +def async_http_request(hass, uri): + """Perform actual request.""" + try: + session = hass.helpers.aiohttp_client.async_get_clientsession(hass) + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + req = yield from session.get(uri) + if req.status != 200: + return {'error': req.status} + else: + json_response = yield from req.json() + return json_response + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) + except ValueError: + _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint") + + +class ViaggiaTrenoSensor(Entity): + """Implementation of a ViaggiaTreno sensor.""" + + def __init__(self, train_id, station_id, name): + """Initialize the sensor.""" + self._state = None + self._attributes = {} + self._unit = '' + self._icon = ICON + self._station_id = station_id + self._name = name + + self.uri = VIAGGIATRENO_ENDPOINT.format( + station_id=station_id, + train_id=train_id) + + @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 + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return extra attributes.""" + self._attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + return self._attributes + + @staticmethod + def has_departed(data): + """Check if the train has actually departed.""" + try: + first_station = data['fermate'][0] + if data['oraUltimoRilevamento'] or first_station['effettiva']: + return True + except ValueError: + _LOGGER.error("Cannot fetch first station: %s", data) + return False + + @staticmethod + def has_arrived(data): + """Check if the train has already arrived.""" + last_station = data['fermate'][-1] + if not last_station['effettiva']: + return False + return True + + @staticmethod + def is_cancelled(data): + """Check if the train is cancelled.""" + if data['tipoTreno'] == 'ST' and data['provvedimento'] == 1: + return True + return False + + @asyncio.coroutine + def async_update(self): + """Update state.""" + uri = self.uri + res = yield from async_http_request(self.hass, uri) + if res.get('error', ''): + if res['error'] == 204: + self._state = NO_INFORMATION_STRING + self._unit = '' + else: + self._state = "Error: {}".format(res['error']) + self._unit = '' + else: + for i in MONITORED_INFO: + self._attributes[i] = res[i] + + if self.is_cancelled(res): + self._state = CANCELLED_STRING + self._icon = 'mdi:cancel' + self._unit = '' + elif not self.has_departed(res): + self._state = NOT_DEPARTED_STRING + self._unit = '' + elif self.has_arrived(res): + self._state = ARRIVED_STRING + self._unit = '' + else: + self._state = res.get('ritardo') + self._unit = 'min' + self._icon = ICON diff --git a/homeassistant/components/sensor/vultr.py b/homeassistant/components/sensor/vultr.py new file mode 100644 index 00000000000..7a3db3895dc --- /dev/null +++ b/homeassistant/components/sensor/vultr.py @@ -0,0 +1,115 @@ +""" +Support for monitoring the state of Vultr Subscriptions. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.vultr/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, CONF_NAME) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_CURRENT_BANDWIDTH_USED, ATTR_PENDING_CHARGES, + DATA_VULTR) + +# Name defaults to {subscription label} {sensor name} +DEFAULT_NAME = 'Vultr {} {}' +DEPENDENCIES = ['vultr'] + +_LOGGER = logging.getLogger(__name__) + +# Monitored conditions: name, units, icon +MONITORED_CONDITIONS = { + ATTR_CURRENT_BANDWIDTH_USED: ['Current Bandwidth Used', 'GB', + 'mdi:chart-histogram'], + ATTR_PENDING_CHARGES: ['Pending Charges', 'US$', + 'mdi:currency-usd'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription (server) sensor.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + sensors = [] + + for condition in monitored_conditions: + sensors.append(VultrSensor(vultr, + subscription, + condition, + name)) + + add_devices(sensors, True) + + +class VultrSensor(Entity): + """Representation of a Vultr subscription sensor.""" + + def __init__(self, vultr, subscription, condition, name): + """Initialize a new Vultr sensor.""" + self._vultr = vultr + self._condition = condition + self._name = name + + self.subscription = subscription + self.data = None + + condition_info = MONITORED_CONDITIONS[condition] + + self._condition_name = condition_info[0] + self._units = condition_info[1] + self._icon = condition_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + try: + return self._name.format(self._condition_name) + except IndexError: # name contains more {} than fulfilled + try: + return self._name.format(self.data['label'], + self._condition_name) + except (KeyError, TypeError): # label key missing or data is None + return self._name + + @property + def icon(self): + """Icon used in the frontend if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """The unit of measurement to present the value in.""" + return self._units + + @property + def state(self): + """Return the value of this given sensor type.""" + try: + return round(float(self.data.get(self._condition)), 2) + except (TypeError, ValueError): + return self.data.get(self._condition) + + def update(self): + """Update state of sensor.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 2fcb13e13dd..c0763c4fefa 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -616,14 +616,13 @@ LANG_CODES = [ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_PWS_ID): cv.string, - vol.Optional(CONF_LANG, default=DEFAULT_LANG): - vol.All(vol.In(LANG_CODES)), + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.All(vol.In(LANG_CODES)), vol.Inclusive(CONF_LATITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index 2883a396b77..873e27975db 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['yahooweather==0.8'] +REQUIREMENTS = ['yahooweather==0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/zamg.py b/homeassistant/components/sensor/zamg.py index 3eb677b4f02..4b63d769243 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -5,24 +5,25 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zamg/ """ import csv +from datetime import datetime, timedelta import gzip import json import logging import os -from datetime import datetime, timedelta +from aiohttp.hdrs import USER_AGENT import pytz import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.weather import ( - ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, - ATTR_WEATHER_WIND_SPEED) + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING) from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, __version__, - CONF_LATITUDE, CONF_LONGITUDE) + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + __version__) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -30,13 +31,12 @@ _LOGGER = logging.getLogger(__name__) ATTR_STATION = 'station' ATTR_UPDATED = 'updated' -ATTRIBUTION = 'Data provided by ZAMG' +ATTRIBUTION = "Data provided by ZAMG" CONF_STATION_ID = 'station_id' DEFAULT_NAME = 'zamg' -# Data source updates once per hour, so we do nothing if it's been less time MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) SENSOR_TYPES = { @@ -138,7 +138,7 @@ class ZamgData(object): API_URL = 'http://www.zamg.ac.at/ogd/' API_HEADERS = { - 'User-Agent': '{} {}'.format('home-assistant.zamg/', __version__), + USER_AGENT: '{} {}'.format('home-assistant.zamg/', __version__), } def __init__(self, station_id): @@ -162,8 +162,8 @@ class ZamgData(object): cls.API_URL, headers=cls.API_HEADERS, timeout=15) response.raise_for_status() response.encoding = 'UTF8' - return csv.DictReader(response.text.splitlines(), - delimiter=';', quotechar='"') + return csv.DictReader( + response.text.splitlines(), delimiter=';', quotechar='"') except requests.exceptions.HTTPError: _LOGGER.error("While fetching data") diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 37829142e0c..c4e460fdb66 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -390,26 +390,6 @@ rflink: description: The command to be sent. example: 'on' -counter: - decrement: - description: Decrement a counter. - fields: - entity_id: - description: Entity id of the counter to decrement. - example: 'counter.count0' - increment: - description: Increment a counter. - fields: - entity_id: - description: Entity id of the counter to increment. - example: 'counter.count0' - reset: - description: Reset a counter. - fields: - entity_id: - description: Entity id of the counter to reset. - example: 'counter.count0' - abode: change_setting: description: Change an Abode system setting. diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index a271297d0fd..c186559c91a 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -87,9 +87,14 @@ def _async_process_message(sia_message, spc_registry): # ZX - Zone Short # ZD - Zone Disconnected - if sia_code in ('BA', 'CG', 'NL', 'OG', 'OQ'): + extra = {} + + if sia_code in ('BA', 'CG', 'NL', 'OG'): # change in area status, notify alarm panel device device = spc_registry.get_alarm_device(spc_id) + data = sia_message['description'].split('¦') + if len(data) == 3: + extra['changed_by'] = data[1] else: # change in zone status, notify sensor device device = spc_registry.get_sensor_device(spc_id) @@ -98,7 +103,6 @@ def _async_process_message(sia_message, spc_registry): 'CG': STATE_ALARM_ARMED_AWAY, 'NL': STATE_ALARM_ARMED_HOME, 'OG': STATE_ALARM_DISARMED, - 'OQ': STATE_ALARM_DISARMED, 'ZO': STATE_ON, 'ZC': STATE_OFF, 'ZX': STATE_UNKNOWN, @@ -110,7 +114,7 @@ def _async_process_message(sia_message, spc_registry): _LOGGER.warning("No device mapping found for SPC area/zone id %s.", spc_id) elif new_state: - yield from device.async_update_from_spc(new_state) + yield from device.async_update_from_spc(new_state, extra) class SpcRegistry: diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py index 38f8a91a917..a5b42eb9b5a 100644 --- a/homeassistant/components/splunk.py +++ b/homeassistant/components/splunk.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/splunk/ import json import logging +from aiohttp.hdrs import AUTHORIZATION import requests import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, EVENT_STATE_CHANGED) + CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TOKEN, EVENT_STATE_CHANGED) from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv from homeassistant.remote import JSONEncoder @@ -52,7 +53,7 @@ def setup(hass, config): event_collector = '{}{}:{}/services/collector/event'.format( uri_scheme, host, port) - headers = {'Authorization': 'Splunk {}'.format(token)} + headers = {AUTHORIZATION: 'Splunk {}'.format(token)} def splunk_event_listener(event): """Listen for new messages on the bus and sends them to Splunk.""" diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index c12d13860e2..8abdba31b67 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -117,6 +117,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for packet in packets: for retry in range(DEFAULT_RETRY): try: + extra = len(packet) % 4 + if extra > 0: + packet = packet + ('=' * (4 - extra)) payload = b64decode(packet) yield from hass.async_add_job( broadlink_device.send_data, payload) diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py index acb9af3cacb..c3e065abc0e 100644 --- a/homeassistant/components/switch/hikvisioncam.py +++ b/homeassistant/components/switch/hikvisioncam.py @@ -15,7 +15,8 @@ from homeassistant.const import ( from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['hikvision==1.2'] +REQUIREMENTS = ['hikvision==0.4'] +# This is the last working version, please test before updating _LOGGING = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index daaba68dc5e..da36c76f41d 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -4,6 +4,7 @@ Support for Lutron Caseta switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sitch.lutron_caseta/ """ +import asyncio import logging from homeassistant.components.lutron_caseta import ( @@ -16,7 +17,8 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -26,18 +28,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = LutronCasetaLight(switch_device, bridge) devs.append(dev) - add_devices(devs, True) + async_add_devices(devs, True) return True class LutronCasetaLight(LutronCasetaDevice, SwitchDevice): """Representation of a Lutron Caseta switch.""" - def turn_on(self, **kwargs): + @asyncio.coroutine + def async_turn_on(self, **kwargs): """Turn the switch on.""" self._smartbridge.turn_on(self._device_id) - def turn_off(self, **kwargs): + @asyncio.coroutine + def async_turn_off(self, **kwargs): """Turn the switch off.""" self._smartbridge.turn_off(self._device_id) @@ -46,7 +50,8 @@ class LutronCasetaLight(LutronCasetaDevice, SwitchDevice): """Return true if device is on.""" return self._state["current_state"] > 0 - def update(self): + @asyncio.coroutine + def async_update(self): """Update when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index e6342617f28..c731b336dfb 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -8,7 +8,8 @@ import logging import voluptuous as vol import homeassistant.components.modbus as modbus -from homeassistant.const import CONF_NAME, CONF_SLAVE +from homeassistant.const import ( + CONF_NAME, CONF_SLAVE, CONF_COMMAND_ON, CONF_COMMAND_OFF) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers import config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -18,32 +19,76 @@ DEPENDENCIES = ['modbus'] CONF_COIL = "coil" CONF_COILS = "coils" +CONF_REGISTER = "register" +CONF_REGISTERS = "registers" +CONF_VERIFY_STATE = "verify_state" +CONF_VERIFY_REGISTER = "verify_register" +CONF_REGISTER_TYPE = "register_type" +CONF_STATE_ON = "state_on" +CONF_STATE_OFF = "state_off" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_COILS): [{ - vol.Required(CONF_COIL): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_SLAVE): cv.positive_int, - }] +REGISTER_TYPE_HOLDING = 'holding' +REGISTER_TYPE_INPUT = 'input' + +REGISTERS_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_REGISTER): cv.positive_int, + vol.Required(CONF_COMMAND_ON): cv.positive_int, + vol.Required(CONF_COMMAND_OFF): cv.positive_int, + vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, + vol.Optional(CONF_VERIFY_REGISTER, default=None): + cv.positive_int, + vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): + vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), + vol.Optional(CONF_STATE_ON, default=None): cv.positive_int, + vol.Optional(CONF_STATE_OFF, default=None): cv.positive_int, }) +COILS_SCHEMA = vol.Schema({ + vol.Required(CONF_COIL): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, +}) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_COILS, CONF_REGISTERS), + PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_COILS): [COILS_SCHEMA], + vol.Optional(CONF_REGISTERS): [REGISTERS_SCHEMA] + })) + def setup_platform(hass, config, add_devices, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] - for coil in config.get("coils"): - switches.append(ModbusCoilSwitch( - coil.get(CONF_NAME), - coil.get(CONF_SLAVE), - coil.get(CONF_COIL))) + if CONF_COILS in config: + for coil in config.get(CONF_COILS): + switches.append(ModbusCoilSwitch( + coil.get(CONF_NAME), + coil.get(CONF_SLAVE), + coil.get(CONF_COIL))) + if CONF_REGISTERS in config: + for register in config.get(CONF_REGISTERS): + switches.append(ModbusRegisterSwitch( + register.get(CONF_NAME), + register.get(CONF_SLAVE), + register.get(CONF_REGISTER), + register.get(CONF_COMMAND_ON), + register.get(CONF_COMMAND_OFF), + register.get(CONF_VERIFY_STATE), + register.get(CONF_VERIFY_REGISTER), + register.get(CONF_REGISTER_TYPE), + register.get(CONF_STATE_ON), + register.get(CONF_STATE_OFF))) add_devices(switches) class ModbusCoilSwitch(ToggleEntity): - """Representation of a Modbus switch.""" + """Representation of a Modbus coil switch.""" def __init__(self, name, slave, coil): - """Initialize the switch.""" + """Initialize the coil switch.""" self._name = name self._slave = int(slave) if slave else None self._coil = int(coil) @@ -77,3 +122,82 @@ class ModbusCoilSwitch(ToggleEntity): 'No response from modbus slave %s coil %s', self._slave, self._coil) + + +class ModbusRegisterSwitch(ModbusCoilSwitch): + """Representation of a Modbus register switch.""" + + # pylint: disable=super-init-not-called + def __init__(self, name, slave, register, command_on, + command_off, verify_state, verify_register, + register_type, state_on, state_off): + """Initialize the register switch.""" + self._name = name + self._slave = slave + self._register = register + self._command_on = command_on + self._command_off = command_off + self._verify_state = verify_state + self._verify_register = ( + verify_register if verify_register else self._register) + self._register_type = register_type + self._state_on = ( + state_on if state_on else self._command_on) + self._state_off = ( + state_off if state_off else self._command_off) + self._is_on = None + + def turn_on(self, **kwargs): + """Set switch on.""" + modbus.HUB.write_register( + self._slave, + self._register, + self._command_on) + if not self._verify_state: + self._is_on = True + + def turn_off(self, **kwargs): + """Set switch off.""" + modbus.HUB.write_register( + self._slave, + self._register, + self._command_off) + if not self._verify_state: + self._is_on = False + + def update(self): + """Update the state of the switch.""" + if not self._verify_state: + return + + value = 0 + if self._register_type == REGISTER_TYPE_INPUT: + result = modbus.HUB.read_input_registers( + self._slave, + self._register, + 1) + else: + result = modbus.HUB.read_holding_registers( + self._slave, + self._register, + 1) + + try: + value = int(result.registers[0]) + except AttributeError: + _LOGGER.error( + 'No response from modbus slave %s register %s', + self._slave, + self._verify_register) + + if value == self._state_on: + self._is_on = True + elif value == self._state_off: + self._is_on = False + else: + _LOGGER.error( + 'Unexpected response from modbus slave %s ' + 'register %s, got 0x%2x', + self._slave, + self._verify_register, + value) diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py index f29dc31eaf0..62bc5f99d01 100644 --- a/homeassistant/components/switch/neato.py +++ b/homeassistant/components/switch/neato.py @@ -14,11 +14,9 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] -SWITCH_TYPE_CLEAN = 'clean' -SWITCH_TYPE_SCHEDULE = 'scedule' +SWITCH_TYPE_SCHEDULE = 'schedule' SWITCH_TYPES = { - SWITCH_TYPE_CLEAN: ['Clean'], SWITCH_TYPE_SCHEDULE: ['Schedule'] } @@ -64,15 +62,6 @@ class NeatoConnectedSwitch(ToggleEntity): self._state = None return _LOGGER.debug('self._state=%s', self._state) - if self.type == SWITCH_TYPE_CLEAN: - if (self.robot.state['action'] == 1 or - self.robot.state['action'] == 2 or - self.robot.state['action'] == 3 and - self.robot.state['state'] == 2): - self._clean_state = STATE_ON - else: - self._clean_state = STATE_OFF - _LOGGER.debug("Clean state: %s", self._clean_state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) if self.robot.schedule_enabled: @@ -94,26 +83,17 @@ class NeatoConnectedSwitch(ToggleEntity): @property def is_on(self): """Return true if switch is on.""" - if self.type == SWITCH_TYPE_CLEAN: - if self._clean_state == STATE_ON: - return True - return False - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: if self._schedule_state == STATE_ON: return True return False def turn_on(self, **kwargs): """Turn the switch on.""" - if self.type == SWITCH_TYPE_CLEAN: - self.robot.start_cleaning() - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: self.robot.enable_schedule() def turn_off(self, **kwargs): """Turn the switch off.""" - if self.type == SWITCH_TYPE_CLEAN: - self.robot.pause_cleaning() - self.robot.send_to_base() - elif self.type == SWITCH_TYPE_SCHEDULE: + if self.type == SWITCH_TYPE_SCHEDULE: self.robot.disable_schedule() diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index d372991c3e2..99ba9d8cd54 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.1'] +REQUIREMENTS = ['pysnmp==4.4.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/vultr.py b/homeassistant/components/switch/vultr.py new file mode 100644 index 00000000000..888db754f01 --- /dev/null +++ b/homeassistant/components/switch/vultr.py @@ -0,0 +1,106 @@ +""" +Support for interacting with Vultr subscriptions. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.vultr/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.vultr import ( + CONF_SUBSCRIPTION, ATTR_AUTO_BACKUPS, ATTR_ALLOWED_BANDWIDTH, + ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, ATTR_SUBSCRIPTION_NAME, + ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_DISK, + ATTR_COST_PER_MONTH, ATTR_OS, ATTR_REGION, ATTR_VCPUS, DATA_VULTR) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Vultr {}' +DEPENDENCIES = ['vultr'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SUBSCRIPTION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Vultr subscription switch.""" + vultr = hass.data[DATA_VULTR] + + subscription = config.get(CONF_SUBSCRIPTION) + name = config.get(CONF_NAME) + + if subscription not in vultr.data: + _LOGGER.error("Subscription %s not found", subscription) + return False + + add_devices([VultrSwitch(vultr, subscription, name)], True) + + +class VultrSwitch(SwitchDevice): + """Representation of a Vultr subscription switch.""" + + def __init__(self, vultr, subscription, name): + """Initialize a new Vultr switch.""" + self._vultr = vultr + self._name = name + + self.subscription = subscription + self.data = None + + @property + def name(self): + """Return the name of the switch.""" + try: + return self._name.format(self.data['label']) + except (TypeError, KeyError): + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self.data['power_status'] == 'running' + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def device_state_attributes(self): + """Return the state attributes of the Vultr subscription.""" + return { + ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'), + ATTR_AUTO_BACKUPS: self.data.get('auto_backups'), + ATTR_COST_PER_MONTH: self.data.get('cost_per_month'), + ATTR_CREATED_AT: self.data.get('date_created'), + ATTR_DISK: self.data.get('disk'), + ATTR_IPV4_ADDRESS: self.data.get('main_ip'), + ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'), + ATTR_MEMORY: self.data.get('ram'), + ATTR_OS: self.data.get('os'), + ATTR_REGION: self.data.get('location'), + ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'), + ATTR_SUBSCRIPTION_NAME: self.data.get('label'), + ATTR_VCPUS: self.data.get('vcpu_count'), + } + + def turn_on(self): + """Boot-up the subscription.""" + if self.data['power_status'] != 'running': + self._vultr.start(self.subscription) + + def turn_off(self): + """Halt the subscription.""" + if self.data['power_status'] == 'running': + self._vultr.halt(self.subscription) + + def update(self): + """Get the latest data from the device and update the data.""" + self._vultr.update() + self.data = self._vultr.data[self.subscription] diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 1191322dce6..aaa37a24c0e 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' @@ -68,8 +68,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): elif device_info.model in ['qmi.powerstrip.v1', 'zimi.powerstrip.v2']: - from miio import Strip - plug = Strip(host, token) + from miio import PowerStrip + plug = PowerStrip(host, token) device = XiaomiPowerStripSwitch(name, plug, device_info) devices.append(device) elif device_info.model in ['chuangmi.plug.m1', @@ -288,5 +288,9 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): else: self._state = state.is_on + self._state_attrs.update({ + ATTR_TEMPERATURE: state.temperature + }) + except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py new file mode 100644 index 00000000000..6505107d034 --- /dev/null +++ b/homeassistant/components/system_log/__init__.py @@ -0,0 +1,143 @@ +""" +Support for system log. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/system_log/ +""" +import os +import re +import asyncio +import logging +import traceback +from io import StringIO +from collections import deque + +import voluptuous as vol + +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView + +DOMAIN = 'system_log' +DEPENDENCIES = ['http'] +SERVICE_CLEAR = 'clear' + +CONF_MAX_ENTRIES = 'max_entries' + +DEFAULT_MAX_ENTRIES = 50 + +DATA_SYSTEM_LOG = 'system_log' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MAX_ENTRIES, + default=DEFAULT_MAX_ENTRIES): cv.positive_int, + }), +}, extra=vol.ALLOW_EXTRA) + +SERVICE_CLEAR_SCHEMA = vol.Schema({}) + + +class LogErrorHandler(logging.Handler): + """Log handler for error messages.""" + + def __init__(self, maxlen): + """Initialize a new LogErrorHandler.""" + super().__init__() + self.records = deque(maxlen=maxlen) + + def emit(self, record): + """Save error and warning logs. + + Everyhing logged with error or warning is saved in local buffer. A + default upper limit is set to 50 (older entries are discarded) but can + be changed if neeeded. + """ + if record.levelno >= logging.WARN: + self.records.appendleft(record) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the logger component.""" + conf = config.get(DOMAIN) + + if conf is None: + conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + + handler = LogErrorHandler(conf.get(CONF_MAX_ENTRIES)) + logging.getLogger().addHandler(handler) + + hass.http.register_view(AllErrorsView(handler)) + + @asyncio.coroutine + def async_service_handler(service): + """Handle logger services.""" + # Only one service so far + handler.records.clear() + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + hass.services.async_register( + DOMAIN, SERVICE_CLEAR, async_service_handler, + descriptions[DOMAIN].get(SERVICE_CLEAR), + schema=SERVICE_CLEAR_SCHEMA) + + return True + + +def _figure_out_source(record): + # If a stack trace exists, extract filenames from the entire call stack. + # The other case is when a regular "log" is made (without an attached + # exception). In that case, just use the file where the log was made from. + if record.exc_info: + stack = [x[0] for x in traceback.extract_tb(record.exc_info[2])] + else: + stack = [record.pathname] + + # Iterate through the stack call (in reverse) and find the last call from + # a file in HA. Try to figure out where error happened. + for pathname in reversed(stack): + + # Try to match with a file within HA + match = re.match(r'.*/homeassistant/(.*)', pathname) + if match: + return match.group(1) + + # Ok, we don't know what this is + return 'unknown' + + +def _exception_as_string(exc_info): + buf = StringIO() + if exc_info: + traceback.print_exception(*exc_info, file=buf) + return buf.getvalue() + + +def _convert(record): + return { + 'timestamp': record.created, + 'level': record.levelname, + 'message': record.getMessage(), + 'exception': _exception_as_string(record.exc_info), + 'source': _figure_out_source(record), + } + + +class AllErrorsView(HomeAssistantView): + """Get all logged errors and warnings.""" + + url = "/api/error/all" + name = "api:error:all" + + def __init__(self, handler): + """Initialize a new AllErrorsView.""" + self.handler = handler + + @asyncio.coroutine + def get(self, request): + """Get all errors and warnings.""" + return self.json([_convert(x) for x in self.handler.records]) diff --git a/homeassistant/components/system_log/services.yaml b/homeassistant/components/system_log/services.yaml new file mode 100644 index 00000000000..98f86e12f8c --- /dev/null +++ b/homeassistant/components/system_log/services.yaml @@ -0,0 +1,3 @@ +system_log: + clear: + description: Clear all log entries. diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 896dbdc4399..dc9389b1144 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -65,6 +65,7 @@ DOMAIN = 'telegram_bot' SERVICE_SEND_MESSAGE = 'send_message' SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_VIDEO = 'send_video' SERVICE_SEND_DOCUMENT = 'send_document' SERVICE_SEND_LOCATION = 'send_location' SERVICE_EDIT_MESSAGE = 'edit_message' @@ -154,6 +155,7 @@ SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema({ SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, @@ -277,12 +279,11 @@ def async_setup(hass, config): if msgtype == SERVICE_SEND_MESSAGE: yield from hass.async_add_job( partial(notify_service.send_message, **kwargs)) - elif msgtype == SERVICE_SEND_PHOTO: + elif (msgtype == SERVICE_SEND_PHOTO or + msgtype == SERVICE_SEND_VIDEO or + msgtype == SERVICE_SEND_DOCUMENT): yield from hass.async_add_job( - partial(notify_service.send_file, True, **kwargs)) - elif msgtype == SERVICE_SEND_DOCUMENT: - yield from hass.async_add_job( - partial(notify_service.send_file, False, **kwargs)) + partial(notify_service.send_file, msgtype, **kwargs)) elif msgtype == SERVICE_SEND_LOCATION: yield from hass.async_add_job( partial(notify_service.send_location, **kwargs)) @@ -518,11 +519,15 @@ class TelegramNotificationService: callback_query_id, text=message, show_alert=show_alert, **params) - def send_file(self, is_photo=True, target=None, **kwargs): - """Send a photo or a document.""" + def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): + """Send a photo, video, or document.""" params = self._get_msg_kwargs(kwargs) caption = kwargs.get(ATTR_CAPTION) - func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument + func_send = { + SERVICE_SEND_PHOTO: self.bot.sendPhoto, + SERVICE_SEND_VIDEO: self.bot.sendVideo, + SERVICE_SEND_DOCUMENT: self.bot.sendDocument + }.get(file_type) file_content = load_data( self.hass, url=kwargs.get(ATTR_URL), diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 4e26dfe3238..0ce11441843 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -10,6 +10,7 @@ import logging import async_timeout from aiohttp.client_exceptions import ClientError +from aiohttp.hdrs import CONNECTION, KEEP_ALIVE from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, @@ -22,6 +23,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA +RETRY_SLEEP = 10 + + +class WrongHttpStatus(Exception): + """Thrown when a wrong http status is received.""" + + pass @asyncio.coroutine @@ -41,20 +49,14 @@ def async_setup_platform(hass, config): """Stop the bot.""" pol.stop_polling() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, - _start_bot - ) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_bot - ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_bot) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_bot) return True class TelegramPoll(BaseTelegramBotEntity): - """asyncio telegram incoming message handler.""" + """Asyncio telegram incoming message handler.""" def __init__(self, bot, hass, allowed_chat_ids): """Initialize the polling instance.""" @@ -62,9 +64,9 @@ class TelegramPoll(BaseTelegramBotEntity): self.update_id = 0 self.websession = async_get_clientsession(hass) self.update_url = '{0}/getUpdates'.format(bot.base_url) - self.polling_task = None # The actuall polling task. + self.polling_task = None # The actual polling task. self.timeout = 15 # async post timeout - # polling timeout should always be less than async post timeout. + # Polling timeout should always be less than async post timeout. self.post_data = {'timeout': self.timeout - 5} def start_polling(self): @@ -79,52 +81,48 @@ class TelegramPoll(BaseTelegramBotEntity): def get_updates(self, offset): """Bypass the default long polling method to enable asyncio.""" resp = None - _json = {'result': [], 'ok': True} # Empty result. - if offset: self.post_data['offset'] = offset try: with async_timeout.timeout(self.timeout, loop=self.hass.loop): resp = yield from self.websession.post( self.update_url, data=self.post_data, - headers={'connection': 'keep-alive'} + headers={CONNECTION: KEEP_ALIVE} ) if resp.status == 200: _json = yield from resp.json() + return _json else: - _LOGGER.error("Error %s on %s", resp.status, self.update_url) - - except ValueError: - _LOGGER.error("Error parsing Json message") - except (asyncio.TimeoutError, ClientError): - _LOGGER.error("Client connection error") + raise WrongHttpStatus('wrong status %s', resp.status) finally: if resp is not None: yield from resp.release() - return _json - - @asyncio.coroutine - def handle(self): - """Receiving and processing incoming messages.""" - _updates = yield from self.get_updates(self.update_id) - _updates = _updates.get('result') - if _updates is None: - _LOGGER.error("Incorrect result received.") - else: - for update in _updates: - self.update_id = update['update_id'] + 1 - self.process_message(update) - @asyncio.coroutine def check_incoming(self): - """Loop which continuously checks for incoming telegram messages.""" + """Continuously check for incoming telegram messages.""" try: while True: - # Each handle call sends a long polling post request - # to the telegram server. If no incoming message it will return - # an empty list. Calling self.handle() without any delay or - # timeout will for this reason not really stress the processor. - yield from self.handle() + try: + _updates = yield from self.get_updates(self.update_id) + except (WrongHttpStatus, ClientError) as err: + # WrongHttpStatus: Non-200 status code. + # Occurs at times (mainly 502) and recovers + # automatically. Pause for a while before retrying. + _LOGGER.error(err) + yield from asyncio.sleep(RETRY_SLEEP) + except (asyncio.TimeoutError, ValueError): + # Long polling timeout. Nothing serious. + # Json error. Just retry for the next message. + pass + else: + # no exception raised. update received data. + _updates = _updates.get('result') + if _updates is None: + _LOGGER.error("Incorrect result received.") + else: + for update in _updates: + self.update_id = update['update_id'] + 1 + self.process_message(update) except CancelledError: _LOGGER.debug("Stopping Telegram polling bot") diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 3b86d97c310..dc864c9f61a 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -59,6 +59,37 @@ send_photo: description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' +send_video: + description: Send a video. + fields: + url: + description: Remote path to a video. + example: 'http://example.org/path/to/the/video.mp4' + file: + description: Local path to an image. + example: '/path/to/the/video.mp4' + caption: + description: The title of the video. + example: 'My video' + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + send_document: description: Send a document. fields: diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index f7d2c1a77b5..b299aaa8185 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -1,3 +1,5 @@ +# Describes the format for available timer services + start: description: Start a timer. diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index ead4924d599..53ea7eac997 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -5,9 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/ikea_tradfri/ """ import asyncio -import json import logging -import os +from uuid import uuid4 import voluptuous as vol @@ -15,6 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI +from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['pytradfri==4.0.1', 'DTLSSocket==0.1.4', @@ -58,26 +58,40 @@ def request_configuration(hass, config, host): """Handle the submitted configuration.""" try: from pytradfri.api.aiocoap_api import APIFactory + from pytradfri import RequestError except ImportError: _LOGGER.exception("Looks like something isn't installed!") return - api_factory = APIFactory(host, psk_id=GATEWAY_IDENTITY) - psk = yield from api_factory.generate_psk(callback_data.get('key')) - res = yield from _setup_gateway(hass, config, host, psk, + identity = uuid4().hex + security_code = callback_data.get('security_code') + + api_factory = APIFactory(host, psk_id=identity, loop=hass.loop) + # Need To Fix: currently entering a wrong security code sends + # pytradfri aiocoap API into an endless loop. + # Should just raise a requestError or something. + try: + key = yield from api_factory.generate_psk(security_code) + except RequestError: + configurator.async_notify_errors(hass, instance, + "Security Code not accepted.") + return + + res = yield from _setup_gateway(hass, config, host, identity, key, DEFAULT_ALLOW_TRADFRI_GROUPS) if not res: - hass.async_add_job(configurator.notify_errors, instance, - "Unable to connect.") + configurator.async_notify_errors(hass, instance, + "Unable to connect.") return def success(): """Set up was successful.""" - conf = _read_config(hass) - conf[host] = {'key': psk} - _write_config(hass, conf) - hass.async_add_job(configurator.request_done, instance) + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {'identity': identity, + 'key': key} + save_json(hass.config.path(CONFIG_FILE), conf) + configurator.request_done(instance) hass.async_add_job(success) @@ -86,7 +100,8 @@ def request_configuration(hass, config, host): description='Please enter the security code written at the bottom of ' 'your IKEA Trådfri Gateway.', submit_caption="Confirm", - fields=[{'id': 'key', 'name': 'Security Code', 'type': 'password'}] + fields=[{'id': 'security_code', 'name': 'Security Code', + 'type': 'password'}] ) @@ -96,35 +111,37 @@ def async_setup(hass, config): conf = config.get(DOMAIN, {}) host = conf.get(CONF_HOST) allow_tradfri_groups = conf.get(CONF_ALLOW_TRADFRI_GROUPS) - keys = yield from hass.async_add_job(_read_config, hass) + known_hosts = yield from hass.async_add_job(load_json, + hass.config.path(CONFIG_FILE)) @asyncio.coroutine - def gateway_discovered(service, info): + def gateway_discovered(service, info, + allow_tradfri_groups=DEFAULT_ALLOW_TRADFRI_GROUPS): """Run when a gateway is discovered.""" host = info['host'] - if host in keys: - yield from _setup_gateway(hass, config, host, keys[host]['key'], + if host in known_hosts: + # use fallbacks for old config style + # identity was hard coded as 'homeassistant' + identity = known_hosts[host].get('identity', 'homeassistant') + key = known_hosts[host].get('key') + yield from _setup_gateway(hass, config, host, identity, key, allow_tradfri_groups) else: hass.async_add_job(request_configuration, hass, config, host) discovery.async_listen(hass, SERVICE_IKEA_TRADFRI, gateway_discovered) - if not host: - return True - - if host and keys.get(host): - return (yield from _setup_gateway(hass, config, host, - keys[host]['key'], - allow_tradfri_groups)) - else: - hass.async_add_job(request_configuration, hass, config, host) - return True + if host: + yield from gateway_discovered(None, + {'host': host}, + allow_tradfri_groups) + return True @asyncio.coroutine -def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): +def _setup_gateway(hass, hass_config, host, identity, key, + allow_tradfri_groups): """Create a gateway.""" from pytradfri import Gateway, RequestError try: @@ -134,7 +151,7 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): return False try: - factory = APIFactory(host, psk_id=GATEWAY_IDENTITY, psk=key, + factory = APIFactory(host, psk_id=identity, psk=key, loop=hass.loop) api = factory.request gateway = Gateway() @@ -163,22 +180,3 @@ def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): hass.async_add_job(discovery.async_load_platform( hass, 'sensor', DOMAIN, {'gateway': gateway_id}, hass_config)) return True - - -def _read_config(hass): - """Read tradfri config.""" - path = hass.config.path(CONFIG_FILE) - - if not os.path.isfile(path): - return {} - - with open(path) as f_handle: - # Guard against empty file - return json.loads(f_handle.read() or '{}') - - -def _write_config(hass, config): - """Write tradfri config.""" - data = json.dumps(config) - with open(hass.config.path(CONFIG_FILE), 'w', encoding='utf-8') as outfile: - outfile.write(data) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 9f36b2fb78f..59090b98e94 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -286,10 +286,11 @@ class SpeechManager(object): options = options or provider.default_options if options is not None: invalid_opts = [opt_name for opt_name in options.keys() - if opt_name not in provider.supported_options] + if opt_name not in (provider.supported_options or + [])] if invalid_opts: raise HomeAssistantError( - "Invalid options found: %s", invalid_opts) + "Invalid options found: {}".format(invalid_opts)) options_key = ctypes.c_size_t(hash(frozenset(options))).value else: options_key = '-' diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 3ddcc5c716a..e405e5be531 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -9,14 +9,15 @@ import logging import re import aiohttp +from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout import voluptuous as vol import yarl -from homeassistant.components.tts import Provider, PLATFORM_SCHEMA, CONF_LANG +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ["gTTS-token==1.1.1"] +REQUIREMENTS = ['gTTS-token==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -52,10 +53,10 @@ class GoogleProvider(Provider): self.hass = hass self._lang = lang self.headers = { - 'Referer': "http://translate.google.com/", - 'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36") + REFERER: "http://translate.google.com/", + USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/47.0.2526.106 Safari/537.36"), } self.name = 'Google' @@ -86,7 +87,7 @@ class GoogleProvider(Provider): url_param = { 'ie': 'UTF-8', 'tl': language, - 'q': yarl.quote(part, strict=False), + 'q': yarl.quote(part), 'tk': part_token, 'total': len(message_parts), 'idx': idx, diff --git a/homeassistant/components/tts/yandextts.py b/homeassistant/components/tts/yandextts.py index 05daad55412..b5e965a5b50 100644 --- a/homeassistant/components/tts/yandextts.py +++ b/homeassistant/components/tts/yandextts.py @@ -63,6 +63,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Range(min=MIN_SPEED, max=MAX_SPEED) }) +SUPPORTED_OPTIONS = [ + CONF_CODEC, + CONF_VOICE, + CONF_EMOTION, + CONF_SPEED, +] + @asyncio.coroutine def async_get_engine(hass, config): @@ -94,11 +101,17 @@ class YandexSpeechKitProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES + @property + def supported_options(self): + """Return list of supported options.""" + return SUPPORTED_OPTIONS + @asyncio.coroutine def async_get_tts_audio(self, message, language, options=None): """Load TTS from yandex.""" websession = async_get_clientsession(self.hass) actual_language = language + options = options or {} try: with async_timeout.timeout(10, loop=self.hass.loop): @@ -106,10 +119,10 @@ class YandexSpeechKitProvider(Provider): 'text': message, 'lang': actual_language, 'key': self._key, - 'speaker': self._speaker, - 'format': self._codec, - 'emotion': self._emotion, - 'speed': self._speed + 'speaker': options.get(CONF_VOICE, self._speaker), + 'format': options.get(CONF_CODEC, self._codec), + 'emotion': options.get(CONF_EMOTION, self._emotion), + 'speed': options.get(CONF_SPEED, self._speed) } request = yield from websession.get( diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py new file mode 100644 index 00000000000..e1c4a5952af --- /dev/null +++ b/homeassistant/components/vacuum/neato.py @@ -0,0 +1,214 @@ +""" +Support for Neato Connected Vaccums. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/vacuum.neato/ +""" +import logging + +import requests + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.vacuum import ( + VacuumDevice, SUPPORT_BATTERY, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, + SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON) +from homeassistant.components.neato import ( + NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['neato'] + +SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ + SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ + SUPPORT_STATUS | SUPPORT_MAP + +ICON = "mdi:roomba" + +ATTR_CLEAN_START = 'clean_start' +ATTR_CLEAN_STOP = 'clean_stop' +ATTR_CLEAN_AREA = 'clean_area' +ATTR_CLEAN_BATTERY_START = 'battery_level_at_clean_start' +ATTR_CLEAN_BATTERY_END = 'battery_level_at_clean_end' +ATTR_CLEAN_SUSP_COUNT = 'clean_suspension_count' +ATTR_CLEAN_SUSP_TIME = 'clean_suspension_time' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Neato vacuum.""" + dev = [] + for robot in hass.data[NEATO_ROBOTS]: + dev.append(NeatoConnectedVacuum(hass, robot)) + _LOGGER.debug("Adding vacuums %s", dev) + add_devices(dev, True) + + +class NeatoConnectedVacuum(VacuumDevice): + """Neato Connected Vacuums.""" + + def __init__(self, hass, robot): + """Initialize the Neato Connected Vacuums.""" + self.robot = robot + self.neato = hass.data[NEATO_LOGIN] + self._name = '{}'.format(self.robot.name) + self._status_state = None + self._clean_state = None + self._state = None + self._mapdata = hass.data[NEATO_MAP_DATA] + self.clean_time_start = None + self.clean_time_stop = None + self.clean_area = None + self.clean_battery_start = None + self.clean_battery_end = None + self.clean_suspension_charge_count = None + self.clean_suspension_time = None + + def update(self): + """Update the states of Neato Vacuums.""" + _LOGGER.debug("Running Vacuums update") + self.neato.update_robots() + try: + self._state = self.robot.state + except (requests.exceptions.ConnectionError, + requests.exceptions.HTTPError) as ex: + _LOGGER.warning("Neato connection error: %s", ex) + self._state = None + return + _LOGGER.debug('self._state=%s', self._state) + if self._state['state'] == 1: + if self._state['details']['isCharging']: + self._status_state = 'Charging' + elif (self._state['details']['isDocked'] and + not self._state['details']['isCharging']): + self._status_state = 'Docked' + else: + self._status_state = 'Stopped' + elif self._state['state'] == 2: + if ALERTS.get(self._state['error']) is None: + self._status_state = ( + MODE.get(self._state['cleaning']['mode']) + + ' ' + ACTION.get(self._state['action'])) + else: + self._status_state = ALERTS.get(self._state['error']) + elif self._state['state'] == 3: + self._status_state = 'Paused' + elif self._state['state'] == 4: + self._status_state = ERRORS.get(self._state['error']) + + if (self.robot.state['action'] == 1 or + self.robot.state['action'] == 2 or + self.robot.state['action'] == 3 and + self.robot.state['state'] == 2): + self._clean_state = STATE_ON + else: + self._clean_state = STATE_OFF + + if not self._mapdata.get(self.robot.serial, {}).get('maps', []): + return + self.clean_time_start = ( + (self._mapdata[self.robot.serial]['maps'][0]['start_at'] + .strip('Z')) + .replace('T', ' ')) + self.clean_time_stop = ( + (self._mapdata[self.robot.serial]['maps'][0]['end_at'].strip('Z')) + .replace('T', ' ')) + self.clean_area = ( + self._mapdata[self.robot.serial]['maps'][0]['cleaned_area']) + self.clean_suspension_charge_count = ( + self._mapdata[self.robot.serial]['maps'][0] + ['suspended_cleaning_charging_count']) + self.clean_suspension_time = ( + self._mapdata[self.robot.serial]['maps'][0] + ['time_in_suspended_cleaning']) + self.clean_battery_start = ( + self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_start']) + self.clean_battery_end = ( + self._mapdata[self.robot.serial]['maps'][0]['run_charge_at_end']) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def icon(self): + """Return the icon to use for device.""" + return ICON + + @property + def supported_features(self): + """Flag vacuum cleaner robot features that are supported.""" + return SUPPORT_NEATO + + @property + def battery_level(self): + """Return the battery level of the vacuum cleaner.""" + return self._state['details']['charge'] + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return self._status_state + + @property + def state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} + + if self.status is not None: + data[ATTR_STATUS] = self.status + + if self.battery_level is not None: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon + + if self.clean_time_start is not None: + data[ATTR_CLEAN_START] = self.clean_time_start + if self.clean_time_stop is not None: + data[ATTR_CLEAN_STOP] = self.clean_time_stop + if self.clean_area is not None: + data[ATTR_CLEAN_AREA] = self.clean_area + if self.clean_suspension_charge_count is not None: + data[ATTR_CLEAN_SUSP_COUNT] = ( + self.clean_suspension_charge_count) + if self.clean_suspension_time is not None: + data[ATTR_CLEAN_SUSP_TIME] = self.clean_suspension_time + if self.clean_battery_start is not None: + data[ATTR_CLEAN_BATTERY_START] = self.clean_battery_start + if self.clean_battery_end is not None: + data[ATTR_CLEAN_BATTERY_END] = self.clean_battery_end + + return data + + def turn_on(self, **kwargs): + """Turn the vacuum on and start cleaning.""" + self.robot.start_cleaning() + + @property + def is_on(self): + """Return true if switch is on.""" + return self._clean_state == STATE_ON + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.robot.pause_cleaning() + self.robot.send_to_base() + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + self.robot.send_to_base() + + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + self.robot.stop_cleaning() + + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" + if self._state['state'] == 1: + self.robot.start_cleaning() + elif self._state['state'] == 2 and\ + ALERTS.get(self._state['error']) is None: + self.robot.pause_cleaning() + if self._state['state'] == 3: + self.robot.resume_cleaning() diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index ed19e220008..829d0878ffe 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.0'] +REQUIREMENTS = ['python-miio==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 3ed6efc25d7..94f712896cc 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -27,6 +27,7 @@ ATTR_DEVICE_SERIAL = 'device_serial' CONF_ALARM = 'alarm' CONF_CODE_DIGITS = 'code_digits' CONF_DOOR_WINDOW = 'door_window' +CONF_GIID = 'giid' CONF_HYDROMETERS = 'hygrometers' CONF_LOCKS = 'locks' CONF_MOUSE = 'mouse' @@ -47,6 +48,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_ALARM, default=True): cv.boolean, vol.Optional(CONF_CODE_DIGITS, default=4): cv.positive_int, vol.Optional(CONF_DOOR_WINDOW, default=True): cv.boolean, + vol.Optional(CONF_GIID): cv.string, vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, vol.Optional(CONF_LOCKS, default=True): cv.boolean, vol.Optional(CONF_MOUSE, default=True): cv.boolean, @@ -110,6 +112,8 @@ class VerisureHub(object): domain_config[CONF_USERNAME], domain_config[CONF_PASSWORD]) + self.giid = domain_config.get(CONF_GIID) + import jsonpath self.jsonpath = jsonpath.jsonpath @@ -120,6 +124,8 @@ class VerisureHub(object): except self._verisure.Error as ex: _LOGGER.error('Could not log in to verisure, %s', ex) return False + if self.giid: + return self.set_giid() return True def logout(self): @@ -131,6 +137,15 @@ class VerisureHub(object): return False return True + def set_giid(self): + """Set installation GIID.""" + try: + self.session.set_giid(self.giid) + except self._verisure.Error as ex: + _LOGGER.error('Could not set installation GIID, %s', ex) + return False + return True + @Throttle(timedelta(seconds=60)) def update_overview(self): """Update the overview.""" diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 9c8366e7f7e..4cee6ea2139 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -22,13 +22,14 @@ DOMAIN = 'volvooncall' DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.3.3'] +REQUIREMENTS = ['volvooncall==0.4.0'] _LOGGER = logging.getLogger(__name__) CONF_UPDATE_INTERVAL = 'update_interval' MIN_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) +CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) @@ -58,6 +59,7 @@ CONFIG_SCHEMA = vol.Schema({ {cv.slug: cv.string}), vol.Optional(CONF_RESOURCES): vol.All( cv.ensure_list, [vol.In(RESOURCES)]), + vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, }), }, extra=vol.ALLOW_EXTRA) @@ -65,11 +67,12 @@ CONFIG_SCHEMA = vol.Schema({ def setup(hass, config): """Set up the Volvo On Call component.""" - from volvooncall import Connection, DEFAULT_SERVICE_URL + from volvooncall import Connection connection = Connection( config[DOMAIN].get(CONF_USERNAME), config[DOMAIN].get(CONF_PASSWORD), - config[DOMAIN].get(CONF_SERVICE_URL, DEFAULT_SERVICE_URL)) + config[DOMAIN].get(CONF_SERVICE_URL), + config[DOMAIN].get(CONF_REGION)) interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) diff --git a/homeassistant/components/vultr.py b/homeassistant/components/vultr.py new file mode 100644 index 00000000000..59fc707bb28 --- /dev/null +++ b/homeassistant/components/vultr.py @@ -0,0 +1,105 @@ +""" +Support for Vultr. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/vultr/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['vultr==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_BACKUPS = 'auto_backups' +ATTR_ALLOWED_BANDWIDTH = 'allowed_bandwidth_gb' +ATTR_COST_PER_MONTH = 'cost_per_month' +ATTR_CURRENT_BANDWIDTH_USED = 'current_bandwidth_gb' +ATTR_CREATED_AT = 'created_at' +ATTR_DISK = 'disk' +ATTR_SUBSCRIPTION_ID = 'subid' +ATTR_SUBSCRIPTION_NAME = 'label' +ATTR_IPV4_ADDRESS = 'ipv4_address' +ATTR_IPV6_ADDRESS = 'ipv6_address' +ATTR_MEMORY = 'memory' +ATTR_OS = 'os' +ATTR_PENDING_CHARGES = 'pending_charges' +ATTR_REGION = 'region' +ATTR_VCPUS = 'vcpus' + +CONF_SUBSCRIPTION = 'subscription' + +DATA_VULTR = 'data_vultr' +DOMAIN = 'vultr' + +NOTIFICATION_ID = 'vultr_notification' +NOTIFICATION_TITLE = 'Vultr Setup' + +VULTR_PLATFORMS = ['binary_sensor', 'sensor', 'switch'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Vultr component.""" + api_key = config[DOMAIN].get(CONF_API_KEY) + + vultr = Vultr(api_key) + + try: + vultr.update() + except RuntimeError as ex: + _LOGGER.error("Failed to make update API request because: %s", + ex) + hass.components.persistent_notification.create( + 'Error: {}' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + hass.data[DATA_VULTR] = vultr + return True + + +class Vultr(object): + """Handle all communication with the Vultr API.""" + + def __init__(self, api_key): + """Initialize the Vultr connection.""" + from vultr import Vultr as VultrAPI + + self._api_key = api_key + self.data = None + self.api = VultrAPI(self._api_key) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Use the data from Vultr API.""" + self.data = self.api.server_list() + + def _force_update(self): + """Use the data from Vultr API.""" + self.data = self.api.server_list() + + def halt(self, subscription): + """Halt a subscription (hard power off).""" + self.api.server_halt(subscription) + self._force_update() + + def start(self, subscription): + """Start a subscription.""" + self.api.server_start(subscription) + self._force_update() diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 9e927da893e..acb95c17814 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -6,11 +6,10 @@ https://home-assistant.io/components/weather/ """ import asyncio import logging -from numbers import Number -from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.const import PRECISION_WHOLE, PRECISION_TENTHS, TEMP_CELSIUS from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity @@ -98,11 +97,19 @@ class WeatherEntity(Entity): """Return the forecast.""" return None + @property + def precision(self): + """Return the forecast.""" + return PRECISION_TENTHS if self.temperature_unit == TEMP_CELSIUS \ + else PRECISION_WHOLE + @property def state_attributes(self): """Return the state attributes.""" data = { - ATTR_WEATHER_TEMPERATURE: self._temp_for_display(self.temperature), + ATTR_WEATHER_TEMPERATURE: show_temp( + self.hass, self.temperature, self.temperature_unit, + self.precision), ATTR_WEATHER_HUMIDITY: self.humidity, } @@ -134,8 +141,9 @@ class WeatherEntity(Entity): forecast = [] for forecast_entry in self.forecast: forecast_entry = dict(forecast_entry) - forecast_entry[ATTR_FORECAST_TEMP] = self._temp_for_display( - forecast_entry[ATTR_FORECAST_TEMP]) + forecast_entry[ATTR_FORECAST_TEMP] = show_temp( + self.hass, forecast_entry[ATTR_FORECAST_TEMP], + self.temperature_unit, self.precision) forecast.append(forecast_entry) data[ATTR_FORECAST] = forecast @@ -151,19 +159,3 @@ class WeatherEntity(Entity): def condition(self): """Return the current condition.""" raise NotImplementedError() - - def _temp_for_display(self, temp): - """Convert temperature into preferred units for display purposes.""" - unit = self.temperature_unit - hass_unit = self.hass.config.units.temperature_unit - - if (temp is None or not isinstance(temp, Number) or - unit == hass_unit): - return temp - - value = convert_temperature(temp, unit, hass_unit) - - if hass_unit == TEMP_CELSIUS: - return round(value, 1) - # Users of fahrenheit generally expect integer units. - return round(value) diff --git a/homeassistant/components/weather/demo.py b/homeassistant/components/weather/demo.py index 0a404447346..02e07996213 100644 --- a/homeassistant/components/weather/demo.py +++ b/homeassistant/components/weather/demo.py @@ -31,7 +31,7 @@ CONDITION_CLASSES = { def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo weather.""" add_devices([ - DemoWeather('South', 'Sunshine', 21, 92, 1099, 0.5, TEMP_CELSIUS, + DemoWeather('South', 'Sunshine', 21.6414, 92, 1099, 0.5, TEMP_CELSIUS, [22, 19, 15, 12, 14, 18, 21]), DemoWeather('North', 'Shower rain', -12, 54, 987, 4.8, TEMP_FAHRENHEIT, [-10, -13, -18, -23, -19, -14, -9]) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 12dc73af5cd..514eda0f09f 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -15,7 +15,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN) -REQUIREMENTS = ["yahooweather==0.8"] +REQUIREMENTS = ["yahooweather==0.9"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index e9f567c04d3..a1fb0ca9cac 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -202,15 +202,16 @@ class WebsocketAPIView(HomeAssistantView): def get(self, request): """Handle an incoming websocket connection.""" # pylint: disable=no-self-use - return ActiveConnection(request.app['hass']).handle(request) + return ActiveConnection(request.app['hass'], request).handle() class ActiveConnection: """Handle an active websocket client connection.""" - def __init__(self, hass): + def __init__(self, hass, request): """Initialize an active connection.""" self.hass = hass + self.request = request self.wsock = None self.event_listeners = {} self.to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG, loop=hass.loop) @@ -259,8 +260,9 @@ class ActiveConnection: self._writer_task.cancel() @asyncio.coroutine - def handle(self, request): + def handle(self): """Handle the websocket connection.""" + request = self.request wsock = self.wsock = web.WebSocketResponse() yield from wsock.prepare(request) self.debug("Connected") @@ -350,7 +352,7 @@ class ActiveConnection: if wsock.closed: self.debug("Connection closed by client") else: - self.log_error("Unexpected TypeError", msg) + _LOGGER.exception("Unexpected TypeError: %s", msg) except ValueError as err: msg = "Received invalid JSON" @@ -483,9 +485,14 @@ class ActiveConnection: Async friendly. """ msg = GET_PANELS_MESSAGE_SCHEMA(msg) + panels = { + panel: + self.hass.data[frontend.DATA_PANELS][panel].to_response( + self.hass, self.request) + for panel in self.hass.data[frontend.DATA_PANELS]} self.to_write.put_nowait(result_message( - msg['id'], self.hass.data[frontend.DATA_PANELS])) + msg['id'], panels)) def handle_ping(self, msg): """Handle ping command. diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 0e6e41c63a5..2faeccde154 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -123,6 +123,17 @@ SET_WAKEUP_SCHEMA = vol.Schema({ vol.All(vol.Coerce(int), cv.positive_int), }) +HEAL_NODE_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Optional(const.ATTR_RETURN_ROUTES, default=False): cv.boolean, +}) + +TEST_NODE_SCHEMA = vol.Schema({ + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), + vol.Optional(const.ATTR_MESSAGES, default=1): cv.positive_int, +}) + + DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_POLLING_INTENSITY): cv.positive_int, vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean, @@ -564,6 +575,22 @@ def setup(hass, config): _LOGGER.info("Node %s on instance %s does not have resettable " "meters.", node_id, instance) + def heal_node(service): + """Heal a node on the network.""" + node_id = service.data.get(const.ATTR_NODE_ID) + update_return_routes = service.data.get(const.ATTR_RETURN_ROUTES) + node = network.nodes[node_id] + _LOGGER.info("Z-Wave node heal running for node %s", node_id) + node.heal(update_return_routes) + + def test_node(service): + """Send test messages to a node on the network.""" + node_id = service.data.get(const.ATTR_NODE_ID) + messages = service.data.get(const.ATTR_MESSAGES) + node = network.nodes[node_id] + _LOGGER.info("Sending %s test-messages to node %s.", messages, node_id) + node.test(messages) + def start_zwave(_service_or_event): """Startup Z-Wave network.""" _LOGGER.info("Starting Z-Wave network...") @@ -684,6 +711,16 @@ def setup(hass, config): set_poll_intensity, descriptions[const.SERVICE_SET_POLL_INTENSITY], schema=SET_POLL_INTENSITY_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_HEAL_NODE, + heal_node, + descriptions[ + const.SERVICE_HEAL_NODE], + schema=HEAL_NODE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_TEST_NODE, + test_node, + descriptions[ + const.SERVICE_TEST_NODE], + schema=TEST_NODE_SCHEMA) # Setup autoheal if autoheal: diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index dced1689dba..5f0a7f4750b 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -8,7 +8,9 @@ ATTR_INSTANCE = "instance" ATTR_GROUP = "group" ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" +ATTR_MESSAGES = "messages" ATTR_NAME = "name" +ATTR_RETURN_ROUTES = "return_routes" ATTR_SCENE_ID = "scene_id" ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" @@ -32,7 +34,9 @@ SERVICE_ADD_NODE_SECURE = "add_node_secure" SERVICE_REMOVE_NODE = "remove_node" SERVICE_CANCEL_COMMAND = "cancel_command" SERVICE_HEAL_NETWORK = "heal_network" +SERVICE_HEAL_NODE = "heal_node" SERVICE_SOFT_RESET = "soft_reset" +SERVICE_TEST_NODE = "test_node" SERVICE_TEST_NETWORK = "test_network" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 44a30cdc529..04446cff9a1 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -69,11 +69,6 @@ class ZWaveBaseEntity(Entity): self.hass.loop.call_later(0.1, do_update) -def sub_status(status, stage): - """Format sub-status.""" - return '{} ({})'.format(status, stage) if stage else status - - class ZWaveNodeEntity(ZWaveBaseEntity): """Representation of a Z-Wave node.""" @@ -201,17 +196,17 @@ class ZWaveNodeEntity(ZWaveBaseEntity): """Return the state.""" if ATTR_READY not in self._attributes: return None - stage = '' - if not self._attributes[ATTR_READY]: - # If node is not ready use stage as sub-status. - stage = self._attributes[ATTR_QUERY_STAGE] + if self._attributes[ATTR_FAILED]: - return sub_status('Dead', stage) + return 'dead' + if self._attributes[ATTR_QUERY_STAGE] != 'Complete': + return 'initializing' if not self._attributes[ATTR_AWAKE]: - return sub_status('Sleeping', stage) + return 'sleeping' if self._attributes[ATTR_READY]: - return sub_status('Ready', stage) - return stage + return 'ready' + + return None @property def should_poll(self): diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 06e317333be..ba8e177c9f7 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -28,6 +28,17 @@ cancel_command: heal_network: description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW.log for progress. + fields: + return_routes: + description: Wheter or not to update the return routes from the nodes to the controller. Defaults to False. + example: True + +heal_node: + description: Start a Z-Wave node heal. Refer to OZW.log for progress. + fields: + return_routes: + description: Wheter or not to update the return routes from the node to the controller. Defaults to False. + example: True remove_node: description: Remove a node from the Z-Wave network. Refer to OZW.log for progress. @@ -120,6 +131,16 @@ soft_reset: test_network: description: This will send test to nodes in the Z-Wave network. This will greatly slow down the Z-Wave network while it is being processed. Refer to OZW.log for progress. +test_node: + description: This will send test messages to a node in the Z-Wave network. This could bring back dead nodes. + fields: + node_id: + description: ID of the node to send test messages to. + example: 10 + messages: + description: Optional. Amount of test messages to send. + example: 3 + rename_node: description: Set the name of a node. This will also affect the IDs of all entities in the node. fields: diff --git a/homeassistant/const.py b/homeassistant/const.py index fc471c6323d..d8b4dfcb044 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,45 +1,15 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 57 -PATCH_VERSION = '3' +MINOR_VERSION = 58 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) REQUIRED_PYTHON_VER_WIN = (3, 5, 2) CONSTRAINT_FILE = 'package_constraints.txt' -PROJECT_NAME = 'Home Assistant' -PROJECT_PACKAGE_NAME = 'homeassistant' -PROJECT_LICENSE = 'Apache License 2.0' -PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = ' 2013, {}'.format(PROJECT_AUTHOR) -PROJECT_URL = 'https://home-assistant.io/' -PROJECT_EMAIL = 'hello@home-assistant.io' -PROJECT_DESCRIPTION = ('Open-source home automation platform ' - 'running on Python 3.') -PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' - 'home automation platform running on Python 3. ' - 'Track and control all devices at home and ' - 'automate control. ' - 'Installation in less than a minute.') -PROJECT_CLASSIFIERS = [ - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', - 'Topic :: Home Automation' -] - -PROJECT_GITHUB_USERNAME = 'home-assistant' -PROJECT_GITHUB_REPOSITORY = 'home-assistant' - -PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) -GITHUB_PATH = '{}/{}'.format(PROJECT_GITHUB_USERNAME, - PROJECT_GITHUB_REPOSITORY) -GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) - +# Format for platforms PLATFORM_FORMAT = '{}.{}' # Can be used to specify a catch all when registering state or event listeners. @@ -48,8 +18,7 @@ MATCH_ALL = '*' # If no name is specified DEVICE_DEFAULT_NAME = 'Unnamed Device' -WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] - +# Sun events SUN_EVENT_SUNSET = 'sunset' SUN_EVENT_SUNRISE = 'sunrise' @@ -157,6 +126,7 @@ CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' CONF_SSL = 'ssl' CONF_STATE = 'state' +CONF_STATE_TEMPLATE = 'state_template' CONF_STRUCTURE = 'structure' CONF_SWITCHES = 'switches' CONF_TEMPERATURE_UNIT = 'temperature_unit' @@ -429,24 +399,7 @@ HTTP_BASIC_AUTHENTICATION = 'basic' HTTP_DIGEST_AUTHENTICATION = 'digest' HTTP_HEADER_HA_AUTH = 'X-HA-access' -HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding' -HTTP_HEADER_AUTH = 'Authorization' -HTTP_HEADER_USER_AGENT = 'User-Agent' -HTTP_HEADER_CONTENT_TYPE = 'Content-type' -HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding' -HTTP_HEADER_VARY = 'Vary' -HTTP_HEADER_CONTENT_LENGTH = 'Content-Length' -HTTP_HEADER_CACHE_CONTROL = 'Cache-Control' -HTTP_HEADER_EXPIRES = 'Expires' -HTTP_HEADER_ORIGIN = 'Origin' HTTP_HEADER_X_REQUESTED_WITH = 'X-Requested-With' -HTTP_HEADER_ACCEPT = 'Accept' -HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin' -HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers' - -ALLOWED_CORS_HEADERS = [HTTP_HEADER_ORIGIN, HTTP_HEADER_ACCEPT, - HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}' @@ -463,3 +416,10 @@ VOLUME = 'volume' # type: str TEMPERATURE = 'temperature' # type: str SPEED_MS = 'speed_ms' # type: str ILLUMINANCE = 'illuminance' # type: str + +WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] + +# The degree of precision for platforms +PRECISION_WHOLE = 1 +PRECISION_HALVES = 0.5 +PRECISION_TENTHS = 0.1 diff --git a/homeassistant/core.py b/homeassistant/core.py index 31bb281aeaa..30be92af153 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -387,7 +387,7 @@ class EventBus(object): @callback def async_fire(self, event_type: str, event_data=None, - origin=EventOrigin.local, wait=False): + origin=EventOrigin.local): """Fire an event. This method must be run in the event loop. @@ -395,8 +395,10 @@ class EventBus(object): listeners = self._listeners.get(event_type, []) # EVENT_HOMEASSISTANT_CLOSE should go only to his listeners - if event_type != EVENT_HOMEASSISTANT_CLOSE: - listeners = self._listeners.get(MATCH_ALL, []) + listeners + match_all_listeners = self._listeners.get(MATCH_ALL) + if (match_all_listeners is not None and + event_type != EVENT_HOMEASSISTANT_CLOSE): + listeners = match_all_listeners + listeners event = Event(event_type, event_data, origin) @@ -673,15 +675,6 @@ class StateMachine(object): state_obj = self.get(entity_id) return state_obj is not None and state_obj.state == state - def is_state_attr(self, entity_id, name, value): - """Test if entity exists and has a state attribute set to value. - - Async friendly. - """ - state_obj = self.get(entity_id) - return state_obj is not None and \ - state_obj.attributes.get(name, None) == value - def remove(self, entity_id): """Remove the state of an entity. diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e5512b9140e..e5d0a34f76e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -12,8 +12,7 @@ import voluptuous as vol from homeassistant.loader import get_platform from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, CONF_PLATFORM, - CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, + CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) @@ -563,16 +562,3 @@ SCRIPT_SCHEMA = vol.All( [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA)], ) - -FILTER_SCHEMA = vol.Schema({ - vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): - vol.All(ensure_list, [string]) - }), - vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): - vol.All(ensure_list, [string]) - }) -}) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index da82fc9202f..78db0890ab1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -11,7 +11,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, ATTR_DEVICE_CLASS) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.config import DATA_CUSTOMIZE from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.util import ensure_unique_string, slugify @@ -41,6 +41,7 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], entity_id_format.format(slugify(name)), current_ids) +@callback def async_generate_entity_id(entity_id_format: str, name: Optional[str], current_ids: Optional[List[str]]=None, hass: Optional[HomeAssistant]=None) -> str: @@ -168,15 +169,9 @@ class Entity(object): def update(self): """Retrieve latest state. - When not implemented, will forward call to async version if available. + For asyncio use coroutine async_update. """ - async_update = getattr(self, 'async_update', None) - - if async_update is None: - return - - # pylint: disable=not-callable - run_coroutine_threadsafe(async_update(), self.hass.loop).result() + pass # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they @@ -240,10 +235,10 @@ class Entity(object): if not self._slow_reported and end - start > 0.4: self._slow_reported = True - _LOGGER.warning("Updating state for %s took %.3f seconds. " + _LOGGER.warning("Updating state for %s (%s) took %.3f seconds. " "Please report platform to the developers at " "https://goo.gl/Nvioub", self.entity_id, - end - start) + type(self), end - start) # Overwrite properties that have been set in the config file. if DATA_CUSTOMIZE in self.hass.data: @@ -277,10 +272,12 @@ class Entity(object): """ self.hass.add_job(self.async_update_ha_state(force_refresh)) + @callback def async_schedule_update_ha_state(self, force_refresh=False): """Schedule a update ha state change task.""" self.hass.async_add_job(self.async_update_ha_state(force_refresh)) + @asyncio.coroutine def async_device_update(self, warning=True): """Process 'update' or 'async_update' from entity. diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e805f277483..9b25b8ddbd4 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -97,6 +97,7 @@ class EntityComponent(object): expand_group ).result() + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8d3f1c9325..f78c70e57d3 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,6 +1,30 @@ """Helper class to implement include/exclude of entities and domains.""" +import voluptuous as vol + from homeassistant.core import split_entity_id +from homeassistant.helpers import config_validation as cv + +CONF_INCLUDE_DOMAINS = 'include_domains' +CONF_INCLUDE_ENTITIES = 'include_entities' +CONF_EXCLUDE_DOMAINS = 'exclude_domains' +CONF_EXCLUDE_ENTITIES = 'exclude_entities' + +FILTER_SCHEMA = vol.All( + vol.Schema({ + vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_INCLUDE_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids, + }), + lambda config: generate_filter( + config[CONF_INCLUDE_DOMAINS], + config[CONF_INCLUDE_ENTITIES], + config[CONF_EXCLUDE_DOMAINS], + config[CONF_EXCLUDE_ENTITIES], + )) def generate_filter(include_domains, include_entities, diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py new file mode 100644 index 00000000000..a4626c33210 --- /dev/null +++ b/homeassistant/helpers/temperature.py @@ -0,0 +1,33 @@ +"""Temperature helpers for Home Assistant.""" +from numbers import Number + +from homeassistant.core import HomeAssistant +from homeassistant.util.temperature import convert as convert_temperature + + +def display_temp(hass: HomeAssistant, temperature: float, unit: str, + precision: float) -> float: + """Convert temperature into preferred units for display purposes.""" + temperature_unit = unit + ha_unit = hass.config.units.temperature_unit + + if temperature is None: + return temperature + + # If the temperature is not a number this can cause issues + # with Polymer components, so bail early there. + if not isinstance(temperature, Number): + raise TypeError( + "Temperature is not a number: {}".format(temperature)) + + if temperature_unit != ha_unit: + temperature = convert_temperature( + temperature, temperature_unit, ha_unit) + + # Round in the units appropriate + if precision == 0.5: + return round(temperature * 2) / 2.0 + elif precision == 0.1: + return round(temperature, 1) + # Integer as a fall back (PRECISION_WHOLE) + return round(temperature) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6f83688623a..bf1b88e1c3f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -107,7 +107,8 @@ class Template(object): This method must be run in the event loop. """ - self._ensure_compiled() + if self._compiled is None: + self._ensure_compiled() if variables is not None: kwargs.update(variables) @@ -135,7 +136,8 @@ class Template(object): This method must be run in the event loop. """ - self._ensure_compiled() + if self._compiled is None: + self._ensure_compiled() variables = { 'value': value @@ -154,20 +156,17 @@ class Template(object): def _ensure_compiled(self): """Bind a template to a specific hass instance.""" - if self._compiled is not None: - return - self.ensure_valid() assert self.hass is not None, 'hass variable not set on template' - location_methods = LocationMethods(self.hass) + template_methods = TemplateMethods(self.hass) global_vars = ENV.make_globals({ - 'closest': location_methods.closest, - 'distance': location_methods.distance, + 'closest': template_methods.closest, + 'distance': template_methods.distance, 'is_state': self.hass.states.is_state, - 'is_state_attr': self.hass.states.is_state_attr, + 'is_state_attr': template_methods.is_state_attr, 'states': AllStates(self.hass), }) @@ -272,11 +271,11 @@ def _wrap_state(state): return None if state is None else TemplateState(state) -class LocationMethods(object): - """Class to expose distance helpers to templates.""" +class TemplateMethods(object): + """Class to expose helpers to templates.""" def __init__(self, hass): - """Initialize the distance helpers.""" + """Initialize the helpers.""" self._hass = hass def closest(self, *args): @@ -390,6 +389,12 @@ class LocationMethods(object): return self._hass.config.units.length( loc_util.distance(*locations[0] + locations[1]), 'm') + def is_state_attr(self, entity_id, name, value): + """Test if a state is a specific attribute.""" + state_obj = self._hass.states.get(entity_id) + return state_obj is not None and \ + state_obj.attributes.get(name) == value + def _resolve_state(self, entity_id_or_state): """Return state or entity_id if given.""" if isinstance(entity_id_or_state, State): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a2dc9572c81..056ed2f3fa6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,8 +5,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.2.5 -yarl==0.13 +aiohttp==2.3.2 +yarl==0.14.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 diff --git a/homeassistant/remote.py b/homeassistant/remote.py index c8fe62f64d9..7d032303548 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -15,22 +15,18 @@ import urllib.parse from typing import Optional +from aiohttp.hdrs import METH_GET, METH_POST, METH_DELETE, CONTENT_TYPE import requests from homeassistant import core as ha from homeassistant.const import ( - HTTP_HEADER_HA_AUTH, SERVER_PORT, URL_API, - URL_API_EVENTS, URL_API_EVENTS_EVENT, URL_API_SERVICES, URL_API_CONFIG, - URL_API_SERVICES_SERVICE, URL_API_STATES, URL_API_STATES_ENTITY, - HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) + URL_API, SERVER_PORT, URL_API_CONFIG, URL_API_EVENTS, URL_API_STATES, + URL_API_SERVICES, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, + URL_API_EVENTS_EVENT, URL_API_STATES_ENTITY, URL_API_SERVICES_SERVICE) from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -METHOD_GET = 'get' -METHOD_POST = 'post' -METHOD_DELETE = 'delete' - class APIStatus(enum.Enum): """Representation of an API status.""" @@ -67,9 +63,7 @@ class API(object): self.base_url += ':{}'.format(port) self.status = None - self._headers = { - HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, - } + self._headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password @@ -89,7 +83,7 @@ class API(object): url = urllib.parse.urljoin(self.base_url, path) try: - if method == METHOD_GET: + if method == METH_GET: return requests.get( url, params=data, timeout=timeout, headers=self._headers) @@ -144,7 +138,7 @@ class JSONEncoder(json.JSONEncoder): def validate_api(api): """Make a call to validate API.""" try: - req = api(METHOD_GET, URL_API) + req = api(METH_GET, URL_API) if req.status_code == 200: return APIStatus.OK @@ -161,7 +155,7 @@ def validate_api(api): def get_event_listeners(api): """List of events that is being listened for.""" try: - req = api(METHOD_GET, URL_API_EVENTS) + req = api(METH_GET, URL_API_EVENTS) return req.json() if req.status_code == 200 else {} @@ -175,7 +169,7 @@ def get_event_listeners(api): def fire_event(api, event_type, data=None): """Fire an event at remote API.""" try: - req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data) + req = api(METH_POST, URL_API_EVENTS_EVENT.format(event_type), data) if req.status_code != 200: _LOGGER.error("Error firing event: %d - %s", @@ -188,7 +182,7 @@ def fire_event(api, event_type, data=None): def get_state(api, entity_id): """Query given API for state of entity_id.""" try: - req = api(METHOD_GET, URL_API_STATES_ENTITY.format(entity_id)) + req = api(METH_GET, URL_API_STATES_ENTITY.format(entity_id)) # req.status_code == 422 if entity does not exist @@ -205,7 +199,7 @@ def get_state(api, entity_id): def get_states(api): """Query given API for all states.""" try: - req = api(METHOD_GET, + req = api(METH_GET, URL_API_STATES) return [ha.State.from_dict(item) for @@ -224,7 +218,7 @@ def remove_state(api, entity_id): Return True if entity is gone (removed/never existed). """ try: - req = api(METHOD_DELETE, URL_API_STATES_ENTITY.format(entity_id)) + req = api(METH_DELETE, URL_API_STATES_ENTITY.format(entity_id)) if req.status_code in (200, 404): return True @@ -250,9 +244,7 @@ def set_state(api, entity_id, new_state, attributes=None, force_update=False): 'force_update': force_update} try: - req = api(METHOD_POST, - URL_API_STATES_ENTITY.format(entity_id), - data) + req = api(METH_POST, URL_API_STATES_ENTITY.format(entity_id), data) if req.status_code not in (200, 201): _LOGGER.error("Error changing state: %d - %s", @@ -280,7 +272,7 @@ def get_services(api): Each dict has a string "domain" and a list of strings "services". """ try: - req = api(METHOD_GET, URL_API_SERVICES) + req = api(METH_GET, URL_API_SERVICES) return req.json() if req.status_code == 200 else {} @@ -294,7 +286,7 @@ def get_services(api): def call_service(api, domain, service, service_data=None, timeout=5): """Call a service at the remote API.""" try: - req = api(METHOD_POST, + req = api(METH_POST, URL_API_SERVICES_SERVICE.format(domain, service), service_data, timeout=timeout) @@ -309,7 +301,7 @@ def call_service(api, domain, service, service_data=None, timeout=5): def get_config(api): """Return configuration.""" try: - req = api(METHOD_GET, URL_API_CONFIG) + req = api(METH_GET, URL_API_CONFIG) if req.status_code != 200: return {} diff --git a/homeassistant/setup.py b/homeassistant/setup.py index a7083d010e6..05a8ee1e2f1 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -1,6 +1,5 @@ """All methods needed to bootstrap a Home Assistant instance.""" import asyncio -import logging import logging.handlers import os from timeit import default_timer as timer @@ -9,13 +8,13 @@ from types import ModuleType from typing import Optional, Dict import homeassistant.config as conf_util -from homeassistant.config import async_notify_setup_error import homeassistant.core as core import homeassistant.loader as loader import homeassistant.util.package as pkg_util -from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.config import async_notify_setup_error from homeassistant.const import ( EVENT_COMPONENT_LOADED, PLATFORM_FORMAT, CONSTRAINT_FILE) +from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index da97ed5662e..48d709bc549 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -78,7 +78,8 @@ def load_yaml(fname: str) -> Union[List, Dict]: def dump(_dict: dict) -> str: """Dump YAML to a string and remove null.""" - return yaml.safe_dump(_dict, default_flow_style=False) \ + return yaml.safe_dump( + _dict, default_flow_style=False, allow_unicode=True) \ .replace(': null\n', ':\n') diff --git a/requirements_all.txt b/requirements_all.txt index fb22149e54d..405bafaf13a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,8 +6,8 @@ pip>=8.0.3 jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 -aiohttp==2.2.5 -yarl==0.13 +aiohttp==2.3.2 +yarl==0.14.0 async_timeout==2.0.0 chardet==3.0.4 astral==1.4 @@ -59,7 +59,7 @@ TwitterAPI==2.4.6 YesssSMS==0.1.1b3 # homeassistant.components.abode -abodepy==0.12.1 +abodepy==0.12.2 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 @@ -96,7 +96,7 @@ anthemav==1.1.8 apcaccess==0.0.13 # homeassistant.components.notify.apns -apns2==0.1.1 +apns2==0.3.0 # homeassistant.components.asterisk_mbox asterisk_mbox==0.4.0 @@ -266,7 +266,7 @@ fixerio==0.1.1 flux_led==0.20 # homeassistant.components.notify.free_mobile -freesms==0.1.1 +freesms==0.1.2 # homeassistant.components.device_tracker.fritz # homeassistant.components.sensor.fritzbox_callmonitor @@ -316,13 +316,13 @@ ha-philipsjs==0.0.1 haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.8 +hbmqtt==0.9.1 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 # homeassistant.components.switch.hikvisioncam -hikvision==1.2 +hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 @@ -331,7 +331,7 @@ hipnotify==1.0.8 holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171105.0 +home-assistant-frontend==20171118.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -559,7 +559,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.4.0 +psutil==5.4.1 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -591,7 +591,7 @@ pyHS100==0.3.0 pyRFXtrx==0.20.1 # homeassistant.components.sensor.tibber -pyTibber==0.1.1 +pyTibber==0.2.1 # homeassistant.components.switch.dlink pyW215==0.6.0 @@ -603,7 +603,7 @@ pyairvisual==1.0.0 pyalarmdotcom==0.3.0 # homeassistant.components.arlo -pyarlo==0.0.7 +pyarlo==0.1.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 @@ -612,7 +612,7 @@ pyasn1-modules==0.1.5 pyasn1==0.3.7 # homeassistant.components.apple_tv -pyatv==0.3.5 +pyatv==0.3.8 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -667,7 +667,7 @@ pyflexit==0.3 pyfttt==0.3 # homeassistant.components.remote.harmony -pyharmony==1.0.16 +pyharmony==1.0.18 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.4 @@ -696,6 +696,9 @@ pykira==0.1.1 # homeassistant.components.sensor.kwb pykwb==0.0.8 +# homeassistant.components.sensor.lacrosse +pylacrosse==0.2.7 + # homeassistant.components.sensor.lastfm pylast==2.0.0 @@ -710,7 +713,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.8 +pylutron-caseta==0.3.0 # homeassistant.components.lutron pylutron==0.1.0 @@ -728,7 +731,7 @@ pymodbus==1.3.1 pymonoprice==0.2 # homeassistant.components.media_player.yamaha_musiccast -pymusiccast==0.1.3 +pymusiccast==0.1.5 # homeassistant.components.cover.myq pymyq==0.0.8 @@ -786,7 +789,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.1 +pysnmp==4.4.2 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner @@ -814,7 +817,7 @@ python-etherscan-api==0.0.1 python-forecastio==1.3.5 # homeassistant.components.gc100 -python-gc100==1.0.1a +python-gc100==1.0.3a # homeassistant.components.sensor.hp_ilo python-hpilo==3.9 @@ -833,7 +836,7 @@ python-juicenet==0.0.5 # homeassistant.components.light.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.0 +python-miio==0.3.1 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -890,6 +893,9 @@ pythonegardia==1.0.22 # homeassistant.components.sensor.whois pythonwhois==2.4.3 +# homeassistant.components.device_tracker.tile +pytile==1.0.0 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 @@ -912,7 +918,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.1.0 +pywebpush==1.3.0 # homeassistant.components.wemo pywemo==0.4.20 @@ -945,7 +951,7 @@ restrictedpython==4.0b2 rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.6 +ring_doorbell==0.1.7 # homeassistant.components.notify.rocketchat rocketchat-API==0.6.1 @@ -991,7 +997,7 @@ sharp_aquos_rc==0.3.2 shodan==1.7.5 # homeassistant.components.notify.simplepush -simplepush==1.1.3 +simplepush==1.1.4 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 @@ -1016,7 +1022,7 @@ sleepyq==0.6 # smbus-cffi==0.5.1 # homeassistant.components.media_player.snapcast -snapcast==2.0.7 +snapcast==2.0.8 # homeassistant.components.climate.honeywell somecomfort==0.4.1 @@ -1026,7 +1032,7 @@ speedtest-cli==1.0.7 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.14 +sqlalchemy==1.1.15 # homeassistant.components.statsd statsd==3.2.1 @@ -1072,7 +1078,7 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.12 +total_connect_client==0.13 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission @@ -1091,7 +1097,7 @@ upsmychoice==1.0.6 uvcclient==0.10.1 # homeassistant.components.volvooncall -volvooncall==0.3.3 +volvooncall==0.4.0 # homeassistant.components.verisure vsure==1.3.7 @@ -1099,6 +1105,9 @@ vsure==1.3.7 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 +# homeassistant.components.vultr +vultr==0.1.2 + # homeassistant.components.wake_on_lan # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv @@ -1126,7 +1135,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.16 +xknx==0.7.18 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data @@ -1139,7 +1148,7 @@ yahoo-finance==1.4.0 # homeassistant.components.sensor.yweather # homeassistant.components.weather.yweather -yahooweather==0.8 +yahooweather==0.9 # homeassistant.components.light.yeelight yeelight==0.3.3 @@ -1148,7 +1157,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.10.29 +youtube_dl==2017.11.15 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test.txt b/requirements_test.txt index 1aa909bc9bb..3edfa168f79 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.540 +mypy==0.550 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb26016687d..c9ea20494d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.540 +mypy==0.550 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 @@ -34,7 +34,7 @@ aioautomatic==0.6.4 aiohttp_cors==0.5.3 # homeassistant.components.notify.apns -apns2==0.1.1 +apns2==0.3.0 # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.1.1 @@ -68,13 +68,13 @@ ha-ffmpeg==1.9 haversine==0.4.5 # homeassistant.components.mqtt.server -hbmqtt==0.8 +hbmqtt==0.9.1 # homeassistant.components.binary_sensor.workday holidays==0.8.1 # homeassistant.components.frontend -home-assistant-frontend==20171105.0 +home-assistant-frontend==20171118.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -134,7 +134,7 @@ pythonwhois==2.4.3 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.1.0 +pywebpush==1.3.0 # homeassistant.components.python_script restrictedpython==4.0b2 @@ -143,7 +143,7 @@ restrictedpython==4.0b2 rflink==0.0.34 # homeassistant.components.ring -ring_doorbell==0.1.6 +ring_doorbell==0.1.7 # homeassistant.components.media_player.yamaha rxv==0.5.1 @@ -156,7 +156,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.14 +sqlalchemy==1.1.15 # homeassistant.components.statsd statsd==3.2.1 @@ -164,6 +164,9 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.1 +# homeassistant.components.vultr +vultr==0.1.2 + # homeassistant.components.wake_on_lan # homeassistant.components.media_player.panasonic_viera # homeassistant.components.media_player.samsungtv diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d2ac40c2550..9d9725e9e6a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -82,7 +82,8 @@ TEST_REQUIREMENTS = ( 'warrant', 'yahoo-finance', 'pythonwhois', - 'wakeonlan' + 'wakeonlan', + 'vultr' ) IGNORE_PACKAGES = ( diff --git a/setup.py b/setup.py index 3eb636d0801..f7a3e4ab8f3 100755 --- a/setup.py +++ b/setup.py @@ -2,15 +2,46 @@ """Home Assistant setup script.""" import os from setuptools import setup, find_packages -from homeassistant.const import (__version__, PROJECT_PACKAGE_NAME, - PROJECT_LICENSE, PROJECT_URL, - PROJECT_EMAIL, PROJECT_DESCRIPTION, - PROJECT_CLASSIFIERS, GITHUB_URL, - PROJECT_AUTHOR) + +from homeassistant.const import __version__ + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_LICENSE = 'Apache License 2.0' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2017, {}'.format(PROJECT_AUTHOR) +PROJECT_URL = 'https://home-assistant.io/' +PROJECT_EMAIL = 'hello@home-assistant.io' +PROJECT_DESCRIPTION = ('Open-source home automation platform ' + 'running on Python 3.') +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Home Automation' +] + +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + HERE = os.path.abspath(os.path.dirname(__file__)) -DOWNLOAD_URL = ('{}/archive/' - '{}.zip'.format(GITHUB_URL, __version__)) +DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, __version__) PACKAGES = find_packages(exclude=['tests', 'tests.*']) @@ -22,8 +53,8 @@ REQUIRES = [ 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', - 'aiohttp==2.2.5', - 'yarl==0.13', # Update this whenever you update aiohttp + 'aiohttp==2.3.2', # If updated, check if yarl also needs an update! + 'yarl==0.14.0', 'async_timeout==2.0.0', 'chardet==3.0.4', 'astral==1.4', diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index b5af01584d3..1b10b942281 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,10 +72,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_HOME)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -83,8 +81,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_HOME, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_HOME def test_arm_home_with_invalid_code(self): """Attempt to arm home without a valid code.""" @@ -155,10 +153,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_AWAY)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -166,8 +162,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_AWAY, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_AWAY def test_arm_away_with_invalid_code(self): """Attempt to arm away without a valid code.""" @@ -238,10 +234,9 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_NIGHT)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_NIGHT future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -249,8 +244,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_ARMED_NIGHT, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_NIGHT def test_arm_night_with_invalid_code(self): """Attempt to night home without a valid code.""" @@ -329,10 +324,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_TRIGGERED)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -340,8 +333,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_TRIGGERED, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=5) with patch(('homeassistant.components.alarm_control_panel.manual.' @@ -349,8 +342,8 @@ class TestAlarmControlPanelManual(unittest.TestCase): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(STATE_ALARM_DISARMED, - self.hass.states.get(entity_id).state) + state = self.hass.states.get(entity_id) + assert state.state == STATE_ALARM_DISARMED def test_armed_home_with_specific_pending(self): """Test arm home method.""" diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index 5210c616f9c..e56b6865e6e 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -100,10 +100,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_HOME)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -189,10 +187,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_AWAY)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -278,10 +274,9 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_ARMED_NIGHT)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_NIGHT future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' @@ -375,10 +370,8 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) - self.assertTrue( - self.hass.states.is_state_attr(entity_id, - 'post_pending_state', - STATE_ALARM_TRIGGERED)) + state = self.hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' diff --git a/tests/components/alarm_control_panel/test_spc.py b/tests/components/alarm_control_panel/test_spc.py index 504b4e9237c..63b79781404 100644 --- a/tests/components/alarm_control_panel/test_spc.py +++ b/tests/components/alarm_control_panel/test_spc.py @@ -7,7 +7,7 @@ from homeassistant.components.spc import SpcRegistry from homeassistant.components.alarm_control_panel import spc from tests.common import async_test_home_assistant from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) + STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) @pytest.fixture @@ -38,19 +38,19 @@ def test_setup_platform(hass): 'last_set_user_name': 'Pelle', 'last_unset_time': '1485800564', 'last_unset_user_id': '1', - 'last_unset_user_name': 'Pelle', + 'last_unset_user_name': 'Lisa', 'last_alarm': '1478174896' - }, { + }, { 'id': '3', 'name': 'Garage', 'mode': '0', 'last_set_time': '1483705803', 'last_set_user_id': '9998', - 'last_set_user_name': 'Lisa', + 'last_set_user_name': 'Pelle', 'last_unset_time': '1483705808', 'last_unset_user_id': '9998', 'last_unset_user_name': 'Lisa' - }]} + }]} yield from spc.async_setup_platform(hass=hass, config={}, @@ -58,7 +58,11 @@ def test_setup_platform(hass): discovery_info=areas) assert len(added_entities) == 2 + assert added_entities[0].name == 'House' assert added_entities[0].state == STATE_ALARM_ARMED_AWAY + assert added_entities[0].changed_by == 'Pelle' + assert added_entities[1].name == 'Garage' assert added_entities[1].state == STATE_ALARM_DISARMED + assert added_entities[1].changed_by == 'Lisa' diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 565ebec64aa..a3587622b3d 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -13,6 +13,8 @@ from homeassistant.components.alexa import intent SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" +AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC" +BUILTIN_AUTH_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.TEST" # pylint: disable=invalid-name calls = [] @@ -90,7 +92,7 @@ def alexa_client(loop, hass, test_client): "type": "plain", "text": "LaunchRequest has been received.", } - } + }, } })) return loop.run_until_complete(test_client(hass.http.app)) @@ -207,6 +209,156 @@ def test_intent_request_with_slots(alexa_client): assert text == "You told us your sign is virgo." +@asyncio.coroutine +def test_intent_request_with_slots_and_synonym_resolution(alexa_client): + """Test a request with slots and a name synonym.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID + }, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False + } + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "V zodiac", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Virgo" + } + } + ] + }, + { + "authority": BUILTIN_AUTH_ID, + "status": { + "code": "ER_SUCCESS_NO_MATCH" + }, + "values": [ + { + "value": { + "name": "Test" + } + } + ] + } + ] + } + } + } + } + } + } + req = yield from _intent_req(alexa_client, data) + assert req.status == 200 + data = yield from req.json() + text = data.get("response", {}).get("outputSpeech", + {}).get("text") + assert text == "You told us your sign is Virgo." + + +@asyncio.coroutine +def test_intent_request_with_slots_and_multi_synonym_resolution(alexa_client): + """Test a request with slots and multiple name synonyms.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": { + "applicationId": APPLICATION_ID + }, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False + } + }, + "user": { + "userId": "amzn1.account.AM3B00000000000000000000000" + } + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "V zodiac", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Virgo" + } + } + ] + }, + { + "authority": BUILTIN_AUTH_ID, + "status": { + "code": "ER_SUCCESS_MATCH" + }, + "values": [ + { + "value": { + "name": "Test" + } + } + ] + } + ] + } + } + } + } + } + } + req = yield from _intent_req(alexa_client, data) + assert req.status == 200 + data = yield from req.json() + text = data.get("response", {}).get("outputSpeech", + {}).get("text") + assert text == "You told us your sign is V zodiac." + + @asyncio.coroutine def test_intent_request_with_slots_but_no_value(alexa_client): """Test a request with slots but no value.""" @@ -237,7 +389,7 @@ def test_intent_request_with_slots_but_no_value(alexa_client): "name": "GetZodiacHoroscopeIntent", "slots": { "ZodiacSign": { - "name": "ZodiacSign", + "name": "ZodiacSign" } } } diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4c79e95b324..55a412af1fd 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -5,9 +5,12 @@ from uuid import uuid4 import pytest from homeassistant.components.alexa import smart_home +from homeassistant.helpers import entityfilter from tests.common import async_mock_service +DEFAULT_CONFIG = smart_home.Config(filter=lambda entity_id: True) + def get_new_request(namespace, name, endpoint=None): """Generate a new API message.""" @@ -91,7 +94,7 @@ def test_wrong_version(hass): msg['directive']['header']['payloadVersion'] = '2' with pytest.raises(AssertionError): - yield from smart_home.async_handle_message(hass, msg) + yield from smart_home.async_handle_message(hass, DEFAULT_CONFIG, msg) @asyncio.coroutine @@ -99,7 +102,7 @@ def test_discovery_request(hass): """Test alexa discovery request.""" request = get_new_request('Alexa.Discovery', 'Discover') - # settup test devices + # setup test devices hass.states.async_set( 'switch.test', 'on', {'friendly_name': "Test switch"}) @@ -114,12 +117,56 @@ def test_discovery_request(hass): 'friendly_name': "Test light 3", 'supported_features': 19 }) - msg = yield from smart_home.async_handle_message(hass, request) + hass.states.async_set( + 'script.test', 'off', {'friendly_name': "Test script"}) + + hass.states.async_set( + 'input_boolean.test', 'off', {'friendly_name': "Test input boolean"}) + + hass.states.async_set( + 'scene.test', 'off', {'friendly_name': "Test scene"}) + + hass.states.async_set( + 'fan.test_1', 'off', {'friendly_name': "Test fan 1"}) + + hass.states.async_set( + 'fan.test_2', 'off', { + 'friendly_name': "Test fan 2", 'supported_features': 1, + 'speed_list': ['low', 'medium', 'high'] + }) + + hass.states.async_set( + 'lock.test', 'off', {'friendly_name': "Test lock"}) + + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", + 'supported_features': 20925, + 'volume_level': 1 + }) + + hass.states.async_set( + 'alert.test', 'off', {'friendly_name': "Test alert"}) + + hass.states.async_set( + 'automation.test', 'off', {'friendly_name': "Test automation"}) + + hass.states.async_set( + 'group.test', 'off', {'friendly_name': "Test group"}) + + hass.states.async_set( + 'cover.test', 'off', { + 'friendly_name': "Test cover", 'supported_features': 255, + 'position': 85 + }) + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] - assert len(msg['payload']['endpoints']) == 4 + assert len(msg['payload']['endpoints']) == 15 assert msg['header']['name'] == 'Discover.Response' assert msg['header']['namespace'] == 'Alexa.Discovery' @@ -170,9 +217,173 @@ def test_discovery_request(hass): continue + if appliance['endpointId'] == 'script#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test script" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'input_boolean#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test input boolean" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'scene#test': + assert appliance['displayCategories'][0] == "ACTIVITY_TRIGGER" + assert appliance['friendlyName'] == "Test scene" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.SceneController' + continue + + if appliance['endpointId'] == 'fan#test_1': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test fan 1" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'fan#test_2': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test fan 2" + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PercentageController' in caps + assert 'Alexa.PowerController' in caps + continue + + if appliance['endpointId'] == 'lock#test': + assert appliance['displayCategories'][0] == "SMARTLOCK" + assert appliance['friendlyName'] == "Test lock" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.LockController' + continue + + if appliance['endpointId'] == 'media_player#test': + assert appliance['displayCategories'][0] == "TV" + assert appliance['friendlyName'] == "Test media player" + assert len(appliance['capabilities']) == 3 + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PowerController' in caps + assert 'Alexa.Speaker' in caps + assert 'Alexa.PlaybackController' in caps + continue + + if appliance['endpointId'] == 'alert#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test alert" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'automation#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test automation" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'group#test': + assert appliance['displayCategories'][0] == "OTHER" + assert appliance['friendlyName'] == "Test group" + assert len(appliance['capabilities']) == 1 + assert appliance['capabilities'][-1]['interface'] == \ + 'Alexa.PowerController' + continue + + if appliance['endpointId'] == 'cover#test': + assert appliance['displayCategories'][0] == "DOOR" + assert appliance['friendlyName'] == "Test cover" + assert len(appliance['capabilities']) == 2 + + caps = set() + for feature in appliance['capabilities']: + caps.add(feature['interface']) + + assert 'Alexa.PercentageController' in caps + assert 'Alexa.PowerController' in caps + continue + raise AssertionError("Unknown appliance!") +@asyncio.coroutine +def test_exclude_filters(hass): + """Test exclusion filters.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + + hass.states.async_set( + 'script.deny', 'off', {'friendly_name': "Blocked script"}) + + hass.states.async_set( + 'cover.deny', 'off', {'friendly_name': "Blocked cover"}) + + config = smart_home.Config(filter=entityfilter.generate_filter( + include_domains=[], + include_entities=[], + exclude_domains=['script'], + exclude_entities=['cover.deny'], + )) + + msg = yield from smart_home.async_handle_message(hass, config, request) + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + +@asyncio.coroutine +def test_include_filters(hass): + """Test inclusion filters.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'switch.deny', 'on', {'friendly_name': "Blocked switch"}) + + hass.states.async_set( + 'script.deny', 'off', {'friendly_name': "Blocked script"}) + + hass.states.async_set( + 'automation.allow', 'off', {'friendly_name': "Allowed automation"}) + + hass.states.async_set( + 'group.allow', 'off', {'friendly_name': "Allowed group"}) + + config = smart_home.Config(filter=entityfilter.generate_filter( + include_domains=['automation', 'group'], + include_entities=['script.deny'], + exclude_domains=[], + exclude_entities=[], + )) + + msg = yield from smart_home.async_handle_message(hass, config, request) + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 3 + + @asyncio.coroutine def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" @@ -180,7 +391,8 @@ def test_api_entity_not_exists(hass): call_switch = async_mock_service(hass, 'switch', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -195,7 +407,8 @@ def test_api_entity_not_exists(hass): 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) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -206,21 +419,29 @@ def test_api_function_not_implemented(hass): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch']) +@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', + 'input_boolean', 'light', 'script', + 'switch']) def test_api_turn_on(hass, domain): """Test api turn on process.""" request = get_new_request( 'Alexa.PowerController', 'TurnOn', '{}#test'.format(domain)) - # settup test devices + # setup test devices hass.states.async_set( '{}.test'.format(domain), 'off', { 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, domain, 'turn_on') + call_domain = domain - msg = yield from smart_home.async_handle_message(hass, request) + if domain == 'group': + call_domain = 'homeassistant' + + call = async_mock_service(hass, call_domain, 'turn_on') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -231,21 +452,29 @@ def test_api_turn_on(hass, domain): @asyncio.coroutine -@pytest.mark.parametrize("domain", ['light', 'switch']) +@pytest.mark.parametrize("domain", ['alert', 'automation', 'group', + 'input_boolean', 'light', 'script', + 'switch']) def test_api_turn_off(hass, domain): """Test api turn on process.""" request = get_new_request( 'Alexa.PowerController', 'TurnOff', '{}#test'.format(domain)) - # settup test devices + # setup test devices hass.states.async_set( '{}.test'.format(domain), 'on', { 'friendly_name': "Test {}".format(domain) }) - call = async_mock_service(hass, domain, 'turn_off') + call_domain = domain - msg = yield from smart_home.async_handle_message(hass, request) + if domain == 'group': + call_domain = 'homeassistant' + + call = async_mock_service(hass, call_domain, 'turn_off') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -264,13 +493,14 @@ def test_api_set_brightness(hass): # add payload request['directive']['payload']['brightness'] = '50' - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', {'friendly_name': "Test light"}) call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -292,7 +522,7 @@ def test_api_adjust_brightness(hass, result, adjust): # add payload request['directive']['payload']['brightnessDelta'] = adjust - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'brightness': '77' @@ -300,7 +530,8 @@ def test_api_adjust_brightness(hass, result, adjust): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -324,7 +555,7 @@ def test_api_set_color_rgb(hass): 'brightness': '0.342', } - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", @@ -333,7 +564,8 @@ def test_api_set_color_rgb(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -357,7 +589,7 @@ def test_api_set_color_xy(hass): 'brightness': '0.342', } - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", @@ -366,7 +598,8 @@ def test_api_set_color_xy(hass): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -388,13 +621,14 @@ def test_api_set_color_temperature(hass): # add payload request['directive']['payload']['colorTemperatureInKelvin'] = '7500' - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', {'friendly_name': "Test light"}) call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -413,7 +647,7 @@ def test_api_decrease_color_temp(hass, result, initial): 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature', 'light#test') - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'color_temp': initial, @@ -422,7 +656,8 @@ def test_api_decrease_color_temp(hass, result, initial): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -441,7 +676,7 @@ def test_api_increase_color_temp(hass, result, initial): 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature', 'light#test') - # settup test devices + # setup test devices hass.states.async_set( 'light.test', 'off', { 'friendly_name': "Test light", 'color_temp': initial, @@ -450,7 +685,8 @@ def test_api_increase_color_temp(hass, result, initial): call_light = async_mock_service(hass, 'light', 'turn_on') - msg = yield from smart_home.async_handle_message(hass, request) + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) assert 'event' in msg msg = msg['event'] @@ -459,3 +695,392 @@ def test_api_increase_color_temp(hass, result, initial): assert call_light[0].data['entity_id'] == 'light.test' assert call_light[0].data['color_temp'] == result assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['scene']) +def test_api_activate(hass, domain): + """Test api activate process.""" + request = get_new_request( + 'Alexa.SceneController', 'Activate', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'turn_on') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_percentage_fan(hass): + """Test api set percentage for fan process.""" + request = get_new_request( + 'Alexa.PercentageController', 'SetPercentage', 'fan#test_2') + + # add payload + request['directive']['payload']['percentage'] = '50' + + # setup test devices + hass.states.async_set( + 'fan.test_2', 'off', {'friendly_name': "Test fan"}) + + call_fan = async_mock_service(hass, 'fan', 'set_speed') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_fan) == 1 + assert call_fan[0].data['entity_id'] == 'fan.test_2' + assert call_fan[0].data['speed'] == 'medium' + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_percentage_cover(hass): + """Test api set percentage for cover process.""" + request = get_new_request( + 'Alexa.PercentageController', 'SetPercentage', 'cover#test') + + # add payload + request['directive']['payload']['percentage'] = '50' + + # setup test devices + hass.states.async_set( + 'cover.test', 'closed', { + 'friendly_name': "Test cover" + }) + + call_cover = async_mock_service(hass, 'cover', 'set_cover_position') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_cover) == 1 + assert call_cover[0].data['entity_id'] == 'cover.test' + assert call_cover[0].data['position'] == 50 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [('high', '-5'), ('off', '5'), ('low', '-80')]) +def test_api_adjust_percentage_fan(hass, result, adjust): + """Test api adjust percentage for fan process.""" + request = get_new_request( + 'Alexa.PercentageController', 'AdjustPercentage', 'fan#test_2') + + # add payload + request['directive']['payload']['percentageDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'fan.test_2', 'on', { + 'friendly_name': "Test fan 2", 'speed': 'high' + }) + + call_fan = async_mock_service(hass, 'fan', 'set_speed') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_fan) == 1 + assert call_fan[0].data['entity_id'] == 'fan.test_2' + assert call_fan[0].data['speed'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')]) +def test_api_adjust_percentage_cover(hass, result, adjust): + """Test api adjust percentage for cover process.""" + request = get_new_request( + 'Alexa.PercentageController', 'AdjustPercentage', 'cover#test') + + # add payload + request['directive']['payload']['percentageDelta'] = adjust + + # setup test devices + hass.states.async_set( + 'cover.test', 'closed', { + 'friendly_name': "Test cover", + 'position': 30 + }) + + call_cover = async_mock_service(hass, 'cover', 'set_cover_position') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_cover) == 1 + assert call_cover[0].data['entity_id'] == 'cover.test' + assert call_cover[0].data['position'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['lock']) +def test_api_lock(hass, domain): + """Test api lock process.""" + request = get_new_request( + 'Alexa.LockController', 'Lock', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'lock') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_play(hass, domain): + """Test api play process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Play', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_play') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_pause(hass, domain): + """Test api pause process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Pause', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_pause') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_stop(hass, domain): + """Test api stop process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Stop', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_stop') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_next(hass, domain): + """Test api next process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Next', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_next_track') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_previous(hass, domain): + """Test api previous process.""" + request = get_new_request( + 'Alexa.PlaybackController', 'Previous', '{}#test'.format(domain)) + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'media_previous_track') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +def test_api_set_volume(hass): + """Test api set volume process.""" + request = get_new_request( + 'Alexa.Speaker', 'SetVolume', 'media_player#test') + + # add payload + request['directive']['payload']['volume'] = 50 + + # setup test devices + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", 'volume_level': 0 + }) + + call_media_player = async_mock_service(hass, 'media_player', 'volume_set') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_media_player) == 1 + assert call_media_player[0].data['entity_id'] == 'media_player.test' + assert call_media_player[0].data['volume_level'] == 0.5 + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize( + "result,adjust", [(0.7, '-5'), (0.8, '5'), (0, '-80')]) +def test_api_adjust_volume(hass, result, adjust): + """Test api adjust volume process.""" + request = get_new_request( + 'Alexa.Speaker', 'AdjustVolume', 'media_player#test') + + # add payload + request['directive']['payload']['volume'] = adjust + + # setup test devices + hass.states.async_set( + 'media_player.test', 'off', { + 'friendly_name': "Test media player", 'volume_level': 0.75 + }) + + call_media_player = async_mock_service(hass, 'media_player', 'volume_set') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call_media_player) == 1 + assert call_media_player[0].data['entity_id'] == 'media_player.test' + assert call_media_player[0].data['volume_level'] == result + assert msg['header']['name'] == 'Response' + + +@asyncio.coroutine +@pytest.mark.parametrize("domain", ['media_player']) +def test_api_mute(hass, domain): + """Test api mute process.""" + request = get_new_request( + 'Alexa.Speaker', 'SetMute', '{}#test'.format(domain)) + + request['directive']['payload']['mute'] = True + + # setup test devices + hass.states.async_set( + '{}.test'.format(domain), 'off', { + 'friendly_name': "Test {}".format(domain) + }) + + call = async_mock_service(hass, domain, 'volume_mute') + + msg = yield from smart_home.async_handle_message( + hass, DEFAULT_CONFIG, request) + + assert 'event' in msg + msg = msg['event'] + + assert len(call) == 1 + assert call[0].data['entity_id'] == '{}.test'.format(domain) + assert msg['header']['name'] == 'Response' diff --git a/tests/components/binary_sensor/test_spc.py b/tests/components/binary_sensor/test_spc.py index 5004ccd3210..d2299874527 100644 --- a/tests/components/binary_sensor/test_spc.py +++ b/tests/components/binary_sensor/test_spc.py @@ -30,7 +30,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '0', 'status': '0', - }, { + }, { 'id': '3', 'type': '0', 'zone_name': 'Hallway PIR', @@ -38,7 +38,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '0', 'status': '0', - }, { + }, { 'id': '5', 'type': '1', 'zone_name': 'Front door', @@ -46,7 +46,7 @@ def test_setup_platform(hass): 'area_name': 'House', 'input': '1', 'status': '0', - }]} + }]} def add_entities(entities): nonlocal added_entities diff --git a/tests/components/binary_sensor/test_vultr.py b/tests/components/binary_sensor/test_vultr.py new file mode 100644 index 00000000000..2bcb220233b --- /dev/null +++ b/tests/components/binary_sensor/test_vultr.py @@ -0,0 +1,165 @@ +"""Test the Vultr binary sensor platform.""" +import unittest +import requests_mock +import pytest +import voluptuous as vol + +from homeassistant.components.binary_sensor import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import ( + ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, + ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, + CONF_SUBSCRIPTION) +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrBinarySensorSetup(unittest.TestCase): + """Test the Vultr binary sensor platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Init values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_SUBSCRIPTION: '576965', + CONF_NAME: "A Server" + }, + { + CONF_SUBSCRIPTION: '123456', + CONF_NAME: "Failed Server" + }, + { + CONF_SUBSCRIPTION: '555555', + CONF_NAME: vultr.DEFAULT_NAME + } + ] + + def tearDown(self): + """Stop our started services.""" + self.hass.stop() + + def test_failed_hub(self): + """Test a hub setup failure.""" + base_vultr.setup(self.hass, VALID_CONFIG) + + @requests_mock.Mocker() + def test_binary_sensor(self, mock): + """Test successful instance.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) + + # Setup each of our test configs + for config in self.configs: + vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertEqual(len(self.DEVICES), 3) + + for device in self.DEVICES: + + # Test pre data retieval + if device.subscription == '555555': + self.assertEqual('Vultr {}', device.name) + + device.update() + device_attrs = device.device_state_attributes + + if device.subscription == '555555': + self.assertEqual('Vultr Another Server', device.name) + + if device.name == 'A Server': + self.assertEqual(True, device.is_on) + self.assertEqual('power', device.device_class) + self.assertEqual('on', device.state) + self.assertEqual('mdi:server', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('yes', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('123.123.123.123', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('10.05', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2013-12-19 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('576965', + device_attrs[ATTR_SUBSCRIPTION_ID]) + elif device.name == 'Failed Server': + self.assertEqual(False, device.is_on) + self.assertEqual('off', device.state) + self.assertEqual('mdi:server-off', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('no', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('192.168.100.50', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('73.25', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2014-10-13 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('123456', + device_attrs[ATTR_SUBSCRIPTION_ID]) + + def test_invalid_sensor_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subs + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + }) + + @requests_mock.Mocker() + def test_invalid_sensors(self, mock): + """Test the VultrBinarySensor fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = {} # No subscription + + no_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertFalse(no_subs_setup) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: '555555' + } # Sub not associated with API key (not in server_list) + + wrong_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertFalse(wrong_subs_setup) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 74b2186b8d7..bb42ef177f0 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -1,9 +1,9 @@ """The tests for the generic_thermostat.""" import asyncio import datetime -import pytz import unittest from unittest import mock +import pytz import homeassistant.core as ha from homeassistant.core import callback @@ -54,13 +54,16 @@ class TestSetupClimateGenericThermostat(unittest.TestCase): 'climate': config}) def test_valid_conf(self): - """Test set up genreic_thermostat with valid config values.""" - self.assertTrue(setup_component(self.hass, 'climate', - {'climate': { - 'platform': 'generic_thermostat', - 'name': 'test', - 'heater': ENT_SWITCH, - 'target_sensor': ENT_SENSOR}})) + """Test set up generic_thermostat with valid config values.""" + self.assertTrue( + setup_component(self.hass, 'climate', + {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + ) def test_setup_with_sensor(self): """Test set up heat_control with sensor to trigger update at init.""" @@ -243,6 +246,31 @@ class TestClimateGenericThermostat(unittest.TestCase): self.hass.block_till_done() self.assertEqual(0, len(self.calls)) + @mock.patch('logging.Logger.error') + def test_invalid_operating_mode(self, log_mock): + """Test error handling for invalid operation mode.""" + climate.set_operation_mode(self.hass, 'invalid mode') + self.hass.block_till_done() + self.assertEqual(log_mock.call_count, 1) + + def test_operating_mode_auto(self): + """Test change mode from OFF to AUTO. + + Switch turns on when temp below setpoint and mode changes. + """ + climate.set_operation_mode(self.hass, STATE_OFF) + climate.set_temperature(self.hass, 30) + self._setup_sensor(25) + self.hass.block_till_done() + self._setup_switch(False) + climate.set_operation_mode(self.hass, climate.STATE_AUTO) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): """Setup the test sensor.""" self.hass.states.set(ENT_SENSOR, temp, { diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index d9f005fdcfa..20f9265a1c1 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -69,7 +69,6 @@ def test_login(mock_cognito): auth_api.login(cloud, 'user', 'pass') assert len(mock_cognito.authenticate.mock_calls) == 1 - 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' diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 1090acb01e9..296baa3f143 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -3,9 +3,10 @@ import asyncio from unittest.mock import patch, MagicMock import pytest +from jose import jwt from homeassistant.bootstrap import async_setup_component -from homeassistant.components.cloud import DOMAIN, auth_api +from homeassistant.components.cloud import DOMAIN, auth_api, iot from tests.common import mock_coro @@ -23,7 +24,8 @@ def cloud_client(hass, test_client): 'relayer': 'relayer', } })) - return hass.loop.run_until_complete(test_client(hass.http.app)) + with patch('homeassistant.components.cloud.Cloud.write_user_info'): + yield hass.loop.run_until_complete(test_client(hass.http.app)) @pytest.fixture @@ -43,21 +45,35 @@ def test_account_view_no_account(cloud_client): @asyncio.coroutine def test_account_view(hass, cloud_client): """Test fetching account if no account available.""" - hass.data[DOMAIN].email = 'hello@home-assistant.io' + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED req = yield from cloud_client.get('/api/cloud/account') assert req.status == 200 result = yield from req.json() - assert result == {'email': 'hello@home-assistant.io'} + assert result == { + 'email': 'hello@home-assistant.io', + 'sub_exp': '2018-01-03', + 'cloud': iot.STATE_CONNECTED, + } @asyncio.coroutine -def test_login_view(hass, cloud_client): +def test_login_view(hass, cloud_client, mock_cognito): """Test logging in.""" - hass.data[DOMAIN].email = 'hello@home-assistant.io' + mock_cognito.id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + mock_cognito.access_token = 'access_token' + mock_cognito.refresh_token = 'refresh_token' - with patch('homeassistant.components.cloud.iot.CloudIoT.connect'), \ - patch('homeassistant.components.cloud.' - 'auth_api.login') as mock_login: + with patch('homeassistant.components.cloud.iot.CloudIoT.' + 'connect') as mock_connect, \ + patch('homeassistant.components.cloud.auth_api._authenticate', + return_value=mock_cognito) as mock_auth: req = yield from cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' @@ -65,9 +81,13 @@ def test_login_view(hass, cloud_client): assert req.status == 200 result = yield from req.json() - assert result == {'email': 'hello@home-assistant.io'} - assert len(mock_login.mock_calls) == 1 - cloud, result_user, result_pass = mock_login.mock_calls[0][1] + assert result['email'] == 'hello@home-assistant.io' + assert result['sub_exp'] == '2018-01-03' + + assert len(mock_connect.mock_calls) == 1 + + assert len(mock_auth.mock_calls) == 1 + cloud, result_user, result_pass = mock_auth.mock_calls[0][1] assert result_user == 'my_username' assert result_pass == 'my_password' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 1eb1051520f..c05fdabf465 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -3,9 +3,11 @@ import asyncio import json from unittest.mock import patch, MagicMock, mock_open +from jose import jwt import pytest from homeassistant.components import cloud +from homeassistant.util.dt import utcnow from tests.common import mock_coro @@ -72,7 +74,6 @@ 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', @@ -85,7 +86,6 @@ def test_initialize_loads_info(mock_os, hass): 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' @@ -102,7 +102,6 @@ def test_logout_clears_info(mock_os, hass): 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 @@ -115,7 +114,6 @@ def test_write_user_info(): 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' @@ -129,7 +127,41 @@ def test_write_user_info(): 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', } + + +@asyncio.coroutine +def test_subscription_not_expired_without_sub_in_claim(): + """Test that we do not enforce subscriptions yet.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({}, 'test') + + assert not cl.subscription_expired + + +@asyncio.coroutine +def test_subscription_expired(): + """Test subscription being expired.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({ + 'custom:sub-exp': '2017-11-13' + }, 'test') + + with patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2018)): + assert cl.subscription_expired + + +@asyncio.coroutine +def test_subscription_not_expired(): + """Test subscription not being expired.""" + cl = cloud.Cloud(None, cloud.MODE_DEV) + cl.id_token = jwt.encode({ + 'custom:sub-exp': '2017-11-13' + }, 'test') + + with patch('homeassistant.util.dt.utcnow', + return_value=utcnow().replace(year=2017, month=11, day=9)): + assert not cl.subscription_expired diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index f1254cdb3c7..be5a93c9e47 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -30,11 +30,16 @@ def mock_handle_message(): yield mock +@pytest.fixture +def mock_cloud(): + """Mock cloud class.""" + return MagicMock(subscription_expired=False) + + @asyncio.coroutine -def test_cloud_calling_handler(mock_client, mock_handle_message): +def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): """Test we call handle message with correct info.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -53,8 +58,8 @@ def test_cloud_calling_handler(mock_client, mock_handle_message): 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 p_hass is mock_cloud.hass + assert p_cloud is mock_cloud assert handler_name == 'test-handler' assert payload == 'test-payload' @@ -67,10 +72,9 @@ def test_cloud_calling_handler(mock_client, mock_handle_message): @asyncio.coroutine -def test_connection_msg_for_unknown_handler(mock_client): +def test_connection_msg_for_unknown_handler(mock_client, mock_cloud): """Test a msg for an unknown handler.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -92,10 +96,10 @@ def test_connection_msg_for_unknown_handler(mock_client): @asyncio.coroutine -def test_connection_msg_for_handler_raising(mock_client, mock_handle_message): +def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, + mock_cloud): """Test we sent error when handler raises exception.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.text, json=MagicMock(return_value={ @@ -136,37 +140,34 @@ def test_handler_forwarding(): @asyncio.coroutine -def test_handling_core_messages(hass): +def test_handling_core_messages(hass, mock_cloud): """Test handling core messages.""" - cloud = MagicMock() - cloud.logout.return_value = mock_coro() - yield from iot.async_handle_cloud(hass, cloud, { + mock_cloud.logout.return_value = mock_coro() + yield from iot.async_handle_cloud(hass, mock_cloud, { 'action': 'logout', 'reason': 'Logged in at two places.' }) - assert len(cloud.logout.mock_calls) == 1 + assert len(mock_cloud.logout.mock_calls) == 1 @asyncio.coroutine -def test_cloud_getting_disconnected_by_server(mock_client, caplog): +def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_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]) + assert 'Connection closed: Connection cancelled.' in caplog.text + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_receiving_bytes(mock_client, caplog): +def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): """Test server disconnecting instance.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.BINARY, )) @@ -174,14 +175,13 @@ def test_cloud_receiving_bytes(mock_client, caplog): 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]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_sending_invalid_json(mock_client, caplog): +def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): """Test cloud sending invalid JSON.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.return_value = mock_coro(MagicMock( type=WSMsgType.TEXT, json=MagicMock(side_effect=ValueError) @@ -190,27 +190,25 @@ def test_cloud_sending_invalid_json(mock_client, caplog): 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]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_check_token_raising(mock_client, caplog): +def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): """Test cloud sending invalid JSON.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_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]) + assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine -def test_cloud_connect_invalid_auth(mock_client, caplog): +def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): """Test invalid auth detected by server.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = \ client_exceptions.WSServerHandshakeError(None, None, code=401) @@ -220,10 +218,9 @@ def test_cloud_connect_invalid_auth(mock_client, caplog): @asyncio.coroutine -def test_cloud_unable_to_connect(mock_client, caplog): +def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud): """Test unable to connect error.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = client_exceptions.ClientError(None, None) yield from conn.connect() @@ -232,12 +229,28 @@ def test_cloud_unable_to_connect(mock_client, caplog): @asyncio.coroutine -def test_cloud_random_exception(mock_client, caplog): +def test_cloud_random_exception(mock_client, caplog, mock_cloud): """Test random exception.""" - cloud = MagicMock() - conn = iot.CloudIoT(cloud) + conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = Exception yield from conn.connect() assert 'Unexpected error' in caplog.text + + +@asyncio.coroutine +def test_refresh_token_before_expiration_fails(hass, mock_cloud): + """Test that we don't connect if token is expired.""" + mock_cloud.subscription_expired = True + mock_cloud.hass = hass + conn = iot.CloudIoT(mock_cloud) + + with patch('homeassistant.components.cloud.auth_api.check_token', + return_value=mock_coro()) as mock_check_token, \ + patch.object(hass.components.persistent_notification, + 'async_create') as mock_create: + yield from conn.connect() + + assert len(mock_check_token.mock_calls) == 1 + assert len(mock_create.mock_calls) == 1 diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index fc359dc7ff7..81800d709e3 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -9,7 +9,7 @@ from homeassistant.components import config from homeassistant.components.zwave import DATA_NETWORK, const from homeassistant.components.config.zwave import ( ZWaveNodeValueView, ZWaveNodeGroupView, ZWaveNodeConfigView, - ZWaveUserCodeView) + ZWaveUserCodeView, ZWaveConfigWriteView) from tests.common import mock_http_component_app from tests.mock.zwave import MockNode, MockValue, MockEntityValues @@ -417,3 +417,36 @@ def test_get_usercodes_no_genreuser(hass, test_client): result = yield from resp.json() assert result == {} + + +@asyncio.coroutine +def test_save_config_no_network(hass, test_client): + """Test saving configuration without network data.""" + app = mock_http_component_app(hass) + ZWaveConfigWriteView().register(app.router) + + client = yield from test_client(app) + + resp = yield from client.post('/api/zwave/saveconfig') + + assert resp.status == 404 + result = yield from resp.json() + assert result == {'message': 'No Z-Wave network data found'} + + +@asyncio.coroutine +def test_save_config(hass, test_client): + """Test saving configuration.""" + app = mock_http_component_app(hass) + ZWaveConfigWriteView().register(app.router) + + network = hass.data[DATA_NETWORK] = MagicMock() + + client = yield from test_client(app) + + resp = yield from client.post('/api/zwave/saveconfig') + + assert resp.status == 200 + result = yield from resp.json() + assert network.write_config.called + assert result == {'message': 'Z-Wave configuration saved to file.'} diff --git a/tests/components/counter/__init__.py b/tests/components/counter/__init__.py new file mode 100644 index 00000000000..7ebe8e7d7b5 --- /dev/null +++ b/tests/components/counter/__init__.py @@ -0,0 +1 @@ +"""Tests for the counter component.""" diff --git a/tests/components/test_counter.py b/tests/components/counter/test_init.py similarity index 100% rename from tests/components/test_counter.py rename to tests/components/counter/test_init.py index 8dc04f0e76a..f4c6ee9c7da 100644 --- a/tests/components/test_counter.py +++ b/tests/components/counter/test_init.py @@ -42,6 +42,47 @@ class TestCounter(unittest.TestCase): self.assertFalse( setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + def test_methods(self): """Test increment, decrement, and reset methods.""" config = { @@ -118,47 +159,6 @@ class TestCounter(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual(15, int(state.state)) - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_INITIAL: 10, - CONF_STEP: 5, - } - } - } - - assert setup_component(self.hass, 'counter', config) - self.hass.block_till_done() - - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) - - self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) - self.hass.block_till_done() - - state_1 = self.hass.states.get('counter.test_1') - state_2 = self.hass.states.get('counter.test_2') - - self.assertIsNotNone(state_1) - self.assertIsNotNone(state_2) - - self.assertEqual(0, int(state_1.state)) - self.assertNotIn(ATTR_ICON, state_1.attributes) - self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) - - self.assertEqual(10, int(state_2.state)) - self.assertEqual('Hello World', - state_2.attributes.get(ATTR_FRIENDLY_NAME)) - self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) - @asyncio.coroutine def test_initial_state_overrules_restore_state(hass): diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index a8531e2aa69..704b2590f12 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -481,6 +481,8 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert test_events[0].data == { 'entity_id': 'device_tracker.hello', 'host_name': 'hello', + 'mac': 'MAC_1', + 'vendor': 'unknown', } # pylint: disable=invalid-name diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index a06adcb286a..4f5efb9d09d 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -18,10 +18,13 @@ DEVICE = 'phone' LOCATION_TOPIC = 'owntracks/{}/{}'.format(USER, DEVICE) EVENT_TOPIC = 'owntracks/{}/{}/event'.format(USER, DEVICE) -WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) +WAYPOINTS_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE) +WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE) USER_BLACKLIST = 'ram' -WAYPOINT_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( +WAYPOINTS_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format( USER_BLACKLIST, DEVICE) +LWT_TOPIC = 'owntracks/{}/{}/lwt'.format(USER, DEVICE) +BAD_TOPIC = 'owntracks/{}/{}/unsupported'.format(USER, DEVICE) DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE) @@ -232,6 +235,15 @@ WAYPOINTS_UPDATED_MESSAGE = { ] } +WAYPOINT_MESSAGE = { + "_type": "waypoint", + "tst": 4, + "lat": 9, + "lon": 47, + "rad": 50, + "desc": "exp_wayp1" +} + WAYPOINT_ENTITY_NAMES = [ 'zone.greg_phone__exp_wayp1', 'zone.greg_phone__exp_wayp2', @@ -239,10 +251,26 @@ WAYPOINT_ENTITY_NAMES = [ 'zone.ram_phone__exp_wayp2', ] +LWT_MESSAGE = { + "_type": "lwt", + "tst": 1 +} + +BAD_MESSAGE = { + "_type": "unsupported", + "tst": 1 +} + BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' +# def raise_on_not_implemented(hass, context, message): +def raise_on_not_implemented(): + """Throw NotImplemented.""" + raise NotImplementedError("oopsie") + + class BaseMQTT(unittest.TestCase): """Base MQTT assert functions.""" @@ -1056,7 +1084,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_simple(self): """Test a simple import of list of waypoints.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) self.assertTrue(wayp is not None) @@ -1066,7 +1094,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_blacklist(self): """Test import of list of waypoints for blacklisted user.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is None) @@ -1088,7 +1116,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): run_coroutine_threadsafe(owntracks.async_setup_scanner( self.hass, test_config, mock_see), self.hass.loop).result() waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) + self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is not None) @@ -1098,7 +1126,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_bad_json(self): """Test importing a bad JSON payload.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message, True) + self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) # Check if it made it into states wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) self.assertTrue(wayp is None) @@ -1108,15 +1136,40 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_existing(self): """Test importing a zone that exists.""" waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) # Get the first waypoint exported wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) # Send an update waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoints_message) + self.send_message(WAYPOINTS_TOPIC, waypoints_message) new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) self.assertTrue(wayp == new_wayp) + def test_single_waypoint_import(self): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + self.send_message(WAYPOINT_TOPIC, waypoint_message) + wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + self.assertTrue(wayp is not None) + + def test_not_implemented_message(self): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + self.assertFalse(self.send_message(LWT_TOPIC, LWT_MESSAGE)) + patch_handler.stop() + + def test_unsupported_message(self): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + self.assertFalse(self.send_message(BAD_TOPIC, BAD_MESSAGE)) + patch_handler.stop() + def generate_ciphers(secret): """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE.""" @@ -1143,7 +1196,7 @@ def generate_ciphers(secret): json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")) ) ).decode("utf-8") - return (ctxt, mctxt) + return ctxt, mctxt TEST_SECRET_KEY = 's3cretkey' @@ -1172,7 +1225,7 @@ def mock_cipher(): if key != mkey: raise ValueError() return plaintext - return (len(TEST_SECRET_KEY), mock_decrypt) + return len(TEST_SECRET_KEY), mock_decrypt class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index cc03324a638..383b4f7165d 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1,33 +1,32 @@ """The tests for the emulated Hue component.""" import asyncio import json - from unittest.mock import patch -import pytest -from homeassistant import setup, const, core +from aiohttp.hdrs import CONTENT_TYPE +import pytest +from tests.common import get_test_instance_port + +from homeassistant import core, const, setup import homeassistant.components as core_components from homeassistant.components import ( - emulated_hue, http, light, script, media_player, fan -) -from homeassistant.const import STATE_ON, STATE_OFF -from homeassistant.components.emulated_hue.hue_api import ( - HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, - HueAllLightsStateView, HueOneLightStateView, HueOneLightChangeView) + fan, http, light, script, emulated_hue, media_player) from homeassistant.components.emulated_hue import Config - -from tests.common import get_test_instance_port +from homeassistant.components.emulated_hue.hue_api import ( + HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, + HueAllLightsStateView, HueOneLightChangeView) +from homeassistant.const import STATE_ON, STATE_OFF HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' -JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON} +JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} @pytest.fixture def hass_hue(loop, hass): - """Setup a hass instance for these tests.""" + """Setup a Home Assistant instance for these tests.""" # We need to do this to get access to homeassistant/turn_(on,off) loop.run_until_complete( core_components.async_setup(hass, {core.DOMAIN: {}})) diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 3706ce224be..1cd895954de 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -4,6 +4,7 @@ import json import unittest from unittest.mock import patch import requests +from aiohttp.hdrs import CONTENT_TYPE from homeassistant import setup, const, core import homeassistant.components as core_components @@ -16,7 +17,7 @@ HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' -JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON} +JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} def setup_hass_instance(emulated_hue_config): diff --git a/tests/components/fan/__init__.py b/tests/components/fan/__init__.py index 54ed1fcc505..28ae7f4e249 100644 --- a/tests/components/fan/__init__.py +++ b/tests/components/fan/__init__.py @@ -1,4 +1,4 @@ -"""Test fan component plaforms.""" +"""Tests for fan platforms.""" import unittest diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 35be79469a9..dba10608991 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,18 +1,19 @@ -"""The tests for the Google Actions component.""" +"""The tests for the Google Assistant component.""" # pylint: disable=protected-access -import json import asyncio -import pytest +import json -from homeassistant import setup, const, core -from homeassistant.components import ( - http, async_setup, light, cover, media_player, fan, switch, climate -) -from homeassistant.components import google_assistant as ga +from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION +import pytest from tests.common import get_test_instance_port -from . import DEMO_DEVICES +from homeassistant import core, const, setup +from homeassistant.components import ( + fan, http, cover, light, switch, climate, async_setup, media_player) +from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from . import DEMO_DEVICES API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() @@ -20,7 +21,7 @@ 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, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } AUTHCFG = { @@ -28,12 +29,12 @@ AUTHCFG = { 'client_id': 'helloworld', 'access_token': 'superdoublesecret' } -AUTH_HEADER = {'Authorization': 'Bearer {}'.format(AUTHCFG['access_token'])} +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.""" + """Create web client for the Google Assistant API.""" hass = hass_fixture web_app = hass.http.app @@ -45,7 +46,7 @@ def assistant_client(loop, hass_fixture, test_client): @pytest.fixture def hass_fixture(loop, hass): - """Set up a hass instance for these tests.""" + """Set up a HOme Assistant 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: {}})) @@ -180,6 +181,8 @@ def test_query_request(hass_fixture, assistant_client): 'id': "light.ceiling_lights", }, { 'id': "light.bed_light", + }, { + 'id': "light.kitchen_lights", }] } }] @@ -192,10 +195,107 @@ def test_query_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert len(devices) == 2 + assert len(devices) == 3 assert devices['light.bed_light']['on'] is False assert devices['light.ceiling_lights']['on'] is True assert devices['light.ceiling_lights']['brightness'] == 70 + assert devices['light.kitchen_lights']['color']['spectrumRGB'] == 16727919 + assert devices['light.kitchen_lights']['color']['temperature'] == 4166 + + +@asyncio.coroutine +def test_query_climate_request(hass_fixture, assistant_client): + """Test a query request.""" + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + 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 devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': 21 + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } + + +@asyncio.coroutine +def test_query_climate_request_f(hass_fixture, assistant_client): + """Test a query request.""" + hass_fixture.config.units = IMPERIAL_SYSTEM + reqid = '5711642932632160984' + data = { + 'requestId': + reqid, + 'inputs': [{ + 'intent': 'action.devices.QUERY', + 'payload': { + 'devices': [ + {'id': 'climate.hvac'}, + {'id': 'climate.heatpump'}, + {'id': 'climate.ecobee'}, + ] + } + }] + } + 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 devices == { + 'climate.heatpump': { + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + }, + 'climate.ecobee': { + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'on', + 'thermostatTemperatureSetpointLow': -6.1, + }, + 'climate.hvac': { + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, + } + } @asyncio.coroutine @@ -225,6 +325,31 @@ def test_execute_request(hass_fixture, assistant_client): "on": False } }] + }, { + "devices": [{ + "id": "light.kitchen_lights", + }], + "execution": [{ + "command": "action.devices.commands.ColorAbsolute", + "params": { + "color": { + "spectrumRGB": 16711680, + "temperature": 2100 + } + } + }] + }, { + "devices": [{ + "id": "light.kitchen_lights", + }], + "execution": [{ + "command": "action.devices.commands.ColorAbsolute", + "params": { + "color": { + "spectrumRGB": 16711680 + } + } + }] }] } }] @@ -237,7 +362,10 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 3 + assert len(commands) == 5 ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' + kitchen = hass_fixture.states.get('light.kitchen_lights') + assert kitchen.attributes.get(light.ATTR_COLOR_TEMP) == 476 + assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) assert hass_fixture.states.get('switch.decorative_lights').state == 'off' diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py new file mode 100644 index 00000000000..9ced9fc329d --- /dev/null +++ b/tests/components/google_assistant/test_init.py @@ -0,0 +1,31 @@ +"""The tests for google-assistant init.""" +import asyncio + +from homeassistant.setup import async_setup_component +from homeassistant.components import google_assistant as ga + +GA_API_KEY = "Agdgjsj399sdfkosd932ksd" +GA_AGENT_USER_ID = "testid" + + +@asyncio.coroutine +def test_request_sync_service(aioclient_mock, hass): + """Test that it posts to the request_sync url.""" + aioclient_mock.post( + ga.const.REQUEST_SYNC_BASE_URL, status=200) + + yield from async_setup_component(hass, 'google_assistant', { + 'google_assistant': { + 'project_id': 'test_project', + 'client_id': 'r7328kwdsdfsdf03223409', + 'access_token': '8wdsfjsf932492342349234', + 'agent_user_id': GA_AGENT_USER_ID, + 'api_key': GA_API_KEY + }}) + + assert aioclient_mock.call_count == 0 + yield from hass.services.async_call(ga.const.DOMAIN, + ga.const.SERVICE_REQUEST_SYNC, + blocking=True) + + assert aioclient_mock.call_count == 1 diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 20db85b998e..2668c0cecfc 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -5,6 +5,7 @@ import asyncio from homeassistant import const from homeassistant.components import climate from homeassistant.components import google_assistant as ga +from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) DETERMINE_SERVICE_TESTS = [{ # Test light brightness 'entity_id': 'light.test', @@ -16,6 +17,57 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness const.SERVICE_TURN_ON, {'entity_id': 'light.test', 'brightness': 242} ) +}, { # Test light color temperature + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'temperature': 2300, + 'name': 'warm white' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'kelvin': 2300} + ) +}, { # Test light color blue + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'spectrumRGB': 255, + 'name': 'blue' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'rgb_color': [0, 0, 255]} + ) +}, { # Test light color yellow + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'spectrumRGB': 16776960, + 'name': 'yellow' + } + }, + 'expected': ( + const.SERVICE_TURN_ON, + {'entity_id': 'light.test', 'rgb_color': [255, 255, 0]} + ) +}, { # Test unhandled action/service + 'entity_id': 'light.test', + 'command': ga.const.COMMAND_COLOR, + 'params': { + 'color': { + 'unhandled': 2300 + } + }, + 'expected': ( + None, + {'entity_id': 'light.test'} + ) }, { # Test switch to light custom type 'entity_id': 'switch.decorative_lights', 'command': ga.const.COMMAND_ONOFF, @@ -82,6 +134,15 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness climate.SERVICE_SET_TEMPERATURE, {'entity_id': 'climate.living_room', 'temperature': 24.5} ), +}, { # Test climate temperature Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + 'params': {'thermostatTemperatureSetpoint': 24.5}, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', 'temperature': 76.1} + ), }, { # Test climate temperature range 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, @@ -94,6 +155,19 @@ DETERMINE_SERVICE_TESTS = [{ # Test light brightness {'entity_id': 'climate.living_room', 'target_temp_high': 24.5, 'target_temp_low': 20.5} ), +}, { # Test climate temperature range Fahrenheit + 'entity_id': 'climate.living_room', + 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + 'params': { + 'thermostatTemperatureSetpointHigh': 24.5, + 'thermostatTemperatureSetpointLow': 20.5, + }, + 'units': IMPERIAL_SYSTEM, + 'expected': ( + climate.SERVICE_SET_TEMPERATURE, + {'entity_id': 'climate.living_room', + 'target_temp_high': 76.1, 'target_temp_low': 68.9} + ), }, { # Test climate operation mode 'entity_id': 'climate.living_room', 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, @@ -122,5 +196,6 @@ def test_determine_service(): result = ga.smart_home.determine_service( test['entity_id'], test['command'], - test['params']) + test['params'], + test.get('units', METRIC_SYSTEM)) assert result == test['expected'] diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4428b5043fd..4ff87efd137 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,19 +1,23 @@ """The tests for the Home Assistant HTTP component.""" import asyncio + +from aiohttp.hdrs import ( + ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_HEADERS, + ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS, + CONTENT_TYPE) import requests - -from homeassistant import setup, const -import homeassistant.components.http as http - from tests.common import get_test_instance_port, get_test_home_assistant +from homeassistant import const, setup +import homeassistant.components.http as http + API_PASSWORD = 'test1234' SERVER_PORT = get_test_instance_port() HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE] @@ -64,9 +68,9 @@ class TestCors: """Test cross origin resource sharing with password in url.""" req = requests.get(_url(const.URL_API), params={'api_password': API_PASSWORD}, - headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL}) + headers={ORIGIN: HTTP_BASE_URL}) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -75,11 +79,11 @@ class TestCors: """Test cross origin resource sharing with password in header.""" headers = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL + ORIGIN: HTTP_BASE_URL } req = requests.get(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -91,8 +95,8 @@ class TestCors: } req = requests.get(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = ACCESS_CONTROL_ALLOW_HEADERS assert req.status_code == 200 assert allow_origin not in req.headers @@ -101,14 +105,14 @@ class TestCors: def test_cors_preflight_allowed(self): """Test cross origin resource sharing preflight (OPTIONS) request.""" headers = { - const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL, - 'Access-Control-Request-Method': 'GET', - 'Access-Control-Request-Headers': 'x-ha-access' + ORIGIN: HTTP_BASE_URL, + ACCESS_CONTROL_REQUEST_METHOD: 'GET', + ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' } req = requests.options(_url(const.URL_API), headers=headers) - allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS + allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN + allow_headers = ACCESS_CONTROL_ALLOW_HEADERS assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL @@ -139,26 +143,14 @@ def test_registering_view_while_running(hass, test_client): } ) - yield from setup.async_setup_component(hass, 'api') - yield from hass.async_start() - - yield from hass.async_block_till_done() - + # This raises a RuntimeError if app is frozen hass.http.register_view(TestView) - client = yield from test_client(hass.http.app) - - resp = yield from client.get('/hello') - assert resp.status == 200 - - text = yield from resp.text() - assert text == 'hello' - @asyncio.coroutine def test_api_base_url_with_domain(hass): - """Test setting api url.""" + """Test setting API URL.""" result = yield from setup.async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index b4576b174d6..8a7d648e6f2 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -1,14 +1,11 @@ """The tests for the demo light component.""" # pylint: disable=protected-access -import asyncio import unittest -from homeassistant.core import State, CoreState -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import setup_component import homeassistant.components.light as light -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE -from tests.common import get_test_home_assistant, mock_component +from tests.common import get_test_home_assistant ENTITY_LIGHT = 'light.bed_light' @@ -79,36 +76,3 @@ class TestDemoLight(unittest.TestCase): light.turn_off(self.hass) self.hass.block_till_done() self.assertFalse(light.is_on(self.hass, ENTITY_LIGHT)) - - -@asyncio.coroutine -def test_restore_state(hass): - """Test state gets restored.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - hass.data[DATA_RESTORE_CACHE] = { - 'light.bed_light': State('light.bed_light', 'on', { - 'brightness': 'value-brightness', - 'color_temp': 'value-color_temp', - 'rgb_color': 'value-rgb_color', - 'xy_color': 'value-xy_color', - 'white_value': 'value-white_value', - 'effect': 'value-effect', - }), - } - - yield from async_setup_component(hass, 'light', { - 'light': { - 'platform': 'demo', - }}) - - state = hass.states.get('light.bed_light') - assert state is not None - assert state.entity_id == 'light.bed_light' - assert state.state == 'on' - assert state.attributes.get('brightness') == 'value-brightness' - assert state.attributes.get('color_temp') == 'value-color_temp' - assert state.attributes.get('rgb_color') == 'value-rgb_color' - assert state.attributes.get('xy_color') == 'value-xy_color' - assert state.attributes.get('white_value') == 'value-white_value' - assert state.attributes.get('effect') == 'value-effect' diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 439b272fd4a..2bcd02e69aa 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -9,7 +9,8 @@ from homeassistant.components.media_player import ( 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 +from homeassistant.components.media_player.monoprice import ( + MonopriceZone, PLATFORM_SCHEMA) class MockState(object): diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 01281d189b4..ffd4008f385 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -2,6 +2,8 @@ from copy import copy import unittest +from voluptuous.error import MultipleInvalid + from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, STATE_PLAYING, STATE_PAUSED) import homeassistant.components.switch as switch @@ -14,6 +16,13 @@ from homeassistant.util.async import run_coroutine_threadsafe from tests.common import mock_service, get_test_home_assistant +def validate_config(config): + """Use the platform schema to validate configuration.""" + validated_config = universal.PLATFORM_SCHEMA(config) + validated_config.pop('platform') + return validated_config + + class MockMediaPlayer(media_player.MediaPlayerDevice): """Mock media player for testing.""" @@ -116,9 +125,9 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): """Mock turn_off function.""" self._state = STATE_OFF - def mute_volume(self): + def mute_volume(self, mute): """Mock mute function.""" - self._is_volume_muted = ~self._is_volume_muted + self._is_volume_muted = mute def set_volume_level(self, volume): """Mock set volume level.""" @@ -210,10 +219,8 @@ class TestMediaPlayer(unittest.TestCase): config_start['commands'] = {} config_start['attributes'] = {} - response = universal.validate_config(self.config_children_only) - - self.assertTrue(response) - self.assertEqual(config_start, self.config_children_only) + config = validate_config(self.config_children_only) + self.assertEqual(config_start, config) def test_config_children_and_attr(self): """Check config with children and attributes.""" @@ -221,15 +228,16 @@ class TestMediaPlayer(unittest.TestCase): del config_start['platform'] config_start['commands'] = {} - response = universal.validate_config(self.config_children_and_attr) - - self.assertTrue(response) - self.assertEqual(config_start, self.config_children_and_attr) + config = validate_config(self.config_children_and_attr) + self.assertEqual(config_start, config) def test_config_no_name(self): """Check config with no Name entry.""" - response = universal.validate_config({'platform': 'universal'}) - + response = True + try: + validate_config({'platform': 'universal'}) + except MultipleInvalid: + response = False self.assertFalse(response) def test_config_bad_children(self): @@ -238,36 +246,31 @@ class TestMediaPlayer(unittest.TestCase): config_bad_children = {'name': 'test', 'children': {}, 'platform': 'universal'} - response = universal.validate_config(config_no_children) - self.assertTrue(response) + config_no_children = validate_config(config_no_children) self.assertEqual([], config_no_children['children']) - response = universal.validate_config(config_bad_children) - self.assertTrue(response) + config_bad_children = validate_config(config_bad_children) self.assertEqual([], config_bad_children['children']) def test_config_bad_commands(self): """Check config with bad commands entry.""" - config = {'name': 'test', 'commands': [], 'platform': 'universal'} + config = {'name': 'test', 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertEqual({}, config['commands']) def test_config_bad_attributes(self): """Check config with bad attributes.""" - config = {'name': 'test', 'attributes': [], 'platform': 'universal'} + config = {'name': 'test', 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertEqual({}, config['attributes']) def test_config_bad_key(self): """Check config with bad key.""" config = {'name': 'test', 'asdf': 5, 'platform': 'universal'} - response = universal.validate_config(config) - self.assertTrue(response) + config = validate_config(config) self.assertFalse('asdf' in config) def test_platform_setup(self): @@ -281,21 +284,27 @@ class TestMediaPlayer(unittest.TestCase): for dev in new_entities: entities.append(dev) - run_coroutine_threadsafe( - universal.async_setup_platform(self.hass, bad_config, add_devices), - self.hass.loop).result() + setup_ok = True + try: + run_coroutine_threadsafe( + universal.async_setup_platform( + self.hass, validate_config(bad_config), add_devices), + self.hass.loop).result() + except MultipleInvalid: + setup_ok = False + self.assertFalse(setup_ok) self.assertEqual(0, len(entities)) run_coroutine_threadsafe( - universal.async_setup_platform(self.hass, config, add_devices), + universal.async_setup_platform( + self.hass, validate_config(config), add_devices), self.hass.loop).result() self.assertEqual(1, len(entities)) self.assertEqual('test', entities[0].name) def test_master_state(self): """Test master state property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -303,8 +312,7 @@ class TestMediaPlayer(unittest.TestCase): def test_master_state_with_attrs(self): """Test master state property.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -312,11 +320,26 @@ class TestMediaPlayer(unittest.TestCase): self.hass.states.set(self.mock_state_switch_id, STATE_ON) self.assertEqual(STATE_ON, ump.master_state) + def test_master_state_with_template(self): + """Test the state_template option.""" + config = copy(self.config_children_and_attr) + self.hass.states.set('input_boolean.test', STATE_OFF) + templ = '{% if states.input_boolean.test.state == "off" %}on' \ + '{% else %}{{ states.media_player.mock1.state }}{% endif %}' + config['state_template'] = templ + config = validate_config(config) + + ump = universal.UniversalMediaPlayer(self.hass, **config) + + self.assertEqual(STATE_ON, ump.master_state) + self.hass.states.set('input_boolean.test', STATE_ON) + self.assertEqual(STATE_OFF, ump.master_state) + def test_master_state_with_bad_attrs(self): """Test master state property.""" - config = self.config_children_and_attr + config = copy(self.config_children_and_attr) config['attributes']['state'] = 'bad.entity_id' - universal.validate_config(config) + config = validate_config(config) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -324,8 +347,7 @@ class TestMediaPlayer(unittest.TestCase): def test_active_child_state(self): """Test active child state property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -356,8 +378,7 @@ class TestMediaPlayer(unittest.TestCase): def test_name(self): """Test name property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -365,8 +386,7 @@ class TestMediaPlayer(unittest.TestCase): def test_polling(self): """Test should_poll property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -374,8 +394,7 @@ class TestMediaPlayer(unittest.TestCase): def test_state_children_only(self): """Test media player state with only children.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -391,8 +410,7 @@ class TestMediaPlayer(unittest.TestCase): def test_state_with_children_and_attrs(self): """Test media player with children and master state.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -416,8 +434,7 @@ class TestMediaPlayer(unittest.TestCase): def test_volume_level(self): """Test volume level property.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -439,9 +456,8 @@ class TestMediaPlayer(unittest.TestCase): def test_media_image_url(self): """Test media_image_url property.""" - TEST_URL = "test_url" - config = self.config_children_only - universal.validate_config(config) + test_url = "test_url" + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -450,7 +466,7 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(None, ump.media_image_url) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1._media_image_url = TEST_URL + self.mock_mp_1._media_image_url = test_url self.mock_mp_1.schedule_update_ha_state() self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() @@ -460,8 +476,7 @@ class TestMediaPlayer(unittest.TestCase): def test_is_volume_muted_children_only(self): """Test is volume muted property w/ children only.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -483,8 +498,7 @@ class TestMediaPlayer(unittest.TestCase): def test_source_list_children_and_attr(self): """Test source list property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -495,8 +509,7 @@ class TestMediaPlayer(unittest.TestCase): def test_source_children_and_attr(self): """Test source property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -507,8 +520,7 @@ class TestMediaPlayer(unittest.TestCase): def test_volume_level_children_and_attr(self): """Test volume level property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -519,8 +531,7 @@ class TestMediaPlayer(unittest.TestCase): def test_is_volume_muted_children_and_attr(self): """Test is volume muted property w/ children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) @@ -531,8 +542,7 @@ class TestMediaPlayer(unittest.TestCase): def test_supported_features_children_only(self): """Test supported media commands with only children.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -549,16 +559,19 @@ class TestMediaPlayer(unittest.TestCase): def test_supported_features_children_and_cmds(self): """Test supported media commands with children and attrs.""" - config = self.config_children_and_attr - universal.validate_config(config) - config['commands']['turn_on'] = 'test' - config['commands']['turn_off'] = 'test' - config['commands']['volume_up'] = 'test' - config['commands']['volume_down'] = 'test' - config['commands']['volume_mute'] = 'test' - config['commands']['volume_set'] = 'test' - config['commands']['select_source'] = 'test' - config['commands']['shuffle_set'] = 'test' + config = copy(self.config_children_and_attr) + excmd = {'service': 'media_player.test', 'data': {'entity_id': 'test'}} + config['commands'] = { + 'turn_on': excmd, + 'turn_off': excmd, + 'volume_up': excmd, + 'volume_down': excmd, + 'volume_mute': excmd, + 'volume_set': excmd, + 'select_source': excmd, + 'shuffle_set': excmd + } + config = validate_config(config) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -577,8 +590,7 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_no_active_child(self): """Test a service call to children with no active child.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_and_attr) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -599,8 +611,7 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_to_child(self): """Test service calls that should be routed to a child.""" - config = self.config_children_only - universal.validate_config(config) + config = validate_config(self.config_children_only) ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -699,10 +710,10 @@ class TestMediaPlayer(unittest.TestCase): def test_service_call_to_command(self): """Test service call to command.""" - config = self.config_children_only + config = copy(self.config_children_only) config['commands'] = {'turn_off': { 'service': 'test.turn_off', 'data': {}}} - universal.validate_config(config) + config = validate_config(config) service = mock_service(self.hass, 'test', 'turn_off') diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3d068224243..55ff0e9ff05 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -388,9 +388,12 @@ class TestMQTTCallbacks(unittest.TestCase): @mock.patch('homeassistant.components.mqtt.time.sleep') def test_mqtt_disconnect_tries_reconnect(self, mock_sleep): """Test the re-connect tries.""" - self.hass.data['mqtt'].topics = { + self.hass.data['mqtt'].subscribed_topics = { 'test/topic': 1, - 'test/progress': None + } + self.hass.data['mqtt'].wanted_topics = { + 'test/progress': 0, + 'test/topic': 2, } self.hass.data['mqtt'].progress = { 1: 'test/progress' @@ -403,7 +406,9 @@ class TestMQTTCallbacks(unittest.TestCase): self.assertEqual([1, 2, 4], [call[1][0] for call in mock_sleep.mock_calls]) - self.assertEqual({'test/topic': 1}, self.hass.data['mqtt'].topics) + self.assertEqual({'test/topic': 2, 'test/progress': 0}, + self.hass.data['mqtt'].wanted_topics) + self.assertEqual({}, self.hass.data['mqtt'].subscribed_topics) self.assertEqual({}, self.hass.data['mqtt'].progress) def test_invalid_mqtt_topics(self): @@ -556,12 +561,15 @@ def test_mqtt_subscribes_topics_on_connect(hass): """Test subscription to topic on connect.""" mqtt_client = yield from mock_mqtt_client(hass) - prev_topics = OrderedDict() - prev_topics['topic/test'] = 1, - prev_topics['home/sensor'] = 2, - prev_topics['still/pending'] = None + subscribed_topics = OrderedDict() + subscribed_topics['topic/test'] = 1 + subscribed_topics['home/sensor'] = 2 - hass.data['mqtt'].topics = prev_topics + wanted_topics = subscribed_topics.copy() + wanted_topics['still/pending'] = 0 + + hass.data['mqtt'].wanted_topics = wanted_topics + hass.data['mqtt'].subscribed_topics = subscribed_topics hass.data['mqtt'].progress = {1: 'still/pending'} # Return values for subscribe calls (rc, mid) @@ -574,7 +582,7 @@ def test_mqtt_subscribes_topics_on_connect(hass): assert not mqtt_client.disconnect.called - expected = [(topic, qos) for topic, qos in prev_topics.items() - if qos is not None] + expected = [(topic, qos) for topic, qos in wanted_topics.items()] assert [call[1][1:] for call in hass.add_job.mock_calls] == expected + assert hass.data['mqtt'].progress == {} diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 5aa8afb4f7d..c3998b6db64 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -2,6 +2,7 @@ import asyncio import json from unittest.mock import patch, MagicMock, mock_open +from aiohttp.hdrs import AUTHORIZATION from homeassistant.components.notify import html5 @@ -56,24 +57,13 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): service = html5.get_service(hass, {}) assert service is not None - def test_get_service_with_bad_json(self): - """Test .""" - hass = MagicMock() - - m = mock_open(read_data='I am not JSON') - with patch( - 'homeassistant.components.notify.html5.open', m, create=True - ): - service = html5.get_service(hass, {}) - - assert service is None - @patch('pywebpush.WebPusher') def test_sending_message(self, mock_wp): """Test sending message.""" @@ -85,7 +75,8 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(data)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): service = html5.get_service(hass, {'gcm_sender_id': '100'}) @@ -119,7 +110,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -157,7 +149,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -192,7 +185,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -221,7 +215,7 @@ class TestHtml5Notify(object): })) assert resp.status == 400 - with patch('homeassistant.components.notify.html5._save_config', + with patch('homeassistant.components.notify.html5.save_json', return_value=False): # resp = view.post(Request(builder.get_environ())) resp = yield from client.post(REGISTER_URL, data=json.dumps({ @@ -242,14 +236,12 @@ class TestHtml5Notify(object): } m = mock_open(read_data=json.dumps(config)) - - with patch('homeassistant.components.notify.html5.open', m, - create=True): + with patch( + 'homeassistant.util.json.open', + m, create=True + ): hass.config.path.return_value = 'file.conf' - - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -278,8 +270,8 @@ class TestHtml5Notify(object): assert json.loads(handle.write.call_args[0][0]) == config @asyncio.coroutine - def test_unregister_device_view_handle_unknown_subscription(self, loop, - test_client): + def test_unregister_device_view_handle_unknown_subscription( + self, loop, test_client): """Test that the HTML unregister view handles unknown subscriptions.""" hass = MagicMock() @@ -290,12 +282,11 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(config)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -322,8 +313,8 @@ class TestHtml5Notify(object): assert handle.write.call_count == 0 @asyncio.coroutine - def test_unregistering_device_view_handles_json_safe_error(self, loop, - test_client): + def test_unregistering_device_view_handles_json_safe_error( + self, loop, test_client): """Test that the HTML unregister view handles JSON write errors.""" hass = MagicMock() @@ -334,12 +325,11 @@ class TestHtml5Notify(object): m = mock_open(read_data=json.dumps(config)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {}) + service = html5.get_service(hass, {}) assert service is not None @@ -356,7 +346,7 @@ class TestHtml5Notify(object): client = yield from test_client(app) hass.http.is_banned_ip.return_value = False - with patch('homeassistant.components.notify.html5._save_config', + with patch('homeassistant.components.notify.html5.save_json', return_value=False): resp = yield from client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], @@ -374,7 +364,8 @@ class TestHtml5Notify(object): m = mock_open() with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' service = html5.get_service(hass, {}) @@ -405,17 +396,16 @@ class TestHtml5Notify(object): hass = MagicMock() data = { - 'device': SUBSCRIPTION_1, + 'device': SUBSCRIPTION_1 } m = mock_open(read_data=json.dumps(data)) with patch( - 'homeassistant.components.notify.html5.open', m, create=True + 'homeassistant.util.json.open', + m, create=True ): hass.config.path.return_value = 'file.conf' - with patch('homeassistant.components.notify.html5.os.path.isfile', - return_value=True): - service = html5.get_service(hass, {'gcm_sender_id': '100'}) + service = html5.get_service(hass, {'gcm_sender_id': '100'}) assert service is not None @@ -423,8 +413,8 @@ class TestHtml5Notify(object): assert len(hass.mock_calls) == 3 with patch('pywebpush.WebPusher') as mock_wp: - service.send_message('Hello', target=['device'], - data={'icon': 'beer.png'}) + service.send_message( + 'Hello', target=['device'], data={'icon': 'beer.png'}) assert len(mock_wp.mock_calls) == 3 @@ -453,7 +443,7 @@ class TestHtml5Notify(object): resp = yield from client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', - }), headers={'Authorization': bearer_token}) + }), headers={AUTHORIZATION: bearer_token}) assert resp.status == 200 body = yield from resp.json() diff --git a/tests/components/sensor/test_vultr.py b/tests/components/sensor/test_vultr.py new file mode 100644 index 00000000000..a4e5edc5800 --- /dev/null +++ b/tests/components/sensor/test_vultr.py @@ -0,0 +1,165 @@ +"""The tests for the Vultr sensor platform.""" +import pytest +import unittest +import requests_mock +import voluptuous as vol + +from homeassistant.components.sensor import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION +from homeassistant.const import ( + CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_PLATFORM) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrSensorSetup(unittest.TestCase): + """Test the Vultr platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_NAME: vultr.DEFAULT_NAME, + CONF_SUBSCRIPTION: '576965', + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }, + { + CONF_NAME: 'Server {}', + CONF_SUBSCRIPTION: '123456', + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }, + { + CONF_NAME: 'VPS Charges', + CONF_SUBSCRIPTION: '555555', + CONF_MONITORED_CONDITIONS: [ + 'pending_charges' + ] + } + ] + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_sensor(self, mock): + """Test the Vultr sensor class and methods.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + for config in self.configs: + setup = vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertIsNone(setup) + + self.assertEqual(5, len(self.DEVICES)) + + tested = 0 + + for device in self.DEVICES: + + # Test pre update + if device.subscription == '576965': + self.assertEqual(vultr.DEFAULT_NAME, device.name) + + device.update() + + if device.unit_of_measurement == 'GB': # Test Bandwidth Used + if device.subscription == '576965': + self.assertEqual( + 'Vultr my new server Current Bandwidth Used', + device.name) + self.assertEqual('mdi:chart-histogram', device.icon) + self.assertEqual(131.51, device.state) + self.assertEqual('mdi:chart-histogram', device.icon) + tested += 1 + + elif device.subscription == '123456': + self.assertEqual('Server Current Bandwidth Used', + device.name) + self.assertEqual(957.46, device.state) + tested += 1 + + elif device.unit_of_measurement == 'US$': # Test Pending Charges + + if device.subscription == '576965': # Default 'Vultr {} {}' + self.assertEqual('Vultr my new server Pending Charges', + device.name) + self.assertEqual('mdi:currency-usd', device.icon) + self.assertEqual(46.67, device.state) + self.assertEqual('mdi:currency-usd', device.icon) + tested += 1 + + elif device.subscription == '123456': # Custom name with 1 {} + self.assertEqual('Server Pending Charges', device.name) + self.assertEqual('not a number', device.state) + tested += 1 + + elif device.subscription == '555555': # No {} in name + self.assertEqual('VPS Charges', device.name) + self.assertEqual(5.45, device.state) + tested += 1 + + self.assertEqual(tested, 5) + + def test_invalid_sensor_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS + }) + with pytest.raises(vol.Invalid): # Bad monitored_conditions + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + CONF_SUBSCRIPTION: '123456', + CONF_MONITORED_CONDITIONS: [ + 'non-existent-condition', + ] + }) + + @requests_mock.Mocker() + def test_invalid_sensors(self, mock): + """Test the VultrSensor fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = { + CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS, + } # No subs at all + + no_sub_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(no_sub_setup) + self.assertEqual(0, len(self.DEVICES)) diff --git a/tests/components/switch/test_vultr.py b/tests/components/switch/test_vultr.py new file mode 100644 index 00000000000..53bf6fbec85 --- /dev/null +++ b/tests/components/switch/test_vultr.py @@ -0,0 +1,201 @@ +"""Test the Vultr switch platform.""" +import unittest +import requests_mock +import pytest +import voluptuous as vol + +from homeassistant.components.switch import vultr +from homeassistant.components import vultr as base_vultr +from homeassistant.components.vultr import ( + ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS, + ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID, + CONF_SUBSCRIPTION) +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) + +from tests.components.test_vultr import VALID_CONFIG +from tests.common import ( + get_test_home_assistant, load_fixture) + + +class TestVultrSwitchSetup(unittest.TestCase): + """Test the Vultr switch platform.""" + + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Init values for this testcase class.""" + self.hass = get_test_home_assistant() + self.configs = [ + { + CONF_SUBSCRIPTION: '576965', + CONF_NAME: "A Server" + }, + { + CONF_SUBSCRIPTION: '123456', + CONF_NAME: "Failed Server" + }, + { + CONF_SUBSCRIPTION: '555555', + CONF_NAME: vultr.DEFAULT_NAME + } + ] + + def tearDown(self): + """Stop our started services.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_switch(self, mock): + """Test successful instance.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + # Setup hub + base_vultr.setup(self.hass, VALID_CONFIG) + + # Setup each of our test configs + for config in self.configs: + vultr.setup_platform(self.hass, + config, + self.add_devices, + None) + + self.assertEqual(len(self.DEVICES), 3) + + tested = 0 + + for device in self.DEVICES: + if device.subscription == '555555': + self.assertEqual('Vultr {}', device.name) + tested += 1 + + device.update() + device_attrs = device.device_state_attributes + + if device.subscription == '555555': + self.assertEqual('Vultr Another Server', device.name) + tested += 1 + + if device.name == 'A Server': + self.assertEqual(True, device.is_on) + self.assertEqual('on', device.state) + self.assertEqual('mdi:server', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('yes', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('123.123.123.123', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('10.05', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2013-12-19 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('576965', + device_attrs[ATTR_SUBSCRIPTION_ID]) + tested += 1 + + elif device.name == 'Failed Server': + self.assertEqual(False, device.is_on) + self.assertEqual('off', device.state) + self.assertEqual('mdi:server-off', device.icon) + self.assertEqual('1000', + device_attrs[ATTR_ALLOWED_BANDWIDTH]) + self.assertEqual('no', + device_attrs[ATTR_AUTO_BACKUPS]) + self.assertEqual('192.168.100.50', + device_attrs[ATTR_IPV4_ADDRESS]) + self.assertEqual('73.25', + device_attrs[ATTR_COST_PER_MONTH]) + self.assertEqual('2014-10-13 14:45:41', + device_attrs[ATTR_CREATED_AT]) + self.assertEqual('123456', + device_attrs[ATTR_SUBSCRIPTION_ID]) + tested += 1 + + self.assertEqual(4, tested) + + @requests_mock.Mocker() + def test_turn_on(self, mock): + """Test turning a subscription on.""" + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + mock.post( + 'https://api.vultr.com/v1/server/start?api_key=ABCDEFG1234567') + + for device in self.DEVICES: + if device.name == 'Failed Server': + device.turn_on() + + # Turn on, force date update + self.assertEqual(2, mock.call_count) + + @requests_mock.Mocker() + def test_turn_off(self, mock): + """Test turning a subscription off.""" + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + mock.post( + 'https://api.vultr.com/v1/server/halt?api_key=ABCDEFG1234567') + + for device in self.DEVICES: + if device.name == 'A Server': + device.turn_off() + + # Turn off, force update + self.assertEqual(2, mock.call_count) + + def test_invalid_switch_config(self): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA({ + CONF_PLATFORM: base_vultr.DOMAIN, + }) + + @requests_mock.Mocker() + def test_invalid_switches(self, mock): + """Test the VultrSwitch fails.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + base_vultr.setup(self.hass, VALID_CONFIG) + + bad_conf = {} # No subscription + + no_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(no_subs_setup) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: '665544' + } # Sub not associated with API key (not in server_list) + + wrong_subs_setup = vultr.setup_platform(self.hass, + bad_conf, + self.add_devices, + None) + + self.assertIsNotNone(wrong_subs_setup) diff --git a/tests/components/switch/test_wake_on_lan.py b/tests/components/switch/test_wake_on_lan.py index 063cf93d871..3042535ff42 100644 --- a/tests/components/switch/test_wake_on_lan.py +++ b/tests/components/switch/test_wake_on_lan.py @@ -6,7 +6,7 @@ from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF import homeassistant.components.switch as switch -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_service TEST_STATE = None @@ -141,6 +141,7 @@ class TestWOLSwitch(unittest.TestCase): }, } })) + calls = mock_service(self.hass, 'shell_command', 'turn_off_TARGET') state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_OFF, state.state) @@ -152,6 +153,7 @@ class TestWOLSwitch(unittest.TestCase): state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_ON, state.state) + assert len(calls) == 0 TEST_STATE = False @@ -160,6 +162,7 @@ class TestWOLSwitch(unittest.TestCase): state = self.hass.states.get('switch.wake_on_lan') self.assertEqual(STATE_OFF, state.state) + assert len(calls) == 1 @patch('wakeonlan.wol.send_magic_packet', new=send_magic_packet) @patch('subprocess.call', new=call) diff --git a/tests/components/test_dialogflow.py b/tests/components/test_dialogflow.py index 8275534123c..a52c841e0cc 100644 --- a/tests/components/test_dialogflow.py +++ b/tests/components/test_dialogflow.py @@ -4,6 +4,7 @@ import json import unittest import requests +from aiohttp.hdrs import CONTENT_TYPE from homeassistant.core import callback from homeassistant import setup, const @@ -18,7 +19,7 @@ INTENTS_API_URL = "{}{}".format(BASE_API_URL, dialogflow.INTENTS_API_ENDPOINT) HA_HEADERS = { const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, + CONTENT_TYPE: const.CONTENT_TYPE_JSON, } SESSION_ID = "a9b84cec-46b6-484e-8f31-f65dba03ae6d" diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 1b034cfe940..3d8d2b62a2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -52,7 +52,7 @@ def test_frontend_and_static(mock_http_client): # Test we can retrieve frontend.js frontendjs = re.search( - r'(?P\/static\/frontend-[A-Za-z0-9]{32}.html)', text) + r'(?P\/frontend_es5\/frontend-[A-Za-z0-9]{32}.html)', text) assert frontendjs is not None resp = yield from mock_http_client.get(frontendjs.groups(0)[0]) @@ -63,6 +63,10 @@ def test_frontend_and_static(mock_http_client): @asyncio.coroutine def test_dont_cache_service_worker(mock_http_client): """Test that we don't cache the service worker.""" + resp = yield from mock_http_client.get('/service_worker_es5.js') + assert resp.status == 200 + assert 'cache-control' not in resp.headers + resp = yield from mock_http_client.get('/service_worker.js') assert resp.status == 200 assert 'cache-control' not in resp.headers diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 761ba29e403..3704c486a2a 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -231,7 +231,8 @@ def test_auth_required_forward_request(hassio_client): @asyncio.coroutine -def test_forward_request_no_auth_for_panel(hassio_client): +@pytest.mark.parametrize('build_type', ['es5', 'latest']) +def test_forward_request_no_auth_for_panel(hassio_client, build_type): """Test no auth needed for .""" response = MagicMock() response.read.return_value = mock_coro('data') @@ -240,7 +241,8 @@ def test_forward_request_no_auth_for_panel(hassio_client): Mock(return_value=mock_coro(response))), \ patch('homeassistant.components.hassio._create_response') as mresp: mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/panel') + resp = yield from hassio_client.get( + '/api/hassio/panel_{}'.format(build_type)) # Check we got right response assert resp.status == 200 diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index af664f36a53..5d3f1782831 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -102,7 +102,7 @@ def test_set_datetime_time(hass): @asyncio.coroutine def test_set_invalid(hass): """Test set_datetime method with only time.""" - initial = datetime.datetime(2017, 1, 1, 0, 0) + initial = '2017-01-01' yield from async_setup_component(hass, DOMAIN, { DOMAIN: { 'test_date': { @@ -124,7 +124,7 @@ def test_set_invalid(hass): yield from hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == str(initial.date()) + assert state.state == initial @asyncio.coroutine @@ -159,8 +159,8 @@ def test_set_datetime_date(hass): def test_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache(hass, ( - State('input_datetime.test_time', '2017-09-07 19:46:00'), - State('input_datetime.test_date', '2017-09-07 19:46:00'), + State('input_datetime.test_time', '19:46:00'), + State('input_datetime.test_date', '2017-09-07'), State('input_datetime.test_datetime', '2017-09-07 19:46:00'), State('input_datetime.test_bogus_data', 'this is not a date'), )) diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index cc1ea277a34..76d8e48d03a 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -25,7 +25,8 @@ class TestMqttStateStream(object): self.hass.stop() def add_statestream(self, base_topic=None, publish_attributes=None, - publish_timestamps=None): + publish_timestamps=None, publish_include=None, + publish_exclude=None): """Add a mqtt_statestream component.""" config = {} if base_topic: @@ -34,7 +35,10 @@ class TestMqttStateStream(object): config['publish_attributes'] = publish_attributes if publish_timestamps: config['publish_timestamps'] = publish_timestamps - print("Publishing timestamps") + if publish_include: + config['include'] = publish_include + if publish_exclude: + config['exclude'] = publish_exclude return setup_component(self.hass, statestream.DOMAIN, { statestream.DOMAIN: config}) @@ -152,3 +156,237 @@ class TestMqttStateStream(object): mock_pub.assert_has_calls(calls, any_order=True) assert mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub): + """"Test that filtering on included domain works as expected.""" + base_topic = 'pub' + + incl = { + 'domains': ['fake'] + } + excl = {} + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake2.entity', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub): + """"Test that filtering on included entity works as expected.""" + base_topic = 'pub' + + incl = { + 'entities': ['fake.entity'] + } + excl = {} + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub): + """"Test that filtering on excluded domain works as expected.""" + base_topic = 'pub' + + incl = {} + excl = { + 'domains': ['fake2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake2.entity', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub): + """"Test that filtering on excluded entity works as expected.""" + base_topic = 'pub' + + incl = {} + excl = { + 'entities': ['fake.entity2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_exclude_domain_include_entity( + self, mock_utcnow, mock_pub): + """"Test filtering with excluded domain and included entity.""" + base_topic = 'pub' + + incl = { + 'entities': ['fake.entity'] + } + excl = { + 'domains': ['fake'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + @patch('homeassistant.core.dt_util.utcnow') + def test_state_changed_event_include_domain_exclude_entity( + self, mock_utcnow, mock_pub): + """"Test filtering with included domain and excluded entity.""" + base_topic = 'pub' + + incl = { + 'domains': ['fake'] + } + excl = { + 'entities': ['fake.entity2'] + } + + # Add the statestream component for publishing state updates + # Set the filter to allow fake.* items + assert self.add_statestream(base_topic=base_topic, + publish_include=incl, + publish_exclude=excl) + self.hass.block_till_done() + + # Reset the mock because it will have already gotten calls for the + # mqtt_statestream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State('fake.entity', 'on')) + self.hass.block_till_done() + + # Make sure 'on' was published to pub/fake/entity/state + mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on', + 1, True) + assert mock_pub.called + + mock_pub.reset_mock() + # Set a state of an entity that shouldn't be included + mock_state_change_event(self.hass, State('fake.entity2', 'on')) + self.hass.block_till_done() + + assert not mock_pub.called diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 00c824418be..805d73e1820 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -33,8 +33,8 @@ class TestPanelIframe(unittest.TestCase): 'panel_iframe': conf }) - @patch.dict('hass_frontend.FINGERPRINTS', - {'panels/ha-panel-iframe.html': 'md5md5'}) + @patch.dict('hass_frontend_es5.FINGERPRINTS', + {'iframe': 'md5md5'}) def test_correct_config(self): """Test correct config.""" assert setup.setup_component( @@ -55,20 +55,20 @@ class TestPanelIframe(unittest.TestCase): panels = self.hass.data[frontend.DATA_PANELS] - assert panels.get('router').as_dict() == { + assert panels.get('router').to_response(self.hass, None) == { 'component_name': 'iframe', 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url': '/static/panels/ha-panel-iframe-md5md5.html', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'router' } - assert panels.get('weather').as_dict() == { + assert panels.get('weather').to_response(self.hass, None) == { 'component_name': 'iframe', 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'}, 'icon': 'mdi:weather', 'title': 'Weather', - 'url': '/static/panels/ha-panel-iframe-md5md5.html', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'weather', } diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index e5d6b0c4aad..8a7f94d7dcd 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -209,6 +209,27 @@ hass.states.set('hello.ab_list', '{}'.format(ab_list)) assert caplog.text == '' +@asyncio.coroutine +def test_execute_sorted(hass, caplog): + """Test sorted() function.""" + caplog.set_level(logging.ERROR) + source = """ +a = sorted([3,1,2]) +assert(a == [1,2,3]) +hass.states.set('hello.a', a[0]) +hass.states.set('hello.b', a[1]) +hass.states.set('hello.c', a[2]) +""" + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('hello.a', '1') + assert hass.states.is_state('hello.b', '2') + assert hass.states.is_state('hello.c', '3') + # No errors logged = good + assert caplog.text == '' + + @asyncio.coroutine def test_exposed_modules(hass, caplog): """Test datetime and time modules exposed.""" diff --git a/tests/components/test_spc.py b/tests/components/test_spc.py index 6fae8d821c2..7837abd8007 100644 --- a/tests/components/test_spc.py +++ b/tests/components/test_spc.py @@ -7,7 +7,9 @@ from homeassistant.components import spc from homeassistant.bootstrap import async_setup_component from tests.common import async_test_home_assistant from tests.test_util.aiohttp import mock_aiohttp_client -from homeassistant.const import (STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @pytest.fixture @@ -57,7 +59,13 @@ def aioclient_mock(): @asyncio.coroutine -def test_update_alarm_device(hass, aioclient_mock, monkeypatch): +@pytest.mark.parametrize("sia_code,state", [ + ('NL', STATE_ALARM_ARMED_HOME), + ('CG', STATE_ALARM_ARMED_AWAY), + ('OG', STATE_ALARM_DISARMED) +]) +def test_update_alarm_device(hass, aioclient_mock, monkeypatch, + sia_code, state): """Test that alarm panel state changes on incoming websocket data.""" monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." "start_listener", lambda x, *args: None) @@ -65,8 +73,8 @@ def test_update_alarm_device(hass, aioclient_mock, monkeypatch): 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' - } } + } yield from async_setup_component(hass, 'spc', config) yield from hass.async_block_till_done() @@ -74,38 +82,48 @@ def test_update_alarm_device(hass, aioclient_mock, monkeypatch): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - msg = {"sia_code": "NL", "sia_address": "1", "description": "House|Sam|1"} + msg = {"sia_code": sia_code, "sia_address": "1", + "description": "House¦Sam¦1"} yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME + yield from hass.async_block_till_done() - msg = {"sia_code": "OQ", "sia_address": "1", "description": "Sam"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED + state_obj = hass.states.get(entity_id) + assert state_obj.state == state + assert state_obj.attributes['changed_by'] == 'Sam' @asyncio.coroutine -def test_update_sensor_device(hass, aioclient_mock, monkeypatch): - """Test that sensors change state on incoming websocket data.""" +@pytest.mark.parametrize("sia_code,state", [ + ('ZO', STATE_ON), + ('ZC', STATE_OFF) +]) +def test_update_sensor_device(hass, aioclient_mock, monkeypatch, + sia_code, state): + """ + Test that sensors change state on incoming websocket data. + + Note that we don't test for the ZD (disconnected) and ZX (problem/short) + codes since the binary sensor component is hardcoded to only + let on/off states through. + """ monkeypatch.setattr("homeassistant.components.spc.SpcWebGateway." "start_listener", lambda x, *args: None) config = { 'spc': { 'api_url': 'http://localhost/', 'ws_url': 'ws://localhost/' - } } + } yield from async_setup_component(hass, 'spc', config) yield from hass.async_block_till_done() - assert hass.states.get('binary_sensor.hallway_pir').state == 'off' + assert hass.states.get('binary_sensor.hallway_pir').state == STATE_OFF - msg = {"sia_code": "ZO", "sia_address": "3", "description": "Hallway PIR"} + msg = {"sia_code": sia_code, "sia_address": "3", + "description": "Hallway PIR"} yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get('binary_sensor.hallway_pir').state == 'on' - - msg = {"sia_code": "ZC", "sia_address": "3", "description": "Hallway PIR"} - yield from spc._async_process_message(msg, hass.data[spc.DATA_REGISTRY]) - assert hass.states.get('binary_sensor.hallway_pir').state == 'off' + yield from hass.async_block_till_done() + assert hass.states.get('binary_sensor.hallway_pir').state == state class TestSpcRegistry: @@ -139,7 +157,7 @@ class TestSpcWebGateway: ('set', spc.SpcWebGateway.AREA_COMMAND_SET), ('unset', spc.SpcWebGateway.AREA_COMMAND_UNSET), ('set_a', spc.SpcWebGateway.AREA_COMMAND_PART_SET) - ]) + ]) def test_area_commands(self, spcwebgw, url_command, command): """Test alarm arming/disarming.""" with mock_aiohttp_client() as aioclient_mock: diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py new file mode 100644 index 00000000000..b86c768fb42 --- /dev/null +++ b/tests/components/test_system_log.py @@ -0,0 +1,112 @@ +"""Test system log component.""" +import asyncio +import logging +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import system_log + +_LOGGER = logging.getLogger('test_logger') + + +@pytest.fixture(autouse=True) +@asyncio.coroutine +def setup_test_case(hass): + """Setup system_log component before test case.""" + config = {'system_log': {'max_entries': 2}} + yield from async_setup_component(hass, system_log.DOMAIN, config) + + +@asyncio.coroutine +def get_error_log(hass, test_client, expected_count): + """Fetch all entries from system_log via the API.""" + client = yield from test_client(hass.http.app) + resp = yield from client.get('/api/error/all') + assert resp.status == 200 + + data = yield from resp.json() + assert len(data) == expected_count + return data + + +def _generate_and_log_exception(exception, log): + try: + raise Exception(exception) + except: # pylint: disable=bare-except + _LOGGER.exception(log) + + +def assert_log(log, exception, message, level): + """Assert that specified values are in a specific log entry.""" + assert exception in log['exception'] + assert message == log['message'] + assert level == log['level'] + assert log['source'] == 'unknown' # always unkown in tests + assert 'timestamp' in log + + +@asyncio.coroutine +def test_normal_logs(hass, test_client): + """Test that debug and info are not logged.""" + _LOGGER.debug('debug') + _LOGGER.info('info') + + # Assert done by get_error_log + yield from get_error_log(hass, test_client, 0) + + +@asyncio.coroutine +def test_exception(hass, test_client): + """Test that exceptions are logged and retrieved correctly.""" + _generate_and_log_exception('exception message', 'log message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, 'exception message', 'log message', 'ERROR') + + +@asyncio.coroutine +def test_warning(hass, test_client): + """Test that warning are logged and retrieved correctly.""" + _LOGGER.warning('warning message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'warning message', 'WARNING') + + +@asyncio.coroutine +def test_error(hass, test_client): + """Test that errors are logged and retrieved correctly.""" + _LOGGER.error('error message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'error message', 'ERROR') + + +@asyncio.coroutine +def test_critical(hass, test_client): + """Test that critical are logged and retrieved correctly.""" + _LOGGER.critical('critical message') + log = (yield from get_error_log(hass, test_client, 1))[0] + assert_log(log, '', 'critical message', 'CRITICAL') + + +@asyncio.coroutine +def test_remove_older_logs(hass, test_client): + """Test that older logs are rotated out.""" + _LOGGER.error('error message 1') + _LOGGER.error('error message 2') + _LOGGER.error('error message 3') + log = yield from get_error_log(hass, test_client, 2) + assert_log(log[0], '', 'error message 3', 'ERROR') + assert_log(log[1], '', 'error message 2', 'ERROR') + + +@asyncio.coroutine +def test_clear_logs(hass, test_client): + """Test that the log can be cleared via a service call.""" + _LOGGER.error('error message') + + hass.async_add_job( + hass.services.async_call( + system_log.DOMAIN, system_log.SERVICE_CLEAR, {})) + yield from hass.async_block_till_done() + + # Assert done by get_error_log + yield from get_error_log(hass, test_client, 0) diff --git a/tests/components/test_vultr.py b/tests/components/test_vultr.py new file mode 100644 index 00000000000..b504c320dc8 --- /dev/null +++ b/tests/components/test_vultr.py @@ -0,0 +1,48 @@ +"""The tests for the Vultr component.""" +import unittest +import requests_mock + +from copy import deepcopy +from homeassistant import setup +import homeassistant.components.vultr as vultr + +from tests.common import ( + get_test_home_assistant, load_fixture) + +VALID_CONFIG = { + 'vultr': { + 'api_key': 'ABCDEFG1234567' + } +} + + +class TestVultr(unittest.TestCase): + """Tests the Vultr component.""" + + def setUp(self): + """Initialize values for this test case class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that we started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_setup(self, mock): + """Test successful setup.""" + mock.get( + 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567', + text=load_fixture('vultr_account_info.json')) + mock.get( + 'https://api.vultr.com/v1/server/list?api_key=ABCDEFG1234567', + text=load_fixture('vultr_server_list.json')) + + response = vultr.setup(self.hass, self.config) + self.assertTrue(response) + + def test_setup_no_api_key(self): + """Test failed setup with missing API Key.""" + conf = deepcopy(self.config) + del conf['vultr']['api_key'] + assert not setup.setup_component(self.hass, vultr.DOMAIN, conf) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index c310b0d5445..8b6c7494214 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -290,7 +290,7 @@ def test_get_panels(hass, websocket_client): """Test get_panels command.""" yield from hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:account-location') - + hass.data[frontend.DATA_JS_VERSION] = 'es5' websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_PANELS, @@ -300,8 +300,14 @@ def test_get_panels(hass, websocket_client): assert msg['id'] == 5 assert msg['type'] == wapi.TYPE_RESULT assert msg['success'] - assert msg['result'] == {url: panel.as_dict() for url, panel - in hass.data[frontend.DATA_PANELS].items()} + assert msg['result'] == {'map': { + 'component_name': 'map', + 'url_path': 'map', + 'config': None, + 'url': None, + 'icon': 'mdi:account-location', + 'title': 'Map', + }} @asyncio.coroutine diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index 1ed92f34ebe..e08229631cf 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -363,3 +363,40 @@ class TestTTSYandexPlatform(object): assert len(aioclient_mock.mock_calls) == 1 assert len(calls) == 1 + + def test_service_say_specified_options(self, aioclient_mock): + """Test service call say with options.""" + calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + 'text': 'HomeAssistant', + 'lang': 'en-US', + 'key': '1234567xx', + 'speaker': 'zahar', + 'format': 'mp3', + 'emotion': 'evil', + 'speed': 2 + } + aioclient_mock.get( + self._base_url, status=200, content=b'test', params=url_param) + config = { + tts.DOMAIN: { + 'platform': 'yandextts', + 'api_key': '1234567xx', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.services.call(tts.DOMAIN, 'yandextts_say', { + tts.ATTR_MESSAGE: "HomeAssistant", + 'options': { + 'emotion': 'evil', + 'speed': 2, + } + }) + self.hass.block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py index 1563dd377c4..9d22b1ad0ae 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/weather/test_weather.py @@ -37,7 +37,7 @@ class TestWeather(unittest.TestCase): assert state.state == 'sunny' data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == 21 + assert data.get(ATTR_WEATHER_TEMPERATURE) == 21.6 assert data.get(ATTR_WEATHER_HUMIDITY) == 92 assert data.get(ATTR_WEATHER_PRESSURE) == 1099 assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5 @@ -57,4 +57,4 @@ class TestWeather(unittest.TestCase): assert state.state == 'rainy' data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4 + assert data.get(ATTR_WEATHER_TEMPERATURE) == -24 diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 1e759949a46..ce2795297a2 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -1253,3 +1253,27 @@ class TestZWaveServices(unittest.TestCase): assert node.refresh_info.called assert len(node.refresh_info.mock_calls) == 1 + + def test_heal_node(self): + """Test zwave heal_node service.""" + node = MockNode(node_id=19) + self.zwave_network.nodes = {19: node} + self.hass.services.call('zwave', 'heal_node', { + const.ATTR_NODE_ID: 19, + }) + self.hass.block_till_done() + + assert node.heal.called + assert len(node.heal.mock_calls) == 1 + + def test_test_node(self): + """Test the zwave test_node service.""" + node = MockNode(node_id=19) + self.zwave_network.nodes = {19: node} + self.hass.services.call('zwave', 'test_node', { + const.ATTR_NODE_ID: 19, + }) + self.hass.block_till_done() + + assert node.test.called + assert len(node.test.mock_calls) == 1 diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 32351234ad3..e4afca31740 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -330,38 +330,34 @@ class TestZWaveNodeEntity(unittest.TestCase): """Test state property.""" self.node.is_ready = False self.entity.node_changed() - self.assertEqual('Dynamic', self.entity.state) + self.assertEqual('initializing', self.entity.state) self.node.is_failed = True + self.node.query_stage = 'Complete' self.entity.node_changed() - self.assertEqual('Dead (Dynamic)', self.entity.state) + self.assertEqual('dead', self.entity.state) self.node.is_failed = False self.node.is_awake = False self.entity.node_changed() - self.assertEqual('Sleeping (Dynamic)', self.entity.state) + self.assertEqual('sleeping', self.entity.state) def test_state_ready(self): """Test state property.""" + self.node.query_stage = 'Complete' self.node.is_ready = True self.entity.node_changed() - self.assertEqual('Ready', self.entity.state) + self.assertEqual('ready', self.entity.state) self.node.is_failed = True self.entity.node_changed() - self.assertEqual('Dead', self.entity.state) + self.assertEqual('dead', self.entity.state) self.node.is_failed = False self.node.is_awake = False self.entity.node_changed() - self.assertEqual('Sleeping', self.entity.state) + self.assertEqual('sleeping', self.entity.state) def test_not_polled(self): """Test should_poll property.""" self.assertFalse(self.entity.should_poll) - - -def test_sub_status(): - """Test sub_status function.""" - assert node_entity.sub_status('Status', 'Stage') == 'Status (Stage)' - assert node_entity.sub_status('Status', '') == 'Status' diff --git a/tests/fixtures/vultr_account_info.json b/tests/fixtures/vultr_account_info.json new file mode 100644 index 00000000000..beab9534fc3 --- /dev/null +++ b/tests/fixtures/vultr_account_info.json @@ -0,0 +1 @@ +{"balance":"-123.00","pending_charges":"3.38","last_payment_date":"2017-08-11 15:04:04","last_payment_amount":"-10.00"} diff --git a/tests/fixtures/vultr_server_list.json b/tests/fixtures/vultr_server_list.json new file mode 100644 index 00000000000..99955e332ec --- /dev/null +++ b/tests/fixtures/vultr_server_list.json @@ -0,0 +1,122 @@ +{ + "576965": { + "SUBID": "576965", + "os": "CentOS 6 x64", + "ram": "4096 MB", + "disk": "Virtual 60 GB", + "main_ip": "123.123.123.123", + "vcpu_count": "2", + "location": "New Jersey", + "DCID": "1", + "default_password": "nreqnusibni", + "date_created": "2013-12-19 14:45:41", + "pending_charges": "46.67", + "status": "active", + "cost_per_month": "10.05", + "current_bandwidth_gb": 131.512, + "allowed_bandwidth_gb": "1000", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "running", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "my new server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "yes", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + }, + "123456": { + "SUBID": "123456", + "os": "CentOS 6 x64", + "ram": "4096 MB", + "disk": "Virtual 60 GB", + "main_ip": "192.168.100.50", + "vcpu_count": "2", + "location": "New Jersey", + "DCID": "1", + "default_password": "nreqnusibni", + "date_created": "2014-10-13 14:45:41", + "pending_charges": "not a number", + "status": "active", + "cost_per_month": "73.25", + "current_bandwidth_gb": 957.457, + "allowed_bandwidth_gb": "1000", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "halted", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "my failed server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "no", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + }, + "555555": { + "SUBID": "555555", + "os": "CentOS 7 x64", + "ram": "1024 MB", + "disk": "Virtual 30 GB", + "main_ip": "192.168.250.50", + "vcpu_count": "1", + "location": "London", + "DCID": "7", + "default_password": "password", + "date_created": "2014-10-15 14:45:41", + "pending_charges": "5.45", + "status": "active", + "cost_per_month": "73.25", + "current_bandwidth_gb": 57.457, + "allowed_bandwidth_gb": "100", + "netmask_v4": "255.255.255.248", + "gateway_v4": "123.123.123.1", + "power_status": "halted", + "server_state": "ok", + "VPSPLANID": "28", + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64", + "v6_networks": [ + { + "v6_network": "2001:DB8:1000::", + "v6_main_ip": "2001:DB8:1000::100", + "v6_network_size": "64" + } + ], + "label": "Another Server", + "internal_ip": "10.99.0.10", + "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV", + "auto_backups": "no", + "tag": "mytag", + "OSID": "127", + "APPID": "0", + "FIREWALLGROUPID": "0" + } +} diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index d7f518f489e..a4c8b03daa0 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -100,22 +100,6 @@ class TestHelpersEntity(object): fmt, 'overwrite hidden true', hass=self.hass) == 'test.overwrite_hidden_true_2' - def test_update_calls_async_update_if_available(self): - """Test async update getting called.""" - async_update = [] - - class AsyncEntity(entity.Entity): - hass = self.hass - entity_id = 'sensor.test' - - @asyncio.coroutine - def async_update(self): - async_update.append([1]) - - ent = AsyncEntity() - ent.update() - assert len(async_update) == 1 - def test_device_class(self): """Test device class attribute.""" state = self.hass.states.get(self.entity.entity_id) diff --git a/tests/helpers/test_temperature.py b/tests/helpers/test_temperature.py new file mode 100644 index 00000000000..96e7bd6c74f --- /dev/null +++ b/tests/helpers/test_temperature.py @@ -0,0 +1,49 @@ +"""Tests Home Assistant temperature helpers.""" +import unittest + +from tests.common import get_test_home_assistant + +from homeassistant.const import ( + TEMP_CELSIUS, PRECISION_WHOLE, TEMP_FAHRENHEIT, PRECISION_HALVES, + PRECISION_TENTHS) +from homeassistant.helpers.temperature import display_temp +from homeassistant.util.unit_system import METRIC_SYSTEM + +TEMP = 24.636626 + + +class TestHelpersTemperature(unittest.TestCase): + """Setup the temperature tests.""" + + def setUp(self): + """Setup the tests.""" + self.hass = get_test_home_assistant() + self.hass.config.unit_system = METRIC_SYSTEM + + def tearDown(self): + """Stop down stuff we started.""" + self.hass.stop() + + def test_temperature_not_a_number(self): + """Test that temperature is a number.""" + temp = "Temperature" + with self.assertRaises(Exception) as context: + display_temp(self.hass, temp, TEMP_CELSIUS, PRECISION_HALVES) + + self.assertTrue("Temperature is not a number: {}".format(temp) + in str(context.exception)) + + def test_celsius_halves(self): + """Test temperature to celsius rounding to halves.""" + self.assertEqual(24.5, display_temp( + self.hass, TEMP, TEMP_CELSIUS, PRECISION_HALVES)) + + def test_celsius_tenths(self): + """Test temperature to celsius rounding to tenths.""" + self.assertEqual(24.6, display_temp( + self.hass, TEMP, TEMP_CELSIUS, PRECISION_TENTHS)) + + def test_fahrenheit_wholes(self): + """Test temperature to fahrenheit rounding to wholes.""" + self.assertEqual(-4, display_temp( + self.hass, TEMP, TEMP_FAHRENHEIT, PRECISION_WHOLE)) diff --git a/tests/test_core.py b/tests/test_core.py index c3fea749f5d..09ddf721628 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -495,18 +495,6 @@ class TestStateMachine(unittest.TestCase): self.assertFalse(self.states.is_state('light.Bowl', 'off')) self.assertFalse(self.states.is_state('light.Non_existing', 'on')) - def test_is_state_attr(self): - """Test is_state_attr method.""" - self.states.set("light.Bowl", "on", {"brightness": 100}) - self.assertTrue( - self.states.is_state_attr('light.Bowl', 'brightness', 100)) - self.assertFalse( - self.states.is_state_attr('light.Bowl', 'friendly_name', 200)) - self.assertFalse( - self.states.is_state_attr('light.Bowl', 'friendly_name', 'Bowl')) - self.assertFalse( - self.states.is_state_attr('light.Non_existing', 'brightness', 100)) - def test_entity_ids(self): """Test get_entity_ids method.""" ent_ids = self.states.entity_ids() diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 50e271008a2..38b957ad102 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -267,6 +267,10 @@ class TestYaml(unittest.TestCase): """The that the dump method returns empty None values.""" assert yaml.dump({'a': None, 'b': 'b'}) == 'a:\nb: b\n' + def test_dump_unicode(self): + """The that the dump method returns empty None values.""" + assert yaml.dump({'a': None, 'b': 'привет'}) == 'a:\nb: привет\n' + FILES = {}