From 519d9f2fd01751492909e574b8ba3812487d7d6b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Oct 2016 23:48:01 -0700 Subject: [PATCH] async HTTP component (#3914) * Migrate WSGI to asyncio * Rename wsgi -> http * Python 3.4 compat * Move linting to Python 3.4 * lint * Lint * Fix Python 3.4 mock_open + binary data * Surpress logging aiohttp.access * Spelling * Sending files is a coroutine * More callback annotations and naming fixes * Fix ios --- .travis.yml | 6 +- homeassistant/bootstrap.py | 1 + homeassistant/components/alexa.py | 32 +- homeassistant/components/api.py | 195 ++++--- .../components/binary_sensor/ffmpeg.py | 2 +- homeassistant/components/camera/__init__.py | 121 +++-- homeassistant/components/camera/ffmpeg.py | 63 ++- homeassistant/components/camera/generic.py | 57 +- homeassistant/components/camera/mjpeg.py | 71 ++- .../components/device_tracker/locative.py | 26 +- homeassistant/components/emulated_hue.py | 112 ++-- homeassistant/components/ffmpeg.py | 28 +- homeassistant/components/foursquare.py | 31 +- homeassistant/components/frontend/__init__.py | 54 +- homeassistant/components/history.py | 31 +- homeassistant/components/http.py | 511 ++++++++---------- homeassistant/components/ios.py | 2 +- homeassistant/components/logbook.py | 36 +- .../components/media_player/__init__.py | 34 +- homeassistant/components/notify/html5.py | 41 +- homeassistant/components/openalpr.py | 2 +- .../components/persistent_notification.py | 12 +- homeassistant/components/sensor/fitbit.py | 11 +- homeassistant/components/sensor/torque.py | 15 +- homeassistant/components/switch/netio.py | 10 +- homeassistant/helpers/state.py | 7 +- homeassistant/remote.py | 34 +- requirements_all.txt | 15 +- requirements_test.txt | 2 + setup.py | 2 + tests/__init__.py | 30 +- tests/common.py | 56 +- tests/components/camera/test_generic.py | 142 +++-- tests/components/camera/test_local_file.py | 84 ++- tests/components/camera/test_uvc.py | 2 +- tests/components/media_player/test_demo.py | 87 +-- tests/components/notify/test_html5.py | 171 +++--- tests/components/sensor/test_yr.py | 23 +- tests/components/test_api.py | 24 +- tests/components/test_frontend.py | 6 +- tests/components/test_http.py | 12 +- tests/components/test_influxdb.py | 3 + tests/conftest.py | 59 ++ tests/helpers/test_state.py | 62 ++- tests/test_util/aiohttp.py | 112 ++++ 45 files changed, 1425 insertions(+), 1012 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_util/aiohttp.py diff --git a/.travis.yml b/.travis.yml index 3d575c1d7780eb..9cf13f2c831832 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,11 @@ sudo: false matrix: fast_finish: true include: - - python: "3.4" + - python: "3.4.2" env: TOXENV=py34 - - python: "3.4" + - python: "3.4.2" env: TOXENV=requirements - - python: "3.5" + - python: "3.4.2" env: TOXENV=lint - python: "3.5" env: TOXENV=typing diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 8ad4e16c8cde71..0eca105952f2d7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -359,6 +359,7 @@ def enable_logging(hass: core.HomeAssistant, verbose: bool=False, # suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) try: from colorlog import ColoredFormatter diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 64ff50af32372e..3093b0eb12f4b6 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/alexa/ """ +import asyncio import copy import enum import logging @@ -12,6 +13,7 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import template, script, config_validation as cv from homeassistant.components.http import HomeAssistantView @@ -20,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) INTENTS_API_ENDPOINT = '/api/alexa' -FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/' +FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}' CONF_ACTION = 'action' CONF_CARD = 'card' @@ -102,8 +104,8 @@ def setup(hass, config): intents = config[DOMAIN].get(CONF_INTENTS, {}) flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.wsgi.register_view(AlexaIntentsView(hass, intents)) - hass.wsgi.register_view(AlexaFlashBriefingView(hass, flash_briefings)) + hass.http.register_view(AlexaIntentsView(hass, intents)) + hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) return True @@ -128,9 +130,10 @@ def __init__(self, hass, intents): self.intents = intents + @asyncio.coroutine def post(self, request): """Handle Alexa.""" - data = request.json + data = yield from request.json() _LOGGER.debug('Received Alexa request: %s', data) @@ -176,7 +179,7 @@ def post(self, request): action = config.get(CONF_ACTION) if action is not None: - action.run(response.variables) + yield from action.async_run(response.variables) # pylint: disable=unsubscriptable-object if speech is not None: @@ -218,8 +221,8 @@ def add_card(self, card_type, title, content): self.card = card return - card["title"] = title.render(self.variables) - card["content"] = content.render(self.variables) + card["title"] = title.async_render(self.variables) + card["content"] = content.async_render(self.variables) self.card = card def add_speech(self, speech_type, text): @@ -229,7 +232,7 @@ def add_speech(self, speech_type, text): key = 'ssml' if speech_type == SpeechType.ssml else 'text' if isinstance(text, template.Template): - text = text.render(self.variables) + text = text.async_render(self.variables) self.speech = { 'type': speech_type.value, @@ -244,7 +247,7 @@ def add_reprompt(self, speech_type, text): self.reprompt = { 'type': speech_type.value, - key: text.render(self.variables) + key: text.async_render(self.variables) } def as_dict(self): @@ -284,6 +287,7 @@ def __init__(self, hass, flash_briefings): template.attach(hass, self.flash_briefings) # pylint: disable=too-many-branches + @callback def get(self, request, briefing_id): """Handle Alexa Flash Briefing request.""" _LOGGER.debug('Received Alexa flash briefing request for: %s', @@ -292,7 +296,7 @@ def get(self, request, briefing_id): if self.flash_briefings.get(briefing_id) is None: err = 'No configured Alexa flash briefing was found for: %s' _LOGGER.error(err, briefing_id) - return self.Response(status=404) + return b'', 404 briefing = [] @@ -300,13 +304,13 @@ def get(self, request, briefing_id): output = {} if item.get(CONF_TITLE) is not None: if isinstance(item.get(CONF_TITLE), template.Template): - output[ATTR_TITLE_TEXT] = item[CONF_TITLE].render() + output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render() else: output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE) if item.get(CONF_TEXT) is not None: if isinstance(item.get(CONF_TEXT), template.Template): - output[ATTR_MAIN_TEXT] = item[CONF_TEXT].render() + output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render() else: output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT) @@ -315,7 +319,7 @@ def get(self, request, briefing_id): if item.get(CONF_AUDIO) is not None: if isinstance(item.get(CONF_AUDIO), template.Template): - output[ATTR_STREAM_URL] = item[CONF_AUDIO].render() + output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render() else: output[ATTR_STREAM_URL] = item.get(CONF_AUDIO) @@ -323,7 +327,7 @@ def get(self, request, briefing_id): if isinstance(item.get(CONF_DISPLAY_URL), template.Template): output[ATTR_REDIRECTION_URL] = \ - item[CONF_DISPLAY_URL].render() + item[CONF_DISPLAY_URL].async_render() else: output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 5eb28c53a34d8f..ae5e1de7c1ba2d 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -7,7 +7,9 @@ import asyncio import json import logging -import queue + +from aiohttp import web +import async_timeout import homeassistant.core as ha import homeassistant.remote as rem @@ -21,7 +23,7 @@ URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.state import TrackStates +from homeassistant.helpers.state import AsyncTrackStates from homeassistant.helpers import template from homeassistant.components.http import HomeAssistantView @@ -36,20 +38,20 @@ def setup(hass, config): """Register the API with the HTTP interface.""" - hass.wsgi.register_view(APIStatusView) - hass.wsgi.register_view(APIEventStream) - hass.wsgi.register_view(APIConfigView) - hass.wsgi.register_view(APIDiscoveryView) - hass.wsgi.register_view(APIStatesView) - hass.wsgi.register_view(APIEntityStateView) - hass.wsgi.register_view(APIEventListenersView) - hass.wsgi.register_view(APIEventView) - hass.wsgi.register_view(APIServicesView) - hass.wsgi.register_view(APIDomainServicesView) - hass.wsgi.register_view(APIEventForwardingView) - hass.wsgi.register_view(APIComponentsView) - hass.wsgi.register_view(APIErrorLogView) - hass.wsgi.register_view(APITemplateView) + hass.http.register_view(APIStatusView) + hass.http.register_view(APIEventStream) + hass.http.register_view(APIConfigView) + hass.http.register_view(APIDiscoveryView) + hass.http.register_view(APIStatesView) + hass.http.register_view(APIEntityStateView) + hass.http.register_view(APIEventListenersView) + hass.http.register_view(APIEventView) + hass.http.register_view(APIServicesView) + hass.http.register_view(APIDomainServicesView) + hass.http.register_view(APIEventForwardingView) + hass.http.register_view(APIComponentsView) + hass.http.register_view(APIErrorLogView) + hass.http.register_view(APITemplateView) return True @@ -60,6 +62,7 @@ class APIStatusView(HomeAssistantView): url = URL_API name = "api:status" + @ha.callback def get(self, request): """Retrieve if API is running.""" return self.json_message('API running.') @@ -71,12 +74,13 @@ class APIEventStream(HomeAssistantView): url = URL_API_STREAM name = "api:stream" + @asyncio.coroutine def get(self, request): """Provide a streaming interface for the event bus.""" stop_obj = object() - to_write = queue.Queue() + to_write = asyncio.Queue(loop=self.hass.loop) - restrict = request.args.get('restrict') + restrict = request.GET.get('restrict') if restrict: restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP] @@ -96,38 +100,40 @@ def forward_events(event): else: data = json.dumps(event, cls=rem.JSONEncoder) - to_write.put(data) + yield from to_write.put(data) - def stream(): - """Stream events to response.""" - unsub_stream = self.hass.bus.listen(MATCH_ALL, forward_events) + response = web.StreamResponse() + response.content_type = 'text/event-stream' + yield from response.prepare(request) - try: - _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) + unsub_stream = self.hass.bus.async_listen(MATCH_ALL, forward_events) - # Fire off one message so browsers fire open event right away - to_write.put(STREAM_PING_PAYLOAD) + try: + _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) - while True: - try: - payload = to_write.get(timeout=STREAM_PING_INTERVAL) + # Fire off one message so browsers fire open event right away + yield from to_write.put(STREAM_PING_PAYLOAD) - if payload is stop_obj: - break + while True: + try: + with async_timeout.timeout(STREAM_PING_INTERVAL, + loop=self.hass.loop): + payload = yield from to_write.get() - msg = "data: {}\n\n".format(payload) - _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), - msg.strip()) - yield msg.encode("UTF-8") - except queue.Empty: - to_write.put(STREAM_PING_PAYLOAD) - except GeneratorExit: + if payload is stop_obj: break - finally: - _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) - unsub_stream() - return self.Response(stream(), mimetype='text/event-stream') + msg = "data: {}\n\n".format(payload) + _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), + msg.strip()) + response.write(msg.encode("UTF-8")) + yield from response.drain() + except asyncio.TimeoutError: + yield from to_write.put(STREAM_PING_PAYLOAD) + + finally: + _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) + unsub_stream() class APIConfigView(HomeAssistantView): @@ -136,6 +142,7 @@ class APIConfigView(HomeAssistantView): url = URL_API_CONFIG name = "api:config" + @ha.callback def get(self, request): """Get current configuration.""" return self.json(self.hass.config.as_dict()) @@ -148,6 +155,7 @@ class APIDiscoveryView(HomeAssistantView): url = URL_API_DISCOVERY_INFO name = "api:discovery" + @ha.callback def get(self, request): """Get discovery info.""" needs_auth = self.hass.config.api.api_password is not None @@ -165,17 +173,19 @@ class APIStatesView(HomeAssistantView): url = URL_API_STATES name = "api:states" + @ha.callback def get(self, request): """Get current states.""" - return self.json(self.hass.states.all()) + return self.json(self.hass.states.async_all()) class APIEntityStateView(HomeAssistantView): """View to handle EntityState requests.""" - url = "/api/states/" + url = "/api/states/{entity_id}" name = "api:entity-state" + @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" state = self.hass.states.get(entity_id) @@ -184,34 +194,41 @@ def get(self, request, entity_id): else: return self.json_message('Entity not found', HTTP_NOT_FOUND) + @asyncio.coroutine def post(self, request, entity_id): """Update state of entity.""" try: - new_state = request.json['state'] - except KeyError: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON specified', + HTTP_BAD_REQUEST) + + new_state = data.get('state') + + if not new_state: return self.json_message('No state specified', HTTP_BAD_REQUEST) - attributes = request.json.get('attributes') - force_update = request.json.get('force_update', False) + attributes = data.get('attributes') + force_update = data.get('force_update', False) is_new_state = self.hass.states.get(entity_id) is None # Write state - self.hass.states.set(entity_id, new_state, attributes, force_update) + self.hass.states.async_set(entity_id, new_state, attributes, + force_update) # Read the state back for our response - resp = self.json(self.hass.states.get(entity_id)) - - if is_new_state: - resp.status_code = HTTP_CREATED + status_code = HTTP_CREATED if is_new_state else 200 + resp = self.json(self.hass.states.get(entity_id), status_code) resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id)) return resp + @ha.callback def delete(self, request, entity_id): """Remove entity.""" - if self.hass.states.remove(entity_id): + if self.hass.states.async_remove(entity_id): return self.json_message('Entity removed') else: return self.json_message('Entity not found', HTTP_NOT_FOUND) @@ -223,20 +240,23 @@ class APIEventListenersView(HomeAssistantView): url = URL_API_EVENTS name = "api:event-listeners" + @ha.callback def get(self, request): """Get event listeners.""" - return self.json(events_json(self.hass)) + return self.json(async_events_json(self.hass)) class APIEventView(HomeAssistantView): """View to handle Event requests.""" - url = '/api/events/' + url = '/api/events/{event_type}' name = "api:event" + @asyncio.coroutine def post(self, request, event_type): """Fire events.""" - event_data = request.json + body = yield from request.text() + event_data = json.loads(body) if body else None if event_data is not None and not isinstance(event_data, dict): return self.json_message('Event data should be a JSON object', @@ -251,7 +271,7 @@ def post(self, request, event_type): if state: event_data[key] = state - self.hass.bus.fire(event_type, event_data, ha.EventOrigin.remote) + self.hass.bus.async_fire(event_type, event_data, ha.EventOrigin.remote) return self.json_message("Event {} fired.".format(event_type)) @@ -262,24 +282,30 @@ class APIServicesView(HomeAssistantView): url = URL_API_SERVICES name = "api:services" + @ha.callback def get(self, request): """Get registered services.""" - return self.json(services_json(self.hass)) + return self.json(async_services_json(self.hass)) class APIDomainServicesView(HomeAssistantView): """View to handle DomainServices requests.""" - url = "/api/services//" + url = "/api/services/{domain}/{service}" name = "api:domain-services" + @asyncio.coroutine def post(self, request, domain, service): """Call a service. Returns a list of changed states. """ - with TrackStates(self.hass) as changed_states: - self.hass.services.call(domain, service, request.json, True) + body = yield from request.text() + data = json.loads(body) if body else None + + with AsyncTrackStates(self.hass) as changed_states: + yield from self.hass.services.async_call(domain, service, data, + True) return self.json(changed_states) @@ -291,11 +317,14 @@ class APIEventForwardingView(HomeAssistantView): name = "api:event-forward" event_forwarder = None + @asyncio.coroutine def post(self, request): """Setup an event forwarder.""" - data = request.json - if data is None: + try: + data = yield from request.json() + except ValueError: return self.json_message("No data received.", HTTP_BAD_REQUEST) + try: host = data['host'] api_password = data['api_password'] @@ -311,21 +340,25 @@ def post(self, request): api = rem.API(host, api_password, port) - if not api.validate_api(): + valid = yield from self.hass.loop.run_in_executor( + None, api.validate_api) + if not valid: return self.json_message("Unable to validate API.", HTTP_UNPROCESSABLE_ENTITY) if self.event_forwarder is None: self.event_forwarder = rem.EventForwarder(self.hass) - self.event_forwarder.connect(api) + self.event_forwarder.async_connect(api) return self.json_message("Event forwarding setup.") + @asyncio.coroutine def delete(self, request): - """Remove event forwarer.""" - data = request.json - if data is None: + """Remove event forwarder.""" + try: + data = yield from request.json() + except ValueError: return self.json_message("No data received.", HTTP_BAD_REQUEST) try: @@ -342,7 +375,7 @@ def delete(self, request): if self.event_forwarder is not None: api = rem.API(host, None, port) - self.event_forwarder.disconnect(api) + self.event_forwarder.async_disconnect(api) return self.json_message("Event forwarding cancelled.") @@ -353,6 +386,7 @@ class APIComponentsView(HomeAssistantView): url = URL_API_COMPONENTS name = "api:components" + @ha.callback def get(self, request): """Get current loaded components.""" return self.json(self.hass.config.components) @@ -364,9 +398,12 @@ class APIErrorLogView(HomeAssistantView): url = URL_API_ERROR_LOG name = "api:error-log" + @asyncio.coroutine def get(self, request): """Serve error log.""" - return self.file(request, self.hass.config.path(ERROR_LOG_FILENAME)) + resp = yield from self.file( + request, self.hass.config.path(ERROR_LOG_FILENAME)) + return resp class APITemplateView(HomeAssistantView): @@ -375,23 +412,25 @@ class APITemplateView(HomeAssistantView): url = URL_API_TEMPLATE name = "api:template" + @asyncio.coroutine def post(self, request): """Render a template.""" try: - tpl = template.Template(request.json['template'], self.hass) - return tpl.render(request.json.get('variables')) - except TemplateError as ex: + data = yield from request.json() + tpl = template.Template(data['template'], self.hass) + return tpl.async_render(data.get('variables')) + except (ValueError, TemplateError) as ex: return self.json_message('Error rendering template: {}'.format(ex), HTTP_BAD_REQUEST) -def services_json(hass): +def async_services_json(hass): """Generate services data to JSONify.""" return [{"domain": key, "services": value} - for key, value in hass.services.services.items()] + for key, value in hass.services.async_services().items()] -def events_json(hass): +def async_events_json(hass): """Generate event data to JSONify.""" return [{"event": key, "listener_count": value} - for key, value in hass.bus.listeners.items()] + for key, value in hass.bus.async_listeners().items()] diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py index ce89ae2e4db74b..72140936e18d7b 100644 --- a/homeassistant/components/binary_sensor/ffmpeg.py +++ b/homeassistant/components/binary_sensor/ffmpeg.py @@ -81,7 +81,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): from haffmpeg import SensorNoise, SensorMotion # check source - if not run_test(config.get(CONF_INPUT)): + if not run_test(hass, config.get(CONF_INPUT)): return # generate sensor object diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 2f23118a1c3006..ce811780856d1a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -5,8 +5,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/camera/ """ +import asyncio import logging -import time + +from aiohttp import web from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -31,8 +33,8 @@ def setup(hass, config): component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - hass.wsgi.register_view(CameraImageView(hass, component.entities)) - hass.wsgi.register_view(CameraMjpegStream(hass, component.entities)) + hass.http.register_view(CameraImageView(hass, component.entities)) + hass.http.register_view(CameraMjpegStream(hass, component.entities)) component.setup(config) @@ -80,33 +82,59 @@ def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() - def mjpeg_stream(self, response): - """Generate an HTTP MJPEG stream from camera images.""" - def stream(): - """Stream images as mjpeg stream.""" - try: - last_image = None - while True: - img_bytes = self.camera_image() - - if img_bytes is not None and img_bytes != last_image: - yield bytes( - '--jpegboundary\r\n' - 'Content-Type: image/jpeg\r\n' - 'Content-Length: {}\r\n\r\n'.format( - len(img_bytes)), 'utf-8') + img_bytes + b'\r\n' - - last_image = img_bytes - - time.sleep(0.5) - except GeneratorExit: - pass - - return response( - stream(), - content_type=('multipart/x-mixed-replace; ' - 'boundary=--jpegboundary') - ) + @asyncio.coroutine + def async_camera_image(self): + """Return bytes of camera image. + + This method must be run in the event loop. + """ + image = yield from self.hass.loop.run_in_executor( + None, self.camera_image) + return image + + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images. + + This method must be run in the event loop. + """ + response = web.StreamResponse() + + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--jpegboundary') + response.enable_chunked_encoding() + yield from response.prepare(request) + + def write(img_bytes): + """Write image to stream.""" + response.write(bytes( + '--jpegboundary\r\n' + 'Content-Type: image/jpeg\r\n' + 'Content-Length: {}\r\n\r\n'.format( + len(img_bytes)), 'utf-8') + img_bytes + b'\r\n') + + last_image = None + + try: + while True: + img_bytes = yield from self.async_camera_image() + if not img_bytes: + break + + if img_bytes is not None and img_bytes != last_image: + write(img_bytes) + + # Chrome seems to always ignore first picture, + # print it twice. + if last_image is None: + write(img_bytes) + + last_image = img_bytes + yield from response.drain() + + yield from asyncio.sleep(.5) + finally: + self.hass.loop.create_task(response.write_eof()) @property def state(self): @@ -144,22 +172,25 @@ def __init__(self, hass, entities): super().__init__(hass) self.entities = entities + @asyncio.coroutine def get(self, request, entity_id): """Start a get request.""" camera = self.entities.get(entity_id) if camera is None: - return self.Response(status=404) + return web.Response(status=404) authenticated = (request.authenticated or - request.args.get('token') == camera.access_token) + request.GET.get('token') == camera.access_token) if not authenticated: - return self.Response(status=401) + return web.Response(status=401) - return self.handle(camera) + response = yield from self.handle(request, camera) + return response - def handle(self, camera): + @asyncio.coroutine + def handle(self, request, camera): """Hanlde the camera request.""" raise NotImplementedError() @@ -167,25 +198,27 @@ def handle(self, camera): class CameraImageView(CameraView): """Camera view to serve an image.""" - url = "/api/camera_proxy/" + url = "/api/camera_proxy/{entity_id}" name = "api:camera:image" - def handle(self, camera): + @asyncio.coroutine + def handle(self, request, camera): """Serve camera image.""" - response = camera.camera_image() + image = yield from camera.async_camera_image() - if response is None: - return self.Response(status=500) + if image is None: + return web.Response(status=500) - return self.Response(response) + return web.Response(body=image) class CameraMjpegStream(CameraView): """Camera View to serve an MJPEG stream.""" - url = "/api/camera_proxy_stream/" + url = "/api/camera_proxy_stream/{entity_id}" name = "api:camera:stream" - def handle(self, camera): + @asyncio.coroutine + def handle(self, request, camera): """Serve camera image.""" - return camera.mjpeg_stream(self.Response) + yield from camera.handle_async_mjpeg_stream(request) diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 1115bc2d2c166a..85567eca18ea38 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -4,15 +4,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.ffmpeg/ """ +import asyncio import logging import voluptuous as vol +from aiohttp import web from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA) from homeassistant.components.ffmpeg import ( - run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS) + async_run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_NAME +from homeassistant.util.async import run_coroutine_threadsafe DEPENDENCIES = ['ffmpeg'] @@ -27,17 +30,18 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a FFmpeg Camera.""" - if not run_test(config.get(CONF_INPUT)): + if not async_run_test(hass, config.get(CONF_INPUT)): return - add_devices([FFmpegCamera(config)]) + hass.loop.create_task(async_add_devices([FFmpegCamera(hass, config)])) class FFmpegCamera(Camera): """An implementation of an FFmpeg camera.""" - def __init__(self, config): + def __init__(self, hass, config): """Initialize a FFmpeg camera.""" super().__init__() self._name = config.get(CONF_NAME) @@ -45,24 +49,45 @@ def __init__(self, config): self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) def camera_image(self): + """Return bytes of camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + @asyncio.coroutine + def async_camera_image(self): """Return a still image response from the camera.""" - from haffmpeg import ImageSingle, IMAGE_JPEG - ffmpeg = ImageSingle(get_binary()) + from haffmpeg import ImageSingleAsync, IMAGE_JPEG + ffmpeg = ImageSingleAsync(get_binary(), loop=self.hass.loop) - return ffmpeg.get_image(self._input, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments) + image = yield from ffmpeg.get_image( + self._input, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments) + return image - def mjpeg_stream(self, response): + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - from haffmpeg import CameraMjpeg - - stream = CameraMjpeg(get_binary()) - stream.open_camera(self._input, extra_cmd=self._extra_arguments) - return response( - stream, - mimetype='multipart/x-mixed-replace;boundary=ffserver', - direct_passthrough=True - ) + from haffmpeg import CameraMjpegAsync + + stream = CameraMjpegAsync(get_binary(), loop=self.hass.loop) + yield from stream.open_camera( + self._input, extra_cmd=self._extra_arguments) + + response = web.StreamResponse() + response.content_type = 'multipart/x-mixed-replace;boundary=ffserver' + response.enable_chunked_encoding() + + yield from response.prepare(request) + + try: + while True: + data = yield from stream.read(102400) + if not data: + break + response.write(data) + finally: + self.hass.loop.create_task(stream.close()) + self.hass.loop.create_task(response.write_eof()) @property def name(self): diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 5d7488b8e688c4..e6dc89680306d0 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -4,10 +4,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.generic/ """ +import asyncio import logging +import aiohttp +import async_timeout import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests.auth import HTTPDigestAuth import voluptuous as vol from homeassistant.const import ( @@ -16,6 +19,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) from homeassistant.helpers import config_validation as cv +from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) @@ -35,10 +39,11 @@ }) +@asyncio.coroutine # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a generic IP Camera.""" - add_devices([GenericCamera(hass, config)]) + hass.loop.create_task(async_add_devices([GenericCamera(hass, config)])) # pylint: disable=too-many-instance-attributes @@ -49,6 +54,7 @@ def __init__(self, hass, device_info): """Initialize a generic camera.""" super().__init__() self.hass = hass + self._authentication = device_info.get(CONF_AUTHENTICATION) self._name = device_info.get(CONF_NAME) self._still_image_url = device_info[CONF_STILL_IMAGE_URL] self._still_image_url.hass = hass @@ -58,20 +64,27 @@ def __init__(self, hass, device_info): password = device_info.get(CONF_PASSWORD) if username and password: - if device_info[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION: + if self._authentication == HTTP_DIGEST_AUTHENTICATION: self._auth = HTTPDigestAuth(username, password) else: - self._auth = HTTPBasicAuth(username, password) + self._auth = aiohttp.BasicAuth(username, password=password) else: self._auth = None self._last_url = None self._last_image = None + self._session = aiohttp.ClientSession(loop=hass.loop, auth=self._auth) def camera_image(self): + """Return bytes of camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + @asyncio.coroutine + def async_camera_image(self): """Return a still image response from the camera.""" try: - url = self._still_image_url.render() + url = self._still_image_url.async_render() except TemplateError as err: _LOGGER.error('Error parsing template %s: %s', self._still_image_url, err) @@ -80,16 +93,32 @@ def camera_image(self): if url == self._last_url and self._limit_refetch: return self._last_image - kwargs = {'timeout': 10, 'auth': self._auth} - - try: - response = requests.get(url, **kwargs) - except requests.exceptions.RequestException as error: - _LOGGER.error('Error getting camera image: %s', error) - return None + # aiohttp don't support DigestAuth jet + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + def fetch(): + """Read image from a URL.""" + try: + kwargs = {'timeout': 10, 'auth': self._auth} + response = requests.get(url, **kwargs) + return response.content + except requests.exceptions.RequestException as error: + _LOGGER.error('Error getting camera image: %s', error) + return self._last_image + + self._last_image = yield from self.hass.loop.run_in_executor( + None, fetch) + # async + else: + try: + with async_timeout.timeout(10, loop=self.hass.loop): + respone = yield from self._session.get(url) + self._last_image = yield from respone.read() + self.hass.loop.create_task(respone.release()) + except asyncio.TimeoutError: + _LOGGER.error('Timeout getting camera image') + return self._last_image self._last_url = url - self._last_image = response.content return self._last_image @property diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 04f099d8b1e5ce..e1c39a62572c71 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -4,9 +4,14 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.mjpeg/ """ +import asyncio import logging from contextlib import closing +import aiohttp +from aiohttp import web +from aiohttp.web_exceptions import HTTPGatewayTimeout +import async_timeout import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -34,10 +39,11 @@ }) +@asyncio.coroutine # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a MJPEG IP Camera.""" - add_devices([MjpegCamera(config)]) + hass.loop.create_task(async_add_devices([MjpegCamera(hass, config)])) def extract_image_from_mjpeg(stream): @@ -56,7 +62,7 @@ def extract_image_from_mjpeg(stream): class MjpegCamera(Camera): """An implementation of an IP camera that is reachable over a URL.""" - def __init__(self, device_info): + def __init__(self, hass, device_info): """Initialize a MJPEG camera.""" super().__init__() self._name = device_info.get(CONF_NAME) @@ -65,32 +71,57 @@ def __init__(self, device_info): self._password = device_info.get(CONF_PASSWORD) self._mjpeg_url = device_info[CONF_MJPEG_URL] - def camera_stream(self): - """Return a MJPEG stream image response directly from the camera.""" + auth = None + if self._authentication == HTTP_BASIC_AUTHENTICATION: + auth = aiohttp.BasicAuth(self._username, password=self._password) + + self._session = aiohttp.ClientSession(loop=hass.loop, auth=auth) + + def camera_image(self): + """Return a still image response from the camera.""" if self._username and self._password: if self._authentication == HTTP_DIGEST_AUTHENTICATION: auth = HTTPDigestAuth(self._username, self._password) else: auth = HTTPBasicAuth(self._username, self._password) - return requests.get(self._mjpeg_url, - auth=auth, - stream=True, timeout=10) + req = requests.get( + self._mjpeg_url, auth=auth, stream=True, timeout=10) else: - return requests.get(self._mjpeg_url, stream=True, timeout=10) + req = requests.get(self._mjpeg_url, stream=True, timeout=10) - def camera_image(self): - """Return a still image response from the camera.""" - with closing(self.camera_stream()) as response: - return extract_image_from_mjpeg(response.iter_content(1024)) + with closing(req) as response: + return extract_image_from_mjpeg(response.iter_content(102400)) - def mjpeg_stream(self, response): + @asyncio.coroutine + def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - stream = self.camera_stream() - return response( - stream.iter_content(chunk_size=1024), - mimetype=stream.headers[CONTENT_TYPE_HEADER], - direct_passthrough=True - ) + # aiohttp don't support DigestAuth -> Fallback + if self._authentication == HTTP_DIGEST_AUTHENTICATION: + yield from super().handle_async_mjpeg_stream(request) + return + + # connect to stream + try: + with async_timeout.timeout(10, loop=self.hass.loop): + stream = yield from self._session.get(self._mjpeg_url) + except asyncio.TimeoutError: + raise HTTPGatewayTimeout() + + response = web.StreamResponse() + response.content_type = stream.headers.get(CONTENT_TYPE_HEADER) + response.enable_chunked_encoding() + + yield from response.prepare(request) + + try: + while True: + data = yield from stream.content.read(102400) + if not data: + break + response.write(data) + finally: + self.hass.loop.create_task(stream.release()) + self.hass.loop.create_task(response.write_eof()) @property def name(self): diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index f3f2c3c94f5753..adbd1dd13d4ce6 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -4,6 +4,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.locative/ """ +import asyncio +from functools import partial import logging from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME @@ -19,7 +21,7 @@ def setup_scanner(hass, config, see): """Setup an endpoint for the Locative application.""" - hass.wsgi.register_view(LocativeView(hass, see)) + hass.http.register_view(LocativeView(hass, see)) return True @@ -35,15 +37,23 @@ def __init__(self, hass, see): super().__init__(hass) self.see = see + @asyncio.coroutine def get(self, request): """Locative message received as GET.""" - return self.post(request) + res = yield from self._handle(request.GET) + return res + @asyncio.coroutine def post(self, request): """Locative message received.""" - # pylint: disable=too-many-return-statements - data = request.values + data = yield from request.post() + res = yield from self._handle(data) + return res + @asyncio.coroutine + def _handle(self, data): + """Handle locative request.""" + # pylint: disable=too-many-return-statements if 'latitude' not in data or 'longitude' not in data: return ('Latitude and longitude not specified.', HTTP_UNPROCESSABLE_ENTITY) @@ -68,7 +78,9 @@ def post(self, request): direction = data['trigger'] if direction == 'enter': - self.see(dev_id=device, location_name=location_name) + yield from self.hass.loop.run_in_executor( + None, partial(self.see, dev_id=device, + location_name=location_name)) return 'Setting location to {}'.format(location_name) elif direction == 'exit': @@ -76,7 +88,9 @@ def post(self, request): '{}.{}'.format(DOMAIN, device)) if current_state is None or current_state.state == location_name: - self.see(dev_id=device, location_name=STATE_NOT_HOME) + yield from self.hass.loop.run_in_executor( + None, partial(self.see, dev_id=device, + location_name=STATE_NOT_HOME)) return 'Setting location to not home' else: # Ignore the message if it is telling us to exit a zone that we diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py index a63117fc31b0a8..62680d84d36630 100644 --- a/homeassistant/components/emulated_hue.py +++ b/homeassistant/components/emulated_hue.py @@ -4,20 +4,21 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/emulated_hue/ """ +import asyncio import threading import socket import logging -import json import os import select +from aiohttp import web import voluptuous as vol from homeassistant import util, core from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - STATE_ON, HTTP_BAD_REQUEST + STATE_ON, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS @@ -25,8 +26,6 @@ from homeassistant.components.http import ( HomeAssistantView, HomeAssistantWSGI ) -# pylint: disable=unused-import -from homeassistant.components.http import REQUIREMENTS # noqa import homeassistant.helpers.config_validation as cv DOMAIN = 'emulated_hue' @@ -87,20 +86,22 @@ def setup(hass, yaml_config): upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port) + @core.callback + def stop_emulated_hue_bridge(event): + """Stop the emulated hue bridge.""" + upnp_listener.stop() + hass.loop.create_task(server.stop()) + + @core.callback def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" - server.start() + hass.loop.create_task(server.start()) upnp_listener.start() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + stop_emulated_hue_bridge) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) - def stop_emulated_hue_bridge(event): - """Stop the emulated hue bridge.""" - upnp_listener.stop() - server.stop() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) - return True @@ -158,6 +159,7 @@ def __init__(self, hass, config): super().__init__(hass) self.config = config + @core.callback def get(self, request): """Handle a GET request.""" xml_template = """ @@ -185,7 +187,7 @@ def get(self, request): resp_text = xml_template.format( self.config.host_ip_addr, self.config.listen_port) - return self.Response(resp_text, mimetype='text/xml') + return web.Response(text=resp_text, content_type='text/xml') class HueUsernameView(HomeAssistantView): @@ -200,9 +202,13 @@ def __init__(self, hass): """Initialize the instance of the view.""" super().__init__(hass) + @asyncio.coroutine def post(self, request): """Handle a POST request.""" - data = request.json + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) if 'devicetype' not in data: return self.json_message('devicetype not specified', @@ -214,10 +220,10 @@ def post(self, request): class HueLightsView(HomeAssistantView): """Handle requests for getting and setting info about entities.""" - url = '/api//lights' + url = '/api/{username}/lights' name = 'api:username:lights' - extra_urls = ['/api//lights/', - '/api//lights//state'] + extra_urls = ['/api/{username}/lights/{entity_id}', + '/api/{username}/lights/{entity_id}/state'] requires_auth = False def __init__(self, hass, config): @@ -226,58 +232,51 @@ def __init__(self, hass, config): self.config = config self.cached_states = {} + @core.callback def get(self, request, username, entity_id=None): """Handle a GET request.""" if entity_id is None: - return self.get_lights_list() + return self.async_get_lights_list() - if not request.base_url.endswith('state'): - return self.get_light_state(entity_id) + if not request.path.endswith('state'): + return self.async_get_light_state(entity_id) - return self.Response("Method not allowed", status=405) + return web.Response(text="Method not allowed", status=405) + @asyncio.coroutine def put(self, request, username, entity_id=None): """Handle a PUT request.""" - if not request.base_url.endswith('state'): - return self.Response("Method not allowed", status=405) - - content_type = request.environ.get('CONTENT_TYPE', '') - if content_type == 'application/x-www-form-urlencoded': - # Alexa sends JSON data with a form data content type, for - # whatever reason, and Werkzeug parses form data automatically, - # so we need to do some gymnastics to get the data we need - json_data = None - - for key in request.form: - try: - json_data = json.loads(key) - break - except ValueError: - # Try the next key? - pass - - if json_data is None: - return self.Response("Bad request", status=400) - else: - json_data = request.json + if not request.path.endswith('state'): + return web.Response(text="Method not allowed", status=405) - return self.put_light_state(json_data, entity_id) + if entity_id and self.hass.states.get(entity_id) is None: + return self.json_message('Entity not found', HTTP_NOT_FOUND) - def get_lights_list(self): + try: + json_data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + + result = yield from self.async_put_light_state(json_data, entity_id) + return result + + @core.callback + def async_get_lights_list(self): """Process a request to get the list of available lights.""" json_response = {} - for entity in self.hass.states.all(): + for entity in self.hass.states.async_all(): if self.is_entity_exposed(entity): json_response[entity.entity_id] = entity_to_json(entity) return self.json(json_response) - def get_light_state(self, entity_id): + @core.callback + def async_get_light_state(self, entity_id): """Process a request to get the state of an individual light.""" entity = self.hass.states.get(entity_id) if entity is None or not self.is_entity_exposed(entity): - return self.Response("Entity not found", status=404) + return web.Response(text="Entity not found", status=404) cached_state = self.cached_states.get(entity_id, None) @@ -292,23 +291,24 @@ def get_light_state(self, entity_id): return self.json(json_response) - def put_light_state(self, request_json, entity_id): + @asyncio.coroutine + def async_put_light_state(self, request_json, entity_id): """Process a request to set the state of an individual light.""" config = self.config # Retrieve the entity from the state machine entity = self.hass.states.get(entity_id) if entity is None: - return self.Response("Entity not found", status=404) + return web.Response(text="Entity not found", status=404) if not self.is_entity_exposed(entity): - return self.Response("Entity not found", status=404) + return web.Response(text="Entity not found", status=404) # Parse the request into requested "on" status and brightness parsed = parse_hue_api_put_light_body(request_json, entity) if parsed is None: - return self.Response("Bad request", status=400) + return web.Response(text="Bad request", status=400) result, brightness = parsed @@ -333,7 +333,8 @@ def put_light_state(self, request_json, entity_id): self.cached_states[entity_id] = (result, brightness) # Perform the requested action - self.hass.services.call(core.DOMAIN, service, data, blocking=True) + yield from self.hass.services.async_call(core.DOMAIN, service, data, + blocking=True) json_response = \ [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)] @@ -345,7 +346,10 @@ def put_light_state(self, request_json, entity_id): return self.json(json_response) def is_entity_exposed(self, entity): - """Determine if an entity should be exposed on the emulated bridge.""" + """Determine if an entity should be exposed on the emulated bridge. + + Async friendly. + """ config = self.config if entity.attributes.get('view') is not None: diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index 0ba015a4660df1..dea9e2f1bcf902 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -4,14 +4,16 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/ffmpeg/ """ +import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.util.async import run_coroutine_threadsafe DOMAIN = 'ffmpeg' -REQUIREMENTS = ["ha-ffmpeg==0.13"] +REQUIREMENTS = ["ha-ffmpeg==0.14"] _LOGGER = logging.getLogger(__name__) @@ -47,13 +49,26 @@ def setup(hass, config): def get_binary(): - """Return ffmpeg binary from config.""" + """Return ffmpeg binary from config. + + Async friendly. + """ return FFMPEG_CONFIG.get(CONF_FFMPEG_BIN) -def run_test(input_source): +def run_test(hass, input_source): """Run test on this input. TRUE is deactivate or run correct.""" - from haffmpeg import Test + return run_coroutine_threadsafe( + async_run_test(hass, input_source), hass.loop).result() + + +@asyncio.coroutine +def async_run_test(hass, input_source): + """Run test on this input. TRUE is deactivate or run correct. + + This method must be run in the event loop. + """ + from haffmpeg import TestAsync if FFMPEG_CONFIG.get(CONF_RUN_TEST): # if in cache @@ -61,8 +76,9 @@ def run_test(input_source): return FFMPEG_TEST_CACHE[input_source] # run test - test = Test(get_binary()) - if not test.run_test(input_source): + ffmpeg_test = TestAsync(get_binary(), loop=hass.loop) + success = yield from ffmpeg_test.run_test(input_source) + if not success: _LOGGER.error("FFmpeg '%s' test fails!", input_source) FFMPEG_TEST_CACHE[input_source] = False return False diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py index b08ba89ca77de3..bb4c66ad1f9223 100644 --- a/homeassistant/components/foursquare.py +++ b/homeassistant/components/foursquare.py @@ -4,14 +4,14 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/foursquare/ """ +import asyncio import logging import os -import json import requests import voluptuous as vol -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView @@ -75,7 +75,7 @@ def checkin_user(call): descriptions[DOMAIN][SERVICE_CHECKIN], schema=CHECKIN_SERVICE_SCHEMA) - hass.wsgi.register_view(FoursquarePushReceiver( + hass.http.register_view(FoursquarePushReceiver( hass, config[CONF_PUSH_SECRET])) return True @@ -93,16 +93,21 @@ def __init__(self, hass, push_secret): super().__init__(hass) self.push_secret = push_secret + @asyncio.coroutine def post(self, request): """Accept the POST from Foursquare.""" - raw_data = request.form - _LOGGER.debug("Received Foursquare push: %s", raw_data) - if self.push_secret != raw_data["secret"]: + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + + secret = data.pop('secret', None) + + _LOGGER.debug("Received Foursquare push: %s", data) + + if self.push_secret != secret: _LOGGER.error("Received Foursquare push with invalid" - "push secret! Data: %s", raw_data) - return - parsed_payload = { - key: json.loads(val) for key, val in raw_data.items() - if key != "secret" - } - self.hass.bus.fire(EVENT_PUSH, parsed_payload) + "push secret: %s", secret) + return self.json_message('Incorrect secret', HTTP_BAD_REQUEST) + + self.hass.bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2d9abe8fe33a99..494e3aee4014eb 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,8 +1,13 @@ """Handle the frontend for Home Assistant.""" +import asyncio import hashlib +import json import logging import os +from aiohttp import web + +from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.components import api from homeassistant.components.http import HomeAssistantView @@ -39,7 +44,7 @@ def register_built_in_panel(hass, component_name, sidebar_title=None, # pylint: disable=too-many-arguments path = 'panels/ha-panel-{}.html'.format(component_name) - if hass.wsgi.development: + if hass.http.development: url = ('/static/home-assistant-polymer/panels/' '{0}/ha-panel-{0}.html'.format(component_name)) else: @@ -98,7 +103,7 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, url = URL_PANEL_COMPONENT.format(component_name) if url not in _REGISTERED_COMPONENTS: - hass.wsgi.register_static_path(url, path) + hass.http.register_static_path(url, path) _REGISTERED_COMPONENTS.add(url) fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5) @@ -114,20 +119,23 @@ def add_manifest_json_key(key, val): def setup(hass, config): """Setup serving the frontend.""" - hass.wsgi.register_view(BootstrapView) - hass.wsgi.register_view(ManifestJSONView) + hass.http.register_view(BootstrapView) + hass.http.register_view(ManifestJSONView) - if hass.wsgi.development: + if hass.http.development: sw_path = "home-assistant-polymer/build/service_worker.js" else: sw_path = "service_worker.js" - hass.wsgi.register_static_path("/service_worker.js", + hass.http.register_static_path("/service_worker.js", os.path.join(STATIC_PATH, sw_path), 0) - hass.wsgi.register_static_path("/robots.txt", + hass.http.register_static_path("/robots.txt", os.path.join(STATIC_PATH, "robots.txt")) - hass.wsgi.register_static_path("/static", STATIC_PATH) - hass.wsgi.register_static_path("/local", hass.config.path('www')) + hass.http.register_static_path("/static", STATIC_PATH) + + local = hass.config.path('www') + if os.path.isdir(local): + hass.http.register_static_path("/local", local) register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') @@ -140,7 +148,7 @@ def register_frontend_index(event): Done when Home Assistant is started so that all panels are known. """ - hass.wsgi.register_view(IndexView( + hass.http.register_view(IndexView( hass, ['/{}'.format(name) for name in PANELS])) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, register_frontend_index) @@ -161,13 +169,14 @@ class BootstrapView(HomeAssistantView): url = "/api/bootstrap" name = "api:bootstrap" + @callback def get(self, request): """Return all data needed to bootstrap Home Assistant.""" return self.json({ 'config': self.hass.config.as_dict(), - 'states': self.hass.states.all(), - 'events': api.events_json(self.hass), - 'services': api.services_json(self.hass), + 'states': self.hass.states.async_all(), + 'events': api.async_events_json(self.hass), + 'services': api.async_services_json(self.hass), 'panels': PANELS, }) @@ -193,9 +202,10 @@ def __init__(self, hass, extra_urls): ) ) + @asyncio.coroutine def get(self, request, entity_id=None): """Serve the index view.""" - if self.hass.wsgi.development: + if self.hass.http.development: core_url = '/static/home-assistant-polymer/build/core.js' ui_url = '/static/home-assistant-polymer/src/home-assistant.html' else: @@ -215,22 +225,24 @@ def get(self, request, entity_id=None): if self.hass.config.api.api_password: # require password if set no_auth = 'false' - if self.hass.wsgi.is_trusted_ip( - self.hass.wsgi.get_real_ip(request)): + if self.hass.http.is_trusted_ip( + self.hass.http.get_real_ip(request)): # bypass for trusted networks no_auth = 'true' icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html']) - template = self.templates.get_template('index.html') + template = yield from self.hass.loop.run_in_executor( + None, self.templates.get_template, 'index.html') # 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, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=PANELS) - return self.Response(resp, mimetype='text/html') + return web.Response(text=resp, content_type='text/html') class ManifestJSONView(HomeAssistantView): @@ -240,8 +252,8 @@ class ManifestJSONView(HomeAssistantView): url = "/manifest.json" name = "manifestjson" - def get(self, request): + @asyncio.coroutine + def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" - import json msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') - return self.Response(msg, mimetype="application/manifest+json") + return web.Response(body=msg, content_type="application/manifest+json") diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 199a6b47b99c9c..c8230386aa0688 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -4,11 +4,13 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/history/ """ +import asyncio from collections import defaultdict from datetime import timedelta from itertools import groupby import voluptuous as vol +from homeassistant.const import HTTP_BAD_REQUEST import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util from homeassistant.components import recorder, script @@ -182,8 +184,8 @@ def setup(hass, config): filters.included_entities = include[CONF_ENTITIES] filters.included_domains = include[CONF_DOMAINS] - hass.wsgi.register_view(Last5StatesView(hass)) - hass.wsgi.register_view(HistoryPeriodView(hass, filters)) + hass.http.register_view(Last5StatesView(hass)) + hass.http.register_view(HistoryPeriodView(hass, filters)) register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box') return True @@ -192,16 +194,19 @@ def setup(hass, config): class Last5StatesView(HomeAssistantView): """Handle last 5 state view requests.""" - url = '/api/history/entity//recent_states' + url = '/api/history/entity/{entity_id}/recent_states' name = 'api:history:entity-recent-states' def __init__(self, hass): """Initilalize the history last 5 states view.""" super().__init__(hass) + @asyncio.coroutine def get(self, request, entity_id): """Retrieve last 5 states of entity.""" - return self.json(last_5_states(entity_id)) + result = yield from self.hass.loop.run_in_executor( + None, last_5_states, entity_id) + return self.json(result) class HistoryPeriodView(HomeAssistantView): @@ -209,15 +214,22 @@ class HistoryPeriodView(HomeAssistantView): url = '/api/history/period' name = 'api:history:view-period' - extra_urls = ['/api/history/period/'] + extra_urls = ['/api/history/period/{datetime}'] def __init__(self, hass, filters): """Initilalize the history period view.""" super().__init__(hass) self.filters = filters + @asyncio.coroutine def get(self, request, datetime=None): """Return history over a period of time.""" + if datetime: + datetime = dt_util.parse_datetime(datetime) + + if datetime is None: + return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) + one_day = timedelta(days=1) if datetime: @@ -226,10 +238,13 @@ def get(self, request, datetime=None): start_time = dt_util.utcnow() - one_day end_time = start_time + one_day - entity_id = request.args.get('filter_entity_id') + entity_id = request.GET.get('filter_entity_id') + + result = yield from self.hass.loop.run_in_executor( + None, get_significant_states, start_time, end_time, entity_id, + self.filters) - return self.json(get_significant_states( - start_time, end_time, entity_id, self.filters).values()) + return self.json(result.values()) # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 97009b69d1ceca..25515c61046bb5 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -4,31 +4,36 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ +import asyncio import hmac import json import logging import mimetypes -import threading +import os +from pathlib import Path import re import ssl from ipaddress import ip_address, ip_network import voluptuous as vol +from aiohttp import web, hdrs +from aiohttp.file_sender import FileSender +from aiohttp.web_exceptions import ( + HTTPUnauthorized, HTTPMovedPermanently, HTTPNotModified) +from aiohttp.web_urldispatcher import StaticRoute +from homeassistant.core import callback, is_callback import homeassistant.remote as rem from homeassistant import util from homeassistant.const import ( - SERVER_PORT, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CACHE_CONTROL, - HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE_JSON, - HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_HEADERS, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -from homeassistant.core import split_entity_id -import homeassistant.util.dt as dt_util + SERVER_PORT, HTTP_HEADER_HA_AUTH, # HTTP_HEADER_CACHE_CONTROL, + CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP, + EVENT_HOMEASSISTANT_START) import homeassistant.helpers.config_validation as cv from homeassistant.components import persistent_notification DOMAIN = 'http' -REQUIREMENTS = ('cherrypy==8.1.2', 'static3==0.7.0', 'Werkzeug==0.11.11') +REQUIREMENTS = ('aiohttp_cors==0.4.0',) CONF_API_PASSWORD = 'api_password' CONF_SERVER_HOST = 'server_host' @@ -83,6 +88,12 @@ }, extra=vol.ALLOW_EXTRA) +# TEMP TO GET TESTS TO RUN +def request_class(): + """.""" + raise Exception('not implemented') + + class HideSensitiveFilter(logging.Filter): """Filter API password calls.""" @@ -94,17 +105,17 @@ def __init__(self, hass): def filter(self, record): """Hide sensitive data in messages.""" - if self.hass.wsgi.api_password is None: + if self.hass.http.api_password is None: return True - record.msg = record.msg.replace(self.hass.wsgi.api_password, '*******') + record.msg = record.msg.replace(self.hass.http.api_password, '*******') return True def setup(hass, config): """Set up the HTTP API and debug interface.""" - _LOGGER.addFilter(HideSensitiveFilter(hass)) + logging.getLogger('aiohttp.access').addFilter(HideSensitiveFilter(hass)) conf = config.get(DOMAIN, {}) @@ -131,19 +142,20 @@ def setup(hass, config): trusted_networks=trusted_networks ) - def start_wsgi_server(event): - """Start the WSGI server.""" - server.start() + @callback + def stop_server(event): + """Callback to stop the server.""" + hass.loop.create_task(server.stop()) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_wsgi_server) + @callback + def start_server(event): + """Callback to start the server.""" + hass.loop.create_task(server.start()) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - def stop_wsgi_server(event): - """Stop the WSGI server.""" - server.stop() + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_server) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_wsgi_server) - - hass.wsgi = server + hass.http = server hass.config.api = rem.API(server_host if server_host != '0.0.0.0' else util.get_local_ip(), api_password, server_port, @@ -152,105 +164,84 @@ def stop_wsgi_server(event): return True -def request_class(): - """Generate request class. - - Done in method because of imports. - """ - from werkzeug.exceptions import BadRequest - from werkzeug.wrappers import BaseRequest, AcceptMixin - from werkzeug.utils import cached_property - - class Request(BaseRequest, AcceptMixin): - """Base class for incoming requests.""" - - @cached_property - def json(self): - """Get the result of json.loads if possible.""" - if not self.data: - return None - # elif 'json' not in self.environ.get('CONTENT_TYPE', ''): - # raise BadRequest('Not a JSON request') - try: - return json.loads(self.data.decode( - self.charset, self.encoding_errors)) - except (TypeError, ValueError): - raise BadRequest('Unable to read JSON request') +class GzipFileSender(FileSender): + """FileSender class capable of sending gzip version if available.""" - return Request + # pylint: disable=invalid-name, too-few-public-methods + development = False -def routing_map(hass): - """Generate empty routing map with HA validators.""" - from werkzeug.routing import Map, BaseConverter, ValidationError + @asyncio.coroutine + def send(self, request, filepath): + """Send filepath to client using request.""" + gzip = False + if 'gzip' in request.headers[hdrs.ACCEPT_ENCODING]: + gzip_path = filepath.with_name(filepath.name + '.gz') - class EntityValidator(BaseConverter): - """Validate entity_id in urls.""" + if gzip_path.is_file(): + filepath = gzip_path + gzip = True - regex = r"(\w+)\.(\w+)" + st = filepath.stat() - def __init__(self, url_map, exist=True, domain=None): - """Initilalize entity validator.""" - super().__init__(url_map) - self._exist = exist - self._domain = domain + modsince = request.if_modified_since + if modsince is not None and st.st_mtime <= modsince.timestamp(): + raise HTTPNotModified() - def to_python(self, value): - """Validate entity id.""" - if self._exist and hass.states.get(value) is None: - raise ValidationError() - if self._domain is not None and \ - split_entity_id(value)[0] != self._domain: - raise ValidationError() + ct, encoding = mimetypes.guess_type(str(filepath)) + if not ct: + ct = 'application/octet-stream' - return value + resp = self._response_factory() + resp.content_type = ct + if encoding: + resp.headers[hdrs.CONTENT_ENCODING] = encoding + if gzip: + resp.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING + resp.last_modified = st.st_mtime - def to_url(self, value): - """Convert entity_id for a url.""" - return value + # CACHE HACK + if not self.development: + cache_time = 31 * 86400 # = 1 month + resp.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( + cache_time) - class DateValidator(BaseConverter): - """Validate dates in urls.""" + file_size = st.st_size - regex = r'\d{4}-\d{1,2}-\d{1,2}' - - def to_python(self, value): - """Validate and convert date.""" - parsed = dt_util.parse_date(value) - - if parsed is None: - raise ValidationError() + resp.content_length = file_size + resp.set_tcp_cork(True) + try: + with filepath.open('rb') as f: + yield from self._sendfile(request, resp, f, file_size) - return parsed + finally: + resp.set_tcp_nodelay(True) - def to_url(self, value): - """Convert date to url value.""" - return value.isoformat() + return resp - class DateTimeValidator(BaseConverter): - """Validate datetimes in urls formatted per ISO 8601.""" +_GZIP_FILE_SENDER = GzipFileSender() - regex = r'\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d' \ - r'\.\d+([+-][0-2]\d:[0-5]\d|Z)' - def to_python(self, value): - """Validate and convert date.""" - parsed = dt_util.parse_datetime(value) +class HAStaticRoute(StaticRoute): + """StaticRoute with support for fingerprinting.""" - if parsed is None: - raise ValidationError() + def __init__(self, prefix, path): + """Initialize a static route with gzip and cache busting support.""" + super().__init__(None, prefix, path) + self._file_sender = _GZIP_FILE_SENDER - return parsed + def match(self, path): + """Match path to filename.""" + if not path.startswith(self._prefix): + return None - def to_url(self, value): - """Convert date to url value.""" - return value.isoformat() + # Extra sauce to remove fingerprinted resource names + filename = path[self._prefix_len:] + fingerprinted = _FINGERPRINT.match(filename) + if fingerprinted: + filename = '{}.{}'.format(*fingerprinted.groups()) - return Map(converters={ - 'entity': EntityValidator, - 'date': DateValidator, - 'datetime': DateTimeValidator, - }) + return {'filename': filename} class HomeAssistantWSGI(object): @@ -262,28 +253,35 @@ class HomeAssistantWSGI(object): def __init__(self, hass, development, api_password, ssl_certificate, ssl_key, server_host, server_port, cors_origins, trusted_networks): - """Initilalize the WSGI Home Assistant server.""" - from werkzeug.wrappers import Response - - Response.mimetype = 'text/html' + """Initialize the WSGI Home Assistant server.""" + import aiohttp_cors - # pylint: disable=invalid-name - self.Request = request_class() - self.url_map = routing_map(hass) - self.views = {} + self.app = web.Application(loop=hass.loop) self.hass = hass - self.extra_apps = {} self.development = development self.api_password = api_password self.ssl_certificate = ssl_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port - self.cors_origins = cors_origins self.trusted_networks = trusted_networks self.event_forwarder = None + self._handler = None self.server = None + if cors_origins: + self.cors = aiohttp_cors.setup(self.app, defaults={ + host: aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods='*', + ) for host in cors_origins + }) + else: + self.cors = None + + # CACHE HACK + _GZIP_FILE_SENDER.development = development + def register_view(self, view): """Register a view with the WSGI server. @@ -291,21 +289,11 @@ def register_view(self, view): It is optional to instantiate it before registering; this method will handle it either way. """ - from werkzeug.routing import Rule - - if view.name in self.views: - _LOGGER.warning("View '%s' is being overwritten", view.name) if isinstance(view, type): # Instantiate the view, if needed view = view(self.hass) - self.views[view.name] = view - - rule = Rule(view.url, endpoint=view.name) - self.url_map.add(rule) - for url in view.extra_urls: - rule = Rule(url, endpoint=view.name) - self.url_map.add(rule) + view.register(self.app.router) def register_redirect(self, url, redirect_to): """Register a redirect with the server. @@ -316,149 +304,92 @@ def register_redirect(self, url, redirect_to): for the redirect, otherwise it has to be a string with placeholders in rule syntax. """ - from werkzeug.routing import Rule + def redirect(request): + """Redirect to location.""" + raise HTTPMovedPermanently(redirect_to) - self.url_map.add(Rule(url, redirect_to=redirect_to)) + self.app.router.add_route('GET', url, redirect) def register_static_path(self, url_root, path, cache_length=31): """Register a folder to serve as a static path. Specify optional cache length of asset in days. """ - from static import Cling - - headers = [] - - if cache_length and not self.development: - # 1 year in seconds - cache_time = cache_length * 86400 - - headers.append({ - 'prefix': '', - HTTP_HEADER_CACHE_CONTROL: - "public, max-age={}".format(cache_time) - }) - - self.register_wsgi_app(url_root, Cling(path, headers=headers)) - - def register_wsgi_app(self, url_root, app): - """Register a path to serve a WSGI app.""" - if url_root in self.extra_apps: - _LOGGER.warning("Url root '%s' is being overwritten", url_root) - - self.extra_apps[url_root] = app - + if os.path.isdir(path): + assert url_root.startswith('/') + if not url_root.endswith('/'): + url_root += '/' + route = HAStaticRoute(url_root, path) + self.app.router.register_route(route) + return + + filepath = Path(path) + + @asyncio.coroutine + def serve_file(request): + """Redirect to location.""" + return _GZIP_FILE_SENDER.send(request, filepath) + + # aiohttp supports regex matching for variables. Using that as temp + # to work around cache busting MD5. + # Turns something like /static/dev-panel.html into + # /static/{filename:dev-panel(-[a-z0-9]{32}|)\.html} + base, ext = url_root.rsplit('.', 1) + base, file = base.rsplit('/', 1) + regex = r"{}(-[a-z0-9]{{32}}|)\.{}".format(file, ext) + url_pattern = "{}/{{filename:{}}}".format(base, regex) + + self.app.router.add_route('GET', url_pattern, serve_file) + + @asyncio.coroutine def start(self): """Start the wsgi server.""" - from cherrypy import wsgiserver - from cherrypy.wsgiserver.ssl_builtin import BuiltinSSLAdapter - - # pylint: disable=too-few-public-methods,super-init-not-called - class ContextSSLAdapter(BuiltinSSLAdapter): - """SSL Adapter that takes in an SSL context.""" - - def __init__(self, context): - self.context = context - - # pylint: disable=no-member - self.server = wsgiserver.CherryPyWSGIServer( - (self.server_host, self.server_port), self, - server_name='Home Assistant') + if self.cors is not None: + for route in list(self.app.router.routes()): + self.cors.add(route) if self.ssl_certificate: context = ssl.SSLContext(SSL_VERSION) context.options |= SSL_OPTS context.set_ciphers(CIPHERS) context.load_cert_chain(self.ssl_certificate, self.ssl_key) - self.server.ssl_adapter = ContextSSLAdapter(context) + else: + context = None - threading.Thread( - target=self.server.start, daemon=True, name='WSGI-server').start() + self._handler = self.app.make_handler() + self.server = yield from self.hass.loop.create_server( + self._handler, self.server_host, self.server_port, ssl=context) + @asyncio.coroutine def stop(self): """Stop the wsgi server.""" - self.server.stop() - - def dispatch_request(self, request): - """Handle incoming request.""" - from werkzeug.exceptions import ( - MethodNotAllowed, NotFound, BadRequest, Unauthorized, - ) - from werkzeug.routing import RequestRedirect - - with request: - adapter = self.url_map.bind_to_environ(request.environ) - try: - endpoint, values = adapter.match() - return self.views[endpoint].handle_request(request, **values) - except RequestRedirect as ex: - return ex - except (BadRequest, NotFound, MethodNotAllowed, - Unauthorized) as ex: - resp = ex.get_response(request.environ) - if request.accept_mimetypes.accept_json: - resp.data = json.dumps({ - 'result': 'error', - 'message': str(ex), - }) - resp.mimetype = CONTENT_TYPE_JSON - return resp - - def base_app(self, environ, start_response): - """WSGI Handler of requests to base app.""" - request = self.Request(environ) - response = self.dispatch_request(request) - - if self.cors_origins: - cors_check = (environ.get('HTTP_ORIGIN') in self.cors_origins) - cors_headers = ", ".join(ALLOWED_CORS_HEADERS) - if cors_check: - response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN] = \ - environ.get('HTTP_ORIGIN') - response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS] = \ - cors_headers - - return response(environ, start_response) - - def __call__(self, environ, start_response): - """Handle a request for base app + extra apps.""" - from werkzeug.wsgi import DispatcherMiddleware - - if not self.hass.is_running: - from werkzeug.exceptions import BadRequest - return BadRequest()(environ, start_response) - - app = DispatcherMiddleware(self.base_app, self.extra_apps) - # Strip out any cachebusting MD5 fingerprints - fingerprinted = _FINGERPRINT.match(environ.get('PATH_INFO', '')) - if fingerprinted: - environ['PATH_INFO'] = '{}.{}'.format(*fingerprinted.groups()) - return app(environ, start_response) + self.server.close() + yield from self.server.wait_closed() + yield from self.app.shutdown() + yield from self._handler.finish_connections(60.0) + yield from self.app.cleanup() @staticmethod def get_real_ip(request): """Return the clients correct ip address, even in proxied setups.""" - if request.access_route: - return request.access_route[-1] - else: - return request.remote_addr + peername = request.transport.get_extra_info('peername') + return peername[0] if peername is not None else None def is_trusted_ip(self, remote_addr): """Match an ip address against trusted CIDR networks.""" return any(ip_address(remote_addr) in trusted_network - for trusted_network in self.hass.wsgi.trusted_networks) + for trusted_network in self.hass.http.trusted_networks) class HomeAssistantView(object): """Base view for all views.""" + url = None extra_urls = [] requires_auth = True # Views inheriting from this class can override this def __init__(self, hass): """Initilalize the base view.""" - from werkzeug.wrappers import Response - if not hasattr(self, 'url'): class_name = self.__class__.__name__ raise AttributeError( @@ -472,59 +403,99 @@ def __init__(self, hass): ) self.hass = hass - # pylint: disable=invalid-name - self.Response = Response - def handle_request(self, request, **values): - """Handle request to url.""" - from werkzeug.exceptions import MethodNotAllowed, Unauthorized + def json(self, result, status_code=200): # pylint: disable=no-self-use + """Return a JSON response.""" + msg = json.dumps( + result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') + return web.Response( + body=msg, content_type=CONTENT_TYPE_JSON, status=status_code) - if request.method == "OPTIONS": - # For CORS preflight requests. - return self.options(request) + def json_message(self, error, status_code=200): + """Return a JSON message response.""" + return self.json({'message': error}, status_code) + + @asyncio.coroutine + def file(self, request, fil): # pylint: disable=no-self-use + """Return a file.""" + assert isinstance(fil, str), 'only string paths allowed' + response = yield from _GZIP_FILE_SENDER.send(request, Path(fil)) + return response + + def register(self, router): + """Register the view with a router.""" + assert self.url is not None, 'No url set for view' + urls = [self.url] + self.extra_urls + + for method in ('get', 'post', 'delete', 'put'): + handler = getattr(self, method, None) + + if not handler: + continue + + handler = request_handler_factory(self, handler) + + for url in urls: + router.add_route(method, url, handler) + + # aiohttp_cors does not work with class based views + # self.app.router.add_route('*', self.url, self, name=self.name) + + # for url in self.extra_urls: + # self.app.router.add_route('*', url, self) - try: - handler = getattr(self, request.method.lower()) - except AttributeError: - raise MethodNotAllowed +def request_handler_factory(view, handler): + """Factory to wrap our handler classes. + + Eventually authentication should be managed by middleware. + """ + @asyncio.coroutine + def handle(request): + """Handle incoming request.""" remote_addr = HomeAssistantWSGI.get_real_ip(request) # Auth code verbose on purpose authenticated = False - if self.hass.wsgi.api_password is None: + if view.hass.http.api_password is None: authenticated = True - elif self.hass.wsgi.is_trusted_ip(remote_addr): + elif view.hass.http.is_trusted_ip(remote_addr): authenticated = True elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''), - self.hass.wsgi.api_password): + view.hass.http.api_password): # A valid auth header has been set authenticated = True - elif hmac.compare_digest(request.args.get(DATA_API_PASSWORD, ''), - self.hass.wsgi.api_password): + elif hmac.compare_digest(request.GET.get(DATA_API_PASSWORD, ''), + view.hass.http.api_password): authenticated = True - if self.requires_auth and not authenticated: + if view.requires_auth and not authenticated: _LOGGER.warning('Login attempt or request with an invalid ' 'password from %s', remote_addr) - persistent_notification.create( - self.hass, + persistent_notification.async_create( + view.hass, 'Invalid password used from {}'.format(remote_addr), 'Login attempt failed', NOTIFICATION_ID_LOGIN) - raise Unauthorized() + raise HTTPUnauthorized() request.authenticated = authenticated _LOGGER.info('Serving %s to %s (auth: %s)', request.path, remote_addr, authenticated) - result = handler(request, **values) + assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ + "Handler should be a coroutine or a callback." + + result = handler(request, **request.match_info) + + if asyncio.iscoroutine(result): + result = yield from result - if isinstance(result, self.Response): + if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it return result @@ -533,36 +504,14 @@ def handle_request(self, request, **values): if isinstance(result, tuple): result, status_code = result - return self.Response(result, status=status_code) - - def json(self, result, status_code=200): - """Return a JSON response.""" - msg = json.dumps( - result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - return self.Response( - msg, mimetype=CONTENT_TYPE_JSON, status=status_code) - - def json_message(self, error, status_code=200): - """Return a JSON message response.""" - return self.json({'message': error}, status_code) - - def file(self, request, fil, mimetype=None): - """Return a file.""" - from werkzeug.wsgi import wrap_file - from werkzeug.exceptions import NotFound - - if isinstance(fil, str): - if mimetype is None: - mimetype = mimetypes.guess_type(fil)[0] - - try: - fil = open(fil, mode='br') - except IOError: - raise NotFound() + if isinstance(result, str): + result = result.encode('utf-8') + elif result is None: + result = b'' + elif not isinstance(result, bytes): + assert False, ('Result should be None, string, bytes or Response. ' + 'Got: {}').format(result) - return self.Response(wrap_file(request.environ, fil), - mimetype=mimetype, direct_passthrough=True) + return web.Response(body=result, status=status_code) - def options(self, request): - """Default handler for OPTIONS (necessary for CORS preflight).""" - return self.Response('', status=200) + return handle diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index e8545210182058..dac03f1a07bd9e 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -247,7 +247,7 @@ def setup(hass, config): discovery.load_platform(hass, "sensor", DOMAIN, {}, config) - hass.wsgi.register_view(iOSIdentifyDeviceView(hass)) + hass.http.register_view(iOSIdentifyDeviceView(hass)) app_config = config.get(DOMAIN, {}) hass.wsgi.register_view(iOSPushConfigView(hass, diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 266496fff78fa6..9d9936bd474962 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -11,6 +11,7 @@ import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util from homeassistant.components import recorder, sun @@ -19,7 +20,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_NOT_HOME, STATE_OFF, STATE_ON, - ATTR_HIDDEN) + ATTR_HIDDEN, HTTP_BAD_REQUEST) from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN from homeassistant.util.async import run_callback_threadsafe @@ -88,7 +89,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): def setup(hass, config): """Listen for download events to download files.""" - @asyncio.coroutine + @callback def log_message(service): """Handle sending notification message service calls.""" message = service.data[ATTR_MESSAGE] @@ -100,7 +101,7 @@ def log_message(service): message = message.async_render() async_log_entry(hass, name, message, domain, entity_id) - hass.wsgi.register_view(LogbookView(hass, config)) + hass.http.register_view(LogbookView(hass, config)) register_built_in_panel(hass, 'logbook', 'Logbook', 'mdi:format-list-bulleted-type') @@ -115,24 +116,37 @@ class LogbookView(HomeAssistantView): url = '/api/logbook' name = 'api:logbook' - extra_urls = ['/api/logbook/'] + extra_urls = ['/api/logbook/{datetime}'] def __init__(self, hass, config): """Initilalize the logbook view.""" super().__init__(hass) self.config = config + @asyncio.coroutine def get(self, request, datetime=None): """Retrieve logbook entries.""" - start_day = dt_util.as_utc(datetime or dt_util.start_of_local_day()) + if datetime: + datetime = dt_util.parse_datetime(datetime) + + if datetime is None: + return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) + else: + datetime = dt_util.start_of_local_day() + + start_day = dt_util.as_utc(datetime) end_day = start_day + timedelta(days=1) - events = recorder.get_model('Events') - query = recorder.query('Events').filter( - (events.time_fired > start_day) & - (events.time_fired < end_day)) - events = recorder.execute(query) - events = _exclude_events(events, self.config) + def get_results(): + """Query DB for results.""" + events = recorder.get_model('Events') + query = recorder.query('Events').filter( + (events.time_fired > start_day) & + (events.time_fired < end_day)) + events = recorder.execute(query) + return _exclude_events(events, self.config) + + events = yield from self.hass.loop.run_in_executor(None, get_results) return self.json(humanify(events)) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a3a6274a89eac9..838202fdcab13e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -4,11 +4,13 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/media_player/ """ +import asyncio import hashlib import logging import os import requests +from aiohttp import web import voluptuous as vol from homeassistant.config import load_yaml_config_file @@ -291,7 +293,7 @@ def setup(hass, config): component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - hass.wsgi.register_view(MediaPlayerImageView(hass, component.entities)) + hass.http.register_view(MediaPlayerImageView(hass, component.entities)) component.setup(config) @@ -677,7 +679,7 @@ class MediaPlayerImageView(HomeAssistantView): """Media player view to serve an image.""" requires_auth = False - url = "/api/media_player_proxy/" + url = "/api/media_player_proxy/{entity_id}" name = "api:media_player:image" def __init__(self, hass, entities): @@ -685,26 +687,34 @@ def __init__(self, hass, entities): super().__init__(hass) self.entities = entities + @asyncio.coroutine def get(self, request, entity_id): """Start a get request.""" player = self.entities.get(entity_id) - if player is None: - return self.Response(status=404) + return web.Response(status=404) authenticated = (request.authenticated or - request.args.get('token') == player.access_token) + request.GET.get('token') == player.access_token) if not authenticated: - return self.Response(status=401) + return web.Response(status=401) image_url = player.media_image_url - if image_url: - response = requests.get(image_url) - else: - response = None + + if image_url is None: + return web.Response(status=404) + + def fetch_image(): + """Helper method to fetch image.""" + try: + return requests.get(image_url).content + except requests.RequestException: + return None + + response = yield from self.hass.loop.run_in_executor(None, fetch_image) if response is None: - return self.Response(status=500) + return web.Response(status=500) - return self.Response(response) + return web.Response(body=response) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7173538032c04e..4ded65ba3ed4da 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -4,6 +4,7 @@ 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 @@ -107,9 +108,9 @@ def get_service(hass, config): if registrations is None: return None - hass.wsgi.register_view( + hass.http.register_view( HTML5PushRegistrationView(hass, registrations, json_path)) - hass.wsgi.register_view(HTML5PushCallbackView(hass, registrations)) + hass.http.register_view(HTML5PushCallbackView(hass, registrations)) gcm_api_key = config.get(ATTR_GCM_API_KEY) gcm_sender_id = config.get(ATTR_GCM_SENDER_ID) @@ -163,12 +164,18 @@ def __init__(self, hass, registrations, json_path): self.registrations = registrations self.json_path = json_path + @asyncio.coroutine def post(self, request): """Accept the POST request for push registrations from a browser.""" try: - data = REGISTER_SCHEMA(request.json) + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + + try: + data = REGISTER_SCHEMA(data) except vol.Invalid as ex: - return self.json_message(humanize_error(request.json, ex), + return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) name = ensure_unique_string('unnamed device', @@ -182,9 +189,15 @@ def post(self, request): return self.json_message('Push notification subscriber registered.') + @asyncio.coroutine def delete(self, request): """Delete a registration.""" - subscription = request.json.get(ATTR_SUBSCRIPTION) + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + + subscription = data.get(ATTR_SUBSCRIPTION) found = None @@ -270,23 +283,29 @@ def check_authorization_header(self, request): status_code=HTTP_UNAUTHORIZED) return payload + @asyncio.coroutine def post(self, request): """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + event_payload = { - ATTR_TAG: request.json.get(ATTR_TAG), - ATTR_TYPE: request.json[ATTR_TYPE], + ATTR_TAG: data.get(ATTR_TAG), + ATTR_TYPE: data[ATTR_TYPE], ATTR_TARGET: auth_check[ATTR_TARGET], } - if request.json.get(ATTR_ACTION) is not None: - event_payload[ATTR_ACTION] = request.json.get(ATTR_ACTION) + if data.get(ATTR_ACTION) is not None: + event_payload[ATTR_ACTION] = data.get(ATTR_ACTION) - if request.json.get(ATTR_DATA) is not None: - event_payload[ATTR_DATA] = request.json.get(ATTR_DATA) + if data.get(ATTR_DATA) is not None: + event_payload[ATTR_DATA] = data.get(ATTR_DATA) try: event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload) diff --git a/homeassistant/components/openalpr.py b/homeassistant/components/openalpr.py index d6bda32114106a..35793c891443c1 100644 --- a/homeassistant/components/openalpr.py +++ b/homeassistant/components/openalpr.py @@ -153,7 +153,7 @@ def setup(hass, config): # Create Alpr device / render engine if render == RENDER_FFMPEG: use_render_fffmpeg = True - if not run_test(input_source): + if not run_test(hass, input_source): _LOGGER.error("'%s' is not valid ffmpeg input", input_source) continue diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py index 54c93b3270fd19..d27389b51f936f 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/persistent_notification/ """ +import asyncio import os import logging @@ -14,6 +15,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.util import slugify from homeassistant.config import load_yaml_config_file +from homeassistant.util.async import run_coroutine_threadsafe DOMAIN = 'persistent_notification' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -35,6 +37,14 @@ def create(hass, message, title=None, notification_id=None): + """Generate a notification.""" + run_coroutine_threadsafe( + async_create(hass, message, title, notification_id), hass.loop + ).result() + + +@asyncio.coroutine +def async_create(hass, message, title=None, notification_id=None): """Generate a notification.""" data = { key: value for key, value in [ @@ -44,7 +54,7 @@ def create(hass, message, title=None, notification_id=None): ] if value is not None } - hass.services.call(DOMAIN, SERVICE_CREATE, data) + yield from hass.services.async_call(DOMAIN, SERVICE_CREATE, data) def setup(hass, config): diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 11288bae63a49c..2c73bb764fb81c 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -12,6 +12,7 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity @@ -273,8 +274,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): scope=['activity', 'heartrate', 'nutrition', 'profile', 'settings', 'sleep', 'weight']) - hass.wsgi.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) - hass.wsgi.register_view(FitbitAuthCallbackView( + hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) + hass.http.register_view(FitbitAuthCallbackView( hass, config, add_devices, oauth)) request_oauth_completion(hass) @@ -294,12 +295,13 @@ def __init__(self, hass, config, add_devices, oauth): self.add_devices = add_devices self.oauth = oauth + @callback def get(self, request): """Finish OAuth callback request.""" from oauthlib.oauth2.rfc6749.errors import MismatchingStateError from oauthlib.oauth2.rfc6749.errors import MissingTokenError - data = request.args + data = request.GET response_message = """Fitbit has been successfully authorized! You can close this window now!""" @@ -340,7 +342,8 @@ def get(self, request): config_contents): _LOGGER.error("Failed to save config file") - setup_platform(self.hass, self.config, self.add_devices) + self.hass.async_add_job(setup_platform, self.hass, self.config, + self.add_devices) return html_response diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index c05217692acead..c1cb0cd98ca2c0 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_EMAIL, CONF_NAME) @@ -57,7 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): email = config.get(CONF_EMAIL) sensors = {} - hass.wsgi.register_view(TorqueReceiveDataView( + hass.http.register_view(TorqueReceiveDataView( hass, email, vehicle, sensors, add_devices)) return True @@ -77,9 +78,10 @@ def __init__(self, hass, email, vehicle, sensors, add_devices): self.sensors = sensors self.add_devices = add_devices + @callback def get(self, request): """Handle Torque data request.""" - data = request.args + data = request.GET if self.email is not None and self.email != data[SENSOR_EMAIL_FIELD]: return @@ -100,14 +102,14 @@ def get(self, request): elif is_value: pid = convert_pid(is_value.group(1)) if pid in self.sensors: - self.sensors[pid].on_update(data[key]) + self.sensors[pid].async_on_update(data[key]) for pid in names: if pid not in self.sensors: self.sensors[pid] = TorqueSensor( ENTITY_NAME_FORMAT.format(self.vehicle, names[pid]), units.get(pid, None)) - self.add_devices([self.sensors[pid]]) + self.hass.async_add_job(self.add_devices, [self.sensors[pid]]) return None @@ -141,7 +143,8 @@ def icon(self): """Return the default icon of the sensor.""" return 'mdi:car' - def on_update(self, value): + @callback + def async_on_update(self, value): """Receive an update.""" self._state = value - self.update_ha_state() + self.hass.loop.create_task(self.async_update_ha_state()) diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index 03a3d311f3c73b..dde7b791d90401 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -10,6 +10,7 @@ import voluptuous as vol +from homeassistant.core import callback from homeassistant import util from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( @@ -40,7 +41,7 @@ REQ_CONF = [CONF_HOST, CONF_OUTLETS] -URL_API_NETIO_EP = '/api/netio/' +URL_API_NETIO_EP = '/api/netio/{host}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -61,7 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) if len(DEVICES) == 0: - hass.wsgi.register_view(NetioApiView) + hass.http.register_view(NetioApiView) dev = Netio(host, port, username, password) @@ -93,9 +94,10 @@ class NetioApiView(HomeAssistantView): url = URL_API_NETIO_EP name = 'api:netio' + @callback def get(self, request, host): """Request handler.""" - data = request.args + data = request.GET states, consumptions, cumulated_consumptions, start_dates = \ [], [], [], [] @@ -117,7 +119,7 @@ def get(self, request, host): ndev.start_dates = start_dates for dev in DEVICES[host].entities: - dev.update_ha_state() + self.hass.loop.create_task(dev.async_update_ha_state()) return self.json(True) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 4935251db7df60..c9addefec2bb6d 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -83,12 +83,14 @@ # pylint: disable=too-few-public-methods, attribute-defined-outside-init -class TrackStates(object): +class AsyncTrackStates(object): """ Record the time when the with-block is entered. Add all states that have changed since the start time to the return list when with-block is exited. + + Must be run within the event loop. """ def __init__(self, hass): @@ -103,7 +105,8 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): """Add changes states to changes list.""" - self.states.extend(get_changed_since(self.hass.states.all(), self.now)) + self.states.extend(get_changed_since(self.hass.states.async_all(), + self.now)) def get_changed_since(states, utc_point_in_time): diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 15a84e08ffe48d..ce20eb4ce0d414 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -213,35 +213,35 @@ def __init__(self, hass, restrict_origin=None): self._targets = {} self._lock = threading.Lock() - self._unsub_listener = None + self._async_unsub_listener = None - def connect(self, api): + @ha.callback + def async_connect(self, api): """Attach to a Home Assistant instance and forward events. Will overwrite old target if one exists with same host/port. """ - with self._lock: - if self._unsub_listener is None: - self._unsub_listener = self.hass.bus.listen( - ha.MATCH_ALL, self._event_listener) + if self._async_unsub_listener is None: + self._async_unsub_listener = self.hass.bus.async_listen( + ha.MATCH_ALL, self._event_listener) - key = (api.host, api.port) + key = (api.host, api.port) - self._targets[key] = api + self._targets[key] = api - def disconnect(self, api): + @ha.callback + def async_disconnect(self, api): """Remove target from being forwarded to.""" - with self._lock: - key = (api.host, api.port) + key = (api.host, api.port) - did_remove = self._targets.pop(key, None) is None + did_remove = self._targets.pop(key, None) is None - if len(self._targets) == 0: - # Remove event listener if no forwarding targets present - self._unsub_listener() - self._unsub_listener = None + if len(self._targets) == 0: + # Remove event listener if no forwarding targets present + self._async_unsub_listener() + self._async_unsub_listener = None - return did_remove + return did_remove def _event_listener(self, event): """Listen and forward all events.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4ac6c45c01da42..e79f9c9ab2fc50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,6 +6,8 @@ pip>=7.0.0 jinja2>=2.8 voluptuous==0.9.2 typing>=3,<4 +aiohttp==1.0.5 +async_timeout==1.0.0 # homeassistant.components.nuimo_controller --only-binary=all git+https://github.com/getSenic/nuimo-linux-python#nuimo==1.0.0 @@ -28,9 +30,8 @@ SoCo==0.12 # homeassistant.components.notify.twitter TwitterAPI==2.4.2 -# homeassistant.components.emulated_hue # homeassistant.components.http -Werkzeug==0.11.11 +aiohttp_cors==0.4.0 # homeassistant.components.apcupsd apcaccess==0.0.4 @@ -62,10 +63,6 @@ blockchain==1.3.3 # homeassistant.components.notify.aws_sqs boto3==1.3.1 -# homeassistant.components.emulated_hue -# homeassistant.components.http -cherrypy==8.1.2 - # homeassistant.components.sensor.coinmarketcap coinmarketcap==2.0.1 @@ -136,7 +133,7 @@ gps3==0.33.3 ha-alpr==0.3 # homeassistant.components.ffmpeg -ha-ffmpeg==0.13 +ha-ffmpeg==0.14 # homeassistant.components.mqtt.server hbmqtt==0.7.1 @@ -483,10 +480,6 @@ speedtest-cli==0.3.4 # homeassistant.scripts.db_migrator sqlalchemy==1.1.1 -# homeassistant.components.emulated_hue -# homeassistant.components.http -static3==0.7.0 - # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test.txt b/requirements_test.txt index fd782a66933886..933bb8a7c7b4e1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,6 +2,7 @@ flake8>=3.0.4 pylint>=1.5.6 coveralls>=1.1 pytest>=2.9.2 +pytest-aiohttp>=0.1.3 pytest-asyncio>=0.5.0 pytest-cov>=2.3.1 pytest-timeout>=1.0.0 @@ -9,3 +10,4 @@ pytest-catchlog>=1.2.2 pydocstyle>=1.0.0 requests_mock>=1.0 mypy-lang>=0.4 +mock-open>=1.3.1 diff --git a/setup.py b/setup.py index d20aabb0f71996..145b027e9757a3 100755 --- a/setup.py +++ b/setup.py @@ -21,6 +21,8 @@ 'jinja2>=2.8', 'voluptuous==0.9.2', 'typing>=3,<4', + 'aiohttp==1.0.5', + 'async_timeout==1.0.0', ] setup( diff --git a/tests/__init__.py b/tests/__init__.py index 2c44763f234314..35d25f27356240 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,29 +1 @@ -"""Setup some common test helper things.""" -import functools -import logging - -from homeassistant import util -from homeassistant.util import location - -logging.basicConfig() -logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) - - -def test_real(func): - """Force a function to require a keyword _test_real to be passed in.""" - @functools.wraps(func) - def guard_func(*args, **kwargs): - real = kwargs.pop('_test_real', None) - - if not real: - raise Exception('Forgot to mock or pass "_test_real=True" to %s', - func.__name__) - - return func(*args, **kwargs) - - return guard_func - -# Guard a few functions that would make network connections -location.detect_location_info = test_real(location.detect_location_info) -location.elevation = test_real(location.elevation) -util.get_local_ip = lambda: '127.0.0.1' +"""Tests for Home Assistant.""" diff --git a/tests/common.py b/tests/common.py index b185a47e66c7ed..b73af5fc4c5a02 100644 --- a/tests/common.py +++ b/tests/common.py @@ -38,23 +38,11 @@ def get_test_home_assistant(num_threads=None): orig_num_threads = ha.MIN_WORKER_THREAD ha.MIN_WORKER_THREAD = num_threads - hass = ha.HomeAssistant(loop) + hass = loop.run_until_complete(async_test_home_assistant(loop)) if num_threads: ha.MIN_WORKER_THREAD = orig_num_threads - hass.config.location_name = 'test home' - hass.config.config_dir = get_test_config_dir() - hass.config.latitude = 32.87336 - hass.config.longitude = -117.22743 - hass.config.elevation = 0 - hass.config.time_zone = date_util.get_time_zone('US/Pacific') - hass.config.units = METRIC_SYSTEM - hass.config.skip_pip = True - - if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: - loader.prepare(hass) - # FIXME should not be a daemon. Means hass.stop() not called in teardown stop_event = threading.Event() @@ -98,6 +86,35 @@ def stop_hass(): return hass +@asyncio.coroutine +def async_test_home_assistant(loop): + """Return a Home Assistant object pointing at test config dir.""" + loop._thread_ident = threading.get_ident() + + def get_hass(): + """Temp while we migrate core HASS over to be async constructors.""" + hass = ha.HomeAssistant(loop) + + hass.config.location_name = 'test home' + hass.config.config_dir = get_test_config_dir() + hass.config.latitude = 32.87336 + hass.config.longitude = -117.22743 + hass.config.elevation = 0 + hass.config.time_zone = date_util.get_time_zone('US/Pacific') + hass.config.units = METRIC_SYSTEM + hass.config.skip_pip = True + + if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS: + loader.prepare(hass) + + hass.state = ha.CoreState.running + return hass + + hass = yield from loop.run_in_executor(None, get_hass) + + return hass + + def get_test_instance_port(): """Return unused port for running test instance. @@ -181,8 +198,19 @@ def mock_state_change_event(hass, new_state, old_state=None): def mock_http_component(hass): """Mock the HTTP component.""" - hass.wsgi = mock.MagicMock() + hass.http = mock.MagicMock() hass.config.components.append('http') + hass.http.views = {} + + def mock_register_view(view): + """Store registered view.""" + if isinstance(view, type): + # Instantiate the view, if needed + view = view(hass) + + hass.http.views[view.name] = view + + hass.http.register_view = mock_register_view def mock_mqtt_component(hass): diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index df80b48e36bc8c..e2ce9c159360dd 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -1,36 +1,18 @@ """The tests for generic camera component.""" -import unittest +import asyncio from unittest import mock -import requests_mock -from werkzeug.test import EnvironBuilder - from homeassistant.bootstrap import setup_component -from homeassistant.components.http import request_class - -from tests.common import get_test_home_assistant - - -class TestGenericCamera(unittest.TestCase): - """Test the generic camera platform.""" - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.wsgi = mock.MagicMock() - self.hass.config.components.append('http') - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def test_fetching_url(aioclient_mock, hass, test_client): + """Test that it fetches the given url.""" + aioclient_mock.get('http://example.com', text='hello world') - @requests_mock.Mocker() - def test_fetching_url(self, m): - """Test that it fetches the given url.""" - self.hass.wsgi = mock.MagicMock() - m.get('http://example.com', text='hello world') - - assert setup_component(self.hass, 'camera', { + def setup_platform(): + """Setup the platform.""" + assert setup_component(hass, 'camera', { 'camera': { 'name': 'config_test', 'platform': 'generic', @@ -39,32 +21,32 @@ def test_fetching_url(self, m): 'password': 'pass' }}) - image_view = self.hass.wsgi.mock_calls[0][1][0] + yield from hass.loop.run_in_executor(None, setup_platform) + + client = yield from test_client(hass.http.app) - builder = EnvironBuilder(method='GET') - Request = request_class() - request = Request(builder.get_environ()) - request.authenticated = True - resp = image_view.get(request, 'camera.config_test') + resp = yield from client.get('/api/camera_proxy/camera.config_test') - assert m.call_count == 1 - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == 'hello world' + assert aioclient_mock.call_count == 1 + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello world' - image_view.get(request, 'camera.config_test') - assert m.call_count == 2 + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 2 - @requests_mock.Mocker() - def test_limit_refetch(self, m): - """Test that it fetches the given url.""" - self.hass.wsgi = mock.MagicMock() - from requests.exceptions import Timeout - m.get('http://example.com/5a', text='hello world') - m.get('http://example.com/10a', text='hello world') - m.get('http://example.com/15a', text='hello planet') - m.get('http://example.com/20a', status_code=404) - assert setup_component(self.hass, 'camera', { +@asyncio.coroutine +def test_limit_refetch(aioclient_mock, hass, test_client): + """Test that it fetches the given url.""" + aioclient_mock.get('http://example.com/5a', text='hello world') + aioclient_mock.get('http://example.com/10a', text='hello world') + aioclient_mock.get('http://example.com/15a', text='hello planet') + aioclient_mock.get('http://example.com/20a', status=404) + + def setup_platform(): + """Setup the platform.""" + assert setup_component(hass, 'camera', { 'camera': { 'name': 'config_test', 'platform': 'generic', @@ -73,43 +55,47 @@ def test_limit_refetch(self, m): 'limit_refetch_to_url_change': True, }}) - image_view = self.hass.wsgi.mock_calls[0][1][0] + yield from hass.loop.run_in_executor(None, setup_platform) + + client = yield from test_client(hass.http.app) - builder = EnvironBuilder(method='GET') - Request = request_class() - request = Request(builder.get_environ()) - request.authenticated = True + resp = yield from client.get('/api/camera_proxy/camera.config_test') - self.hass.states.set('sensor.temp', '5') + hass.states.async_set('sensor.temp', '5') - with mock.patch('requests.get', side_effect=Timeout()): - resp = image_view.get(request, 'camera.config_test') - assert m.call_count == 0 - assert resp.status_code == 500, resp.response + with mock.patch('async_timeout.timeout', + side_effect=asyncio.TimeoutError()): + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 0 + assert resp.status == 500 - self.hass.states.set('sensor.temp', '10') + hass.states.async_set('sensor.temp', '10') - resp = image_view.get(request, 'camera.config_test') - assert m.call_count == 1 - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == 'hello world' + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello world' - resp = image_view.get(request, 'camera.config_test') - assert m.call_count == 1 - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == 'hello world' + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 1 + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello world' - self.hass.states.set('sensor.temp', '15') + hass.states.async_set('sensor.temp', '15') - # Url change = fetch new image - resp = image_view.get(request, 'camera.config_test') - assert m.call_count == 2 - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == 'hello planet' + # Url change = fetch new image + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 2 + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello planet' - # Cause a template render error - self.hass.states.remove('sensor.temp') - resp = image_view.get(request, 'camera.config_test') - assert m.call_count == 2 - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == 'hello planet' + # Cause a template render error + hass.states.async_remove('sensor.temp') + resp = yield from client.get('/api/camera_proxy/camera.config_test') + assert aioclient_mock.call_count == 2 + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello planet' diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 0c131b441b544e..d43c138c57050d 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -1,70 +1,60 @@ """The tests for local file camera component.""" -import unittest +import asyncio from unittest import mock -from werkzeug.test import EnvironBuilder +# Using third party package because of a bug reading binary data in Python 3.4 +# https://bugs.python.org/issue23004 +from mock_open import MockOpen from homeassistant.bootstrap import setup_component -from homeassistant.components.http import request_class -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import assert_setup_component, mock_http_component -class TestLocalCamera(unittest.TestCase): - """Test the local file camera component.""" +@asyncio.coroutine +def test_loading_file(hass, test_client): + """Test that it loads image from disk.""" + @mock.patch('os.path.isfile', mock.Mock(return_value=True)) + @mock.patch('os.access', mock.Mock(return_value=True)) + def setup_platform(): + """Setup platform inside callback.""" + assert setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'local_file', + 'file_path': 'mock.file', + }}) - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.wsgi = mock.MagicMock() - self.hass.config.components.append('http') + yield from hass.loop.run_in_executor(None, setup_platform) - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + client = yield from test_client(hass.http.app) - def test_loading_file(self): - """Test that it loads image from disk.""" - test_string = 'hello' - self.hass.wsgi = mock.MagicMock() + m_open = MockOpen(read_data=b'hello') + with mock.patch( + 'homeassistant.components.camera.local_file.open', + m_open, create=True + ): + resp = yield from client.get('/api/camera_proxy/camera.config_test') - with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \ - mock.patch('os.access', mock.Mock(return_value=True)): - assert setup_component(self.hass, 'camera', { - 'camera': { - 'name': 'config_test', - 'platform': 'local_file', - 'file_path': 'mock.file', - }}) - - image_view = self.hass.wsgi.mock_calls[0][1][0] - - m_open = mock.mock_open(read_data=test_string) - with mock.patch( - 'homeassistant.components.camera.local_file.open', - m_open, create=True - ): - builder = EnvironBuilder(method='GET') - Request = request_class() # pylint: disable=invalid-name - request = Request(builder.get_environ()) - request.authenticated = True - resp = image_view.get(request, 'camera.config_test') + assert resp.status == 200 + body = yield from resp.text() + assert body == 'hello' - assert resp.status_code == 200, resp.response - assert resp.response[0].decode('utf-8') == test_string - def test_file_not_readable(self): - """Test local file will not setup when file is not readable.""" - self.hass.wsgi = mock.MagicMock() +@asyncio.coroutine +def test_file_not_readable(hass): + """Test local file will not setup when file is not readable.""" + mock_http_component(hass) + def run_test(): with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \ mock.patch('os.access', return_value=False), \ - assert_setup_component(0): - assert setup_component(self.hass, 'camera', { + assert_setup_component(0, 'camera'): + assert setup_component(hass, 'camera', { 'camera': { 'name': 'config_test', 'platform': 'local_file', 'file_path': 'mock.file', }}) - assert [] == self.hass.states.all() + yield from hass.loop.run_in_executor(None, run_test) diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index 5addb3266c3ef9..41b272c15eb258 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -18,7 +18,7 @@ class TestUVCSetup(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.wsgi = mock.MagicMock() + self.hass.http = mock.MagicMock() self.hass.config.components = ['http'] def tearDown(self): diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index b49502054f1698..2bbfaa77b8dfce 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -18,42 +18,19 @@ API_PASSWORD = "test1234" HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD} -hass = None - entity_id = 'media_player.walkman' -def setUpModule(): # pylint: disable=invalid-name - """Initalize a Home Assistant server.""" - global hass - - hass = get_test_home_assistant() - setup_component(hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_SERVER_PORT: SERVER_PORT, - http.CONF_API_PASSWORD: API_PASSWORD, - }, - }) - - hass.start() - time.sleep(0.05) - - -def tearDownModule(): # pylint: disable=invalid-name - """Stop the Home Assistant server.""" - hass.stop() - - class TestDemoMediaPlayer(unittest.TestCase): """Test the media_player module.""" def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" - self.hass = hass - try: - self.hass.config.components.remove(mp.DOMAIN) - except ValueError: - pass + self.hass = get_test_home_assistant() + + def tearDown(self): + """Shut down test instance.""" + self.hass.stop() def test_source_select(self): """Test the input source service.""" @@ -226,21 +203,6 @@ def test_prev_next_track(self): assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & state.attributes.get('supported_media_commands')) - @requests_mock.Mocker(real_http=True) - def test_media_image_proxy(self, m): - """Test the media server image proxy server .""" - fake_picture_data = 'test.test' - m.get('https://graph.facebook.com/v2.5/107771475912710/' - 'picture?type=large', text=fake_picture_data) - assert setup_component( - self.hass, mp.DOMAIN, - {'media_player': {'platform': 'demo'}}) - assert self.hass.states.is_state(entity_id, 'playing') - state = self.hass.states.get(entity_id) - req = requests.get(HTTP_BASE_URL + - state.attributes.get('entity_picture')) - assert req.text == fake_picture_data - @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.' 'media_seek') def test_play_media(self, mock_seek): @@ -275,3 +237,42 @@ def test_play_media(self, mock_seek): mp.media_seek(self.hass, 100, ent_id) self.hass.block_till_done() assert mock_seek.called + + +class TestMediaPlayerWeb(unittest.TestCase): + """Test the media player web views sensor.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + setup_component(self.hass, http.DOMAIN, { + http.DOMAIN: { + http.CONF_SERVER_PORT: SERVER_PORT, + http.CONF_API_PASSWORD: API_PASSWORD, + }, + }) + + self.hass.start() + time.sleep(0.05) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker(real_http=True) + def test_media_image_proxy(self, m): + """Test the media server image proxy server .""" + fake_picture_data = 'test.test' + m.get('https://graph.facebook.com/v2.5/107771475912710/' + 'picture?type=large', text=fake_picture_data) + self.hass.block_till_done() + assert setup_component( + self.hass, mp.DOMAIN, + {'media_player': {'platform': 'demo'}}) + assert self.hass.states.is_state(entity_id, 'playing') + state = self.hass.states.get(entity_id) + req = requests.get(HTTP_BASE_URL + + state.attributes.get('entity_picture')) + assert req.status_code == 200 + assert req.text == fake_picture_data diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index e3439e4cb2f67c..1247d8a0548858 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -1,10 +1,10 @@ """Test HTML5 notify platform.""" +import asyncio import json from unittest.mock import patch, MagicMock, mock_open -from werkzeug.test import EnvironBuilder +from aiohttp import web -from homeassistant.components.http import request_class from homeassistant.components.notify import html5 SUBSCRIPTION_1 = { @@ -35,6 +35,9 @@ }, } +REGISTER_URL = '/api/notify.html5' +PUBLISH_URL = '/api/notify.html5/callback' + class TestHtml5Notify(object): """Tests for HTML5 notify platform.""" @@ -94,9 +97,13 @@ def test_sending_message(self, mock_wp): assert payload['body'] == 'Hello' assert payload['icon'] == 'beer.png' - def test_registering_new_device_view(self): + @asyncio.coroutine + def test_registering_new_device_view(self, loop, test_client): """Test that the HTML view works.""" hass = MagicMock() + expected = { + 'unnamed device': SUBSCRIPTION_1, + } m = mock_open() with patch( @@ -114,21 +121,20 @@ def test_registering_new_device_view(self): assert view.json_path == hass.config.path.return_value assert view.registrations == {} - builder = EnvironBuilder(method='POST', - data=json.dumps(SUBSCRIPTION_1)) - Request = request_class() - resp = view.post(Request(builder.get_environ())) - - expected = { - 'unnamed device': SUBSCRIPTION_1, - } + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) + resp = yield from client.post(REGISTER_URL, + data=json.dumps(SUBSCRIPTION_1)) - assert resp.status_code == 200, resp.response + content = yield from resp.text() + assert resp.status == 200, content assert view.registrations == expected handle = m() assert json.loads(handle.write.call_args[0][0]) == expected - def test_registering_new_device_validation(self): + @asyncio.coroutine + def test_registering_new_device_validation(self, loop, test_client): """Test various errors when registering a new device.""" hass = MagicMock() @@ -146,34 +152,34 @@ def test_registering_new_device_validation(self): view = hass.mock_calls[1][1][0] - Request = request_class() + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) - builder = EnvironBuilder(method='POST', data=json.dumps({ + resp = yield from client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', 'subscription': 'sub info', })) - resp = view.post(Request(builder.get_environ())) - assert resp.status_code == 400, resp.response + assert resp.status == 400 - builder = EnvironBuilder(method='POST', data=json.dumps({ + resp = yield from client.post(REGISTER_URL, data=json.dumps({ 'browser': 'chrome', })) - resp = view.post(Request(builder.get_environ())) - assert resp.status_code == 400, resp.response + assert resp.status == 400 - builder = EnvironBuilder(method='POST', data=json.dumps({ - 'browser': 'chrome', - 'subscription': 'sub info', - })) with patch('homeassistant.components.notify.html5._save_config', return_value=False): - resp = view.post(Request(builder.get_environ())) - assert resp.status_code == 400, resp.response + # resp = view.post(Request(builder.get_environ())) + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'chrome', + 'subscription': 'sub info', + })) - @patch('homeassistant.components.notify.html5.os') - def test_unregistering_device_view(self, mock_os): + assert resp.status == 400 + + @asyncio.coroutine + def test_unregistering_device_view(self, loop, test_client): """Test that the HTML unregister view works.""" - mock_os.path.isfile.return_value = True hass = MagicMock() config = { @@ -182,11 +188,14 @@ def test_unregistering_device_view(self, mock_os): } m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.components.notify.html5.open', m, create=True - ): + + with patch('homeassistant.components.notify.html5.open', m, + create=True): hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {}) + + with patch('homeassistant.components.notify.html5.os.path.isfile', + return_value=True): + service = html5.get_service(hass, {}) assert service is not None @@ -197,23 +206,25 @@ def test_unregistering_device_view(self, mock_os): assert view.json_path == hass.config.path.return_value assert view.registrations == config - builder = EnvironBuilder(method='DELETE', data=json.dumps({ + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) + + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], })) - Request = request_class() - resp = view.delete(Request(builder.get_environ())) config.pop('some device') - assert resp.status_code == 200, resp.response + assert resp.status == 200, resp.response assert view.registrations == config handle = m() assert json.loads(handle.write.call_args[0][0]) == config - @patch('homeassistant.components.notify.html5.os') - def test_unregister_device_view_handle_unknown_subscription(self, mock_os): + @asyncio.coroutine + def test_unregister_device_view_handle_unknown_subscription(self, loop, + test_client): """Test that the HTML unregister view handles unknown subscriptions.""" - mock_os.path.isfile.return_value = True hass = MagicMock() config = { @@ -226,7 +237,9 @@ def test_unregister_device_view_handle_unknown_subscription(self, mock_os): 'homeassistant.components.notify.html5.open', m, create=True ): hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {}) + with patch('homeassistant.components.notify.html5.os.path.isfile', + return_value=True): + service = html5.get_service(hass, {}) assert service is not None @@ -237,21 +250,23 @@ def test_unregister_device_view_handle_unknown_subscription(self, mock_os): assert view.json_path == hass.config.path.return_value assert view.registrations == config - builder = EnvironBuilder(method='DELETE', data=json.dumps({ + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) + + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_3['subscription'] })) - Request = request_class() - resp = view.delete(Request(builder.get_environ())) - assert resp.status_code == 200, resp.response + assert resp.status == 200, resp.response assert view.registrations == config handle = m() assert handle.write.call_count == 0 - @patch('homeassistant.components.notify.html5.os') - def test_unregistering_device_view_handles_json_safe_error(self, mock_os): + @asyncio.coroutine + def test_unregistering_device_view_handles_json_safe_error(self, loop, + test_client): """Test that the HTML unregister view handles JSON write errors.""" - mock_os.path.isfile.return_value = True hass = MagicMock() config = { @@ -264,7 +279,9 @@ def test_unregistering_device_view_handles_json_safe_error(self, mock_os): 'homeassistant.components.notify.html5.open', m, create=True ): hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {}) + with patch('homeassistant.components.notify.html5.os.path.isfile', + return_value=True): + service = html5.get_service(hass, {}) assert service is not None @@ -275,21 +292,23 @@ def test_unregistering_device_view_handles_json_safe_error(self, mock_os): assert view.json_path == hass.config.path.return_value assert view.registrations == config - builder = EnvironBuilder(method='DELETE', data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) - Request = request_class() + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) with patch('homeassistant.components.notify.html5._save_config', return_value=False): - resp = view.delete(Request(builder.get_environ())) + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) - assert resp.status_code == 500, resp.response + assert resp.status == 500, resp.response assert view.registrations == config handle = m() assert handle.write.call_count == 0 - def test_callback_view_no_jwt(self): + @asyncio.coroutine + def test_callback_view_no_jwt(self, loop, test_client): """Test that the notification callback view works without JWT.""" hass = MagicMock() @@ -307,20 +326,20 @@ def test_callback_view_no_jwt(self): view = hass.mock_calls[2][1][0] - builder = EnvironBuilder(method='POST', data=json.dumps({ + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) + + resp = yield from client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' })) - Request = request_class() - resp = view.post(Request(builder.get_environ())) - assert resp.status_code == 401, resp.response + assert resp.status == 401, resp.response - @patch('homeassistant.components.notify.html5.os') - @patch('pywebpush.WebPusher') - def test_callback_view_with_jwt(self, mock_wp, mock_os): + @asyncio.coroutine + def test_callback_view_with_jwt(self, loop, test_client): """Test that the notification callback view works with JWT.""" - mock_os.path.isfile.return_value = True hass = MagicMock() data = { @@ -332,15 +351,18 @@ def test_callback_view_with_jwt(self, mock_wp, mock_os): 'homeassistant.components.notify.html5.open', m, create=True ): hass.config.path.return_value = 'file.conf' - service = html5.get_service(hass, {'gcm_sender_id': '100'}) + with patch('homeassistant.components.notify.html5.os.path.isfile', + return_value=True): + service = html5.get_service(hass, {'gcm_sender_id': '100'}) assert service is not None # assert hass.called assert len(hass.mock_calls) == 3 - service.send_message('Hello', target=['device'], - data={'icon': 'beer.png'}) + with patch('pywebpush.WebPusher') as mock_wp: + service.send_message('Hello', target=['device'], + data={'icon': 'beer.png'}) assert len(mock_wp.mock_calls) == 2 @@ -359,13 +381,14 @@ def test_callback_view_with_jwt(self, mock_wp, mock_os): bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - builder = EnvironBuilder(method='POST', data=json.dumps({ + app = web.Application(loop=loop) + view.register(app.router) + client = yield from test_client(app) + + resp = yield from client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', }), headers={'Authorization': bearer_token}) - Request = request_class() - resp = view.post(Request(builder.get_environ())) - assert resp.status_code == 200, resp.response - returned = resp.response[0].decode('utf-8') - expected = '{"event": "push", "status": "ok"}' - assert json.loads(returned) == json.loads(expected) + assert resp.status == 200 + body = yield from resp.json() + assert body == {"event": "push", "status": "ok"} diff --git a/tests/components/sensor/test_yr.py b/tests/components/sensor/test_yr.py index 3ea94938f0d929..0f7162c079eca7 100644 --- a/tests/components/sensor/test_yr.py +++ b/tests/components/sensor/test_yr.py @@ -1,33 +1,29 @@ """The tests for the Yr sensor platform.""" from datetime import datetime -from unittest import TestCase from unittest.mock import patch -import requests_mock - from homeassistant.bootstrap import _setup_component import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant, load_fixture -class TestSensorYr(TestCase): +class TestSensorYr: """Test the Yr sensor.""" - def setUp(self): + def setup_method(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() self.hass.config.latitude = 32.87336 self.hass.config.longitude = 117.22743 - def tearDown(self): + def teardown_method(self): """Stop everything that was started.""" self.hass.stop() - @requests_mock.Mocker() - def test_default_setup(self, m): + def test_default_setup(self, requests_mock): """Test the default setup.""" - m.get('http://api.yr.no/weatherapi/locationforecast/1.9/', - text=load_fixture('yr.no.json')) + requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/', + text=load_fixture('yr.no.json')) now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) with patch('homeassistant.components.sensor.yr.dt_util.utcnow', @@ -42,11 +38,10 @@ def test_default_setup(self, m): assert state.state.isnumeric() assert state.attributes.get('unit_of_measurement') is None - @requests_mock.Mocker() - def test_custom_setup(self, m): + def test_custom_setup(self, requests_mock): """Test a custom setup.""" - m.get('http://api.yr.no/weatherapi/locationforecast/1.9/', - text=load_fixture('yr.no.json')) + requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/', + text=load_fixture('yr.no.json')) now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) with patch('homeassistant.components.sensor.yr.dt_util.utcnow', diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 78affc70648126..ee00c42b8cce98 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -1,11 +1,13 @@ """The tests for the Home Assistant API component.""" # pylint: disable=protected-access,too-many-public-methods +import asyncio from contextlib import closing import json import time import unittest from unittest.mock import Mock, patch +from aiohttp import web import requests from homeassistant import bootstrap, const @@ -243,20 +245,18 @@ def test_api_get_components(self): def test_api_get_error_log(self): """Test the return of the error log.""" - test_string = 'Test StringĀ°'.encode('UTF-8') - - # Can't use read_data with wsgiserver in Python 3.4.2. Due to a - # bug in read_data, it can't handle byte types ('Type str doesn't - # support the buffer API'), but wsgiserver requires byte types - # ('WSGI Applications must yield bytes'). So just mock our own - # read method. - m_open = Mock(return_value=Mock( - read=Mock(side_effect=[test_string])) - ) - with patch('homeassistant.components.http.open', m_open, create=True): + test_string = 'Test StringĀ°' + + @asyncio.coroutine + def mock_send(): + """Mock file send.""" + return web.Response(text=test_string) + + with patch('homeassistant.components.http.HomeAssistantView.file', + Mock(return_value=mock_send())): req = requests.get(_url(const.URL_API_ERROR_LOG), headers=HA_HEADERS) - self.assertEqual(test_string, req.text.encode('UTF-8')) + self.assertEqual(test_string, req.text) self.assertIsNone(req.headers.get('expires')) def test_api_get_event_listeners(self): diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 2023ea24a35101..765b3e3f35cffb 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -34,12 +34,12 @@ def setUpModule(): # pylint: disable=invalid-name hass.bus.listen('test_event', lambda _: _) hass.states.set('test.test', 'a_state') - bootstrap.setup_component( + assert bootstrap.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SERVER_PORT}}) - bootstrap.setup_component(hass, 'frontend') + assert bootstrap.setup_component(hass, 'frontend') hass.start() time.sleep(0.05) @@ -71,7 +71,7 @@ def test_frontend_and_static(self): self.assertIsNotNone(frontendjs) - req = requests.head(_url(frontendjs.groups(0)[0])) + req = requests.get(_url(frontendjs.groups(0)[0])) self.assertEqual(200, req.status_code) diff --git a/tests/components/test_http.py b/tests/components/test_http.py index 57f21fd76d2b24..5ef26d5d5ab34a 100644 --- a/tests/components/test_http.py +++ b/tests/components/test_http.py @@ -56,7 +56,7 @@ def setUpModule(): bootstrap.setup_component(hass, 'api') - hass.wsgi.trusted_networks = [ + hass.http.trusted_networks = [ ip_network(trusted_network) for trusted_network in TRUSTED_NETWORKS] @@ -159,12 +159,9 @@ def test_cors_allowed_with_password_in_url(self): headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL}) allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS - all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS) assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL - assert req.headers.get(allow_headers) == all_allow_headers def test_cors_allowed_with_password_in_header(self): """Test cross origin resource sharing with password in header.""" @@ -175,12 +172,9 @@ def test_cors_allowed_with_password_in_header(self): 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 - all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS) assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL - assert req.headers.get(allow_headers) == all_allow_headers def test_cors_denied_without_origin_header(self): """Test cross origin resource sharing with password in header.""" @@ -207,8 +201,8 @@ def test_cors_preflight_allowed(self): allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS - all_allow_headers = ', '.join(const.ALLOWED_CORS_HEADERS) assert req.status_code == 200 assert req.headers.get(allow_origin) == HTTP_BASE_URL - assert req.headers.get(allow_headers) == all_allow_headers + assert req.headers.get(allow_headers) == \ + const.HTTP_HEADER_HA_AUTH.upper() diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 1f934e64a19144..060fdf01dca823 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -1,6 +1,7 @@ """The tests for the InfluxDB component.""" import unittest from unittest import mock +from unittest.mock import patch import influxdb as influx_client @@ -60,6 +61,8 @@ def test_setup_minimal_config(self, mock_client): assert setup_component(self.hass, influxdb.DOMAIN, config) + @patch('homeassistant.components.persistent_notification.create', + mock.MagicMock()) def test_setup_missing_password(self, mock_client): """Test the setup with existing username and missing password.""" config = { diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000000000..815765a8ed2228 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""Setup some common test helper things.""" +import functools +import logging + +import pytest +import requests_mock as _requests_mock + +from homeassistant import util +from homeassistant.util import location + +from .common import async_test_home_assistant +from .test_util.aiohttp import mock_aiohttp_client + +logging.basicConfig() +logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + + +def test_real(func): + """Force a function to require a keyword _test_real to be passed in.""" + @functools.wraps(func) + def guard_func(*args, **kwargs): + real = kwargs.pop('_test_real', None) + + if not real: + raise Exception('Forgot to mock or pass "_test_real=True" to %s', + func.__name__) + + return func(*args, **kwargs) + + return guard_func + +# Guard a few functions that would make network connections +location.detect_location_info = test_real(location.detect_location_info) +location.elevation = test_real(location.elevation) +util.get_local_ip = lambda: '127.0.0.1' + + +@pytest.fixture +def hass(loop): + """Fixture to provide a test instance of HASS.""" + hass = loop.run_until_complete(async_test_home_assistant(loop)) + + yield hass + + loop.run_until_complete(hass.async_stop()) + + +@pytest.fixture +def requests_mock(): + """Fixture to provide a requests mocker.""" + with _requests_mock.mock() as m: + yield m + + +@pytest.fixture +def aioclient_mock(): + """Fixture to mock aioclient calls.""" + with mock_aiohttp_client() as mock_session: + yield mock_session diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 1dbf86edae9b33..0d7b8c46d8660f 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -1,4 +1,5 @@ """Test state helpers.""" +import asyncio from datetime import timedelta import unittest from unittest.mock import patch @@ -20,6 +21,42 @@ from tests.common import get_test_home_assistant, mock_service +def test_async_track_states(event_loop): + """Test AsyncTrackStates context manager.""" + hass = get_test_home_assistant() + + try: + point1 = dt_util.utcnow() + point2 = point1 + timedelta(seconds=5) + point3 = point2 + timedelta(seconds=5) + + @asyncio.coroutine + @patch('homeassistant.core.dt_util.utcnow') + def run_test(mock_utcnow): + """Run the test.""" + mock_utcnow.return_value = point2 + + with state.AsyncTrackStates(hass) as states: + mock_utcnow.return_value = point1 + hass.states.set('light.test', 'on') + + mock_utcnow.return_value = point2 + hass.states.set('light.test2', 'on') + state2 = hass.states.get('light.test2') + + mock_utcnow.return_value = point3 + hass.states.set('light.test3', 'on') + state3 = hass.states.get('light.test3') + + assert [state2, state3] == \ + sorted(states, key=lambda state: state.entity_id) + + event_loop.run_until_complete(run_test()) + + finally: + hass.stop() + + class TestStateHelpers(unittest.TestCase): """Test the Home Assistant event helpers.""" @@ -54,31 +91,6 @@ def test_get_changed_since(self): [state2, state3], state.get_changed_since([state1, state2, state3], point2)) - def test_track_states(self): - """Test tracking of states.""" - point1 = dt_util.utcnow() - point2 = point1 + timedelta(seconds=5) - point3 = point2 + timedelta(seconds=5) - - with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: - mock_utcnow.return_value = point2 - - with state.TrackStates(self.hass) as states: - mock_utcnow.return_value = point1 - self.hass.states.set('light.test', 'on') - - mock_utcnow.return_value = point2 - self.hass.states.set('light.test2', 'on') - state2 = self.hass.states.get('light.test2') - - mock_utcnow.return_value = point3 - self.hass.states.set('light.test3', 'on') - state3 = self.hass.states.get('light.test3') - - self.assertEqual( - sorted([state2, state3], key=lambda state: state.entity_id), - sorted(states, key=lambda state: state.entity_id)) - def test_reproduce_with_no_entity(self): """Test reproduce_state with no entity.""" calls = mock_service(self.hass, 'light', SERVICE_TURN_ON) diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py new file mode 100644 index 00000000000000..f2a33a3ac3ce6e --- /dev/null +++ b/tests/test_util/aiohttp.py @@ -0,0 +1,112 @@ +"""Aiohttp test utils.""" +import asyncio +from contextlib import contextmanager +import functools +import json as _json +from unittest import mock + + +class AiohttpClientMocker: + """Mock Aiohttp client requests.""" + + def __init__(self): + """Initialize the request mocker.""" + self._mocks = [] + self.mock_calls = [] + + def request(self, method, url, *, + status=200, + text=None, + content=None, + json=None): + """Mock a request.""" + if json: + text = _json.dumps(json) + if text: + content = text.encode('utf-8') + if content is None: + content = b'' + + self._mocks.append(AiohttpClientMockResponse( + method, url, status, content)) + + def get(self, *args, **kwargs): + """Register a mock get request.""" + self.request('get', *args, **kwargs) + + def put(self, *args, **kwargs): + """Register a mock put request.""" + self.request('put', *args, **kwargs) + + def post(self, *args, **kwargs): + """Register a mock post request.""" + self.request('post', *args, **kwargs) + + def delete(self, *args, **kwargs): + """Register a mock delete request.""" + self.request('delete', *args, **kwargs) + + def options(self, *args, **kwargs): + """Register a mock options request.""" + self.request('options', *args, **kwargs) + + @property + def call_count(self): + """Number of requests made.""" + return len(self.mock_calls) + + @asyncio.coroutine + def match_request(self, method, url): + """Match a request against pre-registered requests.""" + for response in self._mocks: + if response.match_request(method, url): + self.mock_calls.append((method, url)) + return response + + assert False, "No mock registered for {} {}".format(method.upper(), + url) + + +class AiohttpClientMockResponse: + """Mock Aiohttp client response.""" + + def __init__(self, method, url, status, response): + """Initialize a fake response.""" + self.method = method + self.url = url + self.status = status + self.response = response + + def match_request(self, method, url): + """Test if response answers request.""" + return method == self.method and url == self.url + + @asyncio.coroutine + def read(self): + """Return mock response.""" + return self.response + + @asyncio.coroutine + def text(self, encoding='utf-8'): + """Return mock response as a string.""" + return self.response.decode(encoding) + + @asyncio.coroutine + def release(self): + """Mock release.""" + pass + + +@contextmanager +def mock_aiohttp_client(): + """Context manager to mock aiohttp client.""" + mocker = AiohttpClientMocker() + + with mock.patch('aiohttp.ClientSession') as mock_session: + instance = mock_session() + + for method in ('get', 'post', 'put', 'options', 'delete'): + setattr(instance, method, + functools.partial(mocker.match_request, method)) + + yield mocker