From 15e329a5882db608e49f26eded5014645efbac66 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen <paulus@paulusschoutsen.nl> Date: Sat, 14 May 2016 00:58:36 -0700 Subject: [PATCH] Tons of fixes - WIP --- homeassistant/components/alexa.py | 120 +-- homeassistant/components/api.py | 605 +++++---------- homeassistant/components/camera/__init__.py | 152 ++-- homeassistant/components/camera/mjpeg.py | 18 +- .../components/device_tracker/locative.py | 147 ++-- homeassistant/components/frontend/__init__.py | 112 +-- homeassistant/components/history.py | 63 +- homeassistant/components/http.py | 687 +++++++----------- homeassistant/components/logbook.py | 41 +- homeassistant/components/sensor/fitbit.py | 124 ++-- homeassistant/components/sensor/torque.py | 46 +- homeassistant/components/wsgi.py | 218 ------ homeassistant/helpers/event.py | 4 +- homeassistant/remote.py | 15 +- requirements_all.txt | 6 +- tests/common.py | 10 +- .../device_tracker/test_locative.py | 2 + tests/components/test_alexa.py | 8 +- tests/components/test_api.py | 163 +++-- tests/components/test_frontend.py | 5 + tests/components/test_logbook.py | 2 +- tests/test_remote.py | 16 + 22 files changed, 949 insertions(+), 1615 deletions(-) delete mode 100644 homeassistant/components/wsgi.py diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 080d7bd1097..2bb155dd322 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -7,14 +7,14 @@ https://home-assistant.io/components/alexa/ import enum import logging -from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers import template, script +from homeassistant.components.http import HomeAssistantView DOMAIN = 'alexa' DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) -_CONFIG = {} API_ENDPOINT = '/api/alexa' @@ -26,80 +26,88 @@ CONF_ACTION = 'action' def setup(hass, config): """Activate Alexa component.""" - intents = config[DOMAIN].get(CONF_INTENTS, {}) + hass.wsgi.register_view(AlexaView(hass, + config[DOMAIN].get(CONF_INTENTS, {}))) - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script(hass, intent[CONF_ACTION], - "Alexa intent {}".format(name)) + return True - _CONFIG.update(intents) - hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True) +class AlexaView(HomeAssistantView): + """Handle Alexa requests.""" - return True + url = API_ENDPOINT + name = 'api:alexa' + + def __init__(self, hass, intents): + """Initialize Alexa view.""" + super().__init__(hass) + + for name, intent in intents.items(): + if CONF_ACTION in intent: + intent[CONF_ACTION] = script.Script( + hass, intent[CONF_ACTION], "Alexa intent {}".format(name)) + + self.intents = intents + def post(self, request): + """Handle Alexa.""" + data = request.json -def _handle_alexa(handler, path_match, data): - """Handle Alexa.""" - _LOGGER.debug('Received Alexa request: %s', data) + _LOGGER.debug('Received Alexa request: %s', data) - req = data.get('request') + req = data.get('request') - if req is None: - _LOGGER.error('Received invalid data from Alexa: %s', data) - handler.write_json_message( - "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) - return + if req is None: + _LOGGER.error('Received invalid data from Alexa: %s', data) + return self.json_message('Expected request value not received', + HTTP_BAD_REQUEST) - req_type = req['type'] + req_type = req['type'] - if req_type == 'SessionEndedRequest': - handler.send_response(HTTP_OK) - handler.end_headers() - return + if req_type == 'SessionEndedRequest': + return None - intent = req.get('intent') - response = AlexaResponse(handler.server.hass, intent) + intent = req.get('intent') + response = AlexaResponse(self.hass, intent) - if req_type == 'LaunchRequest': - response.add_speech( - SpeechType.plaintext, - "Hello, and welcome to the future. How may I help?") - handler.write_json(response.as_dict()) - return + if req_type == 'LaunchRequest': + response.add_speech( + SpeechType.plaintext, + "Hello, and welcome to the future. How may I help?") + return self.json(response) - if req_type != 'IntentRequest': - _LOGGER.warning('Received unsupported request: %s', req_type) - return + if req_type != 'IntentRequest': + _LOGGER.warning('Received unsupported request: %s', req_type) + return self.json_message( + 'Received unsupported request: {}'.format(req_type), + HTTP_BAD_REQUEST) - intent_name = intent['name'] - config = _CONFIG.get(intent_name) + intent_name = intent['name'] + config = self.intents.get(intent_name) - if config is None: - _LOGGER.warning('Received unknown intent %s', intent_name) - response.add_speech( - SpeechType.plaintext, - "This intent is not yet configured within Home Assistant.") - handler.write_json(response.as_dict()) - return + if config is None: + _LOGGER.warning('Received unknown intent %s', intent_name) + response.add_speech( + SpeechType.plaintext, + "This intent is not yet configured within Home Assistant.") + return self.json(response) - speech = config.get(CONF_SPEECH) - card = config.get(CONF_CARD) - action = config.get(CONF_ACTION) + speech = config.get(CONF_SPEECH) + card = config.get(CONF_CARD) + action = config.get(CONF_ACTION) - # pylint: disable=unsubscriptable-object - if speech is not None: - response.add_speech(SpeechType[speech['type']], speech['text']) + # pylint: disable=unsubscriptable-object + if speech is not None: + response.add_speech(SpeechType[speech['type']], speech['text']) - if card is not None: - response.add_card(CardType[card['type']], card['title'], - card['content']) + if card is not None: + response.add_card(CardType[card['type']], card['title'], + card['content']) - if action is not None: - action.run(response.variables) + if action is not None: + action.run(response.variables) - handler.write_json(response.as_dict()) + return self.json(response) class SpeechType(enum.Enum): diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 8305f78fa1c..339cd5f29f6 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -6,16 +6,14 @@ https://home-assistant.io/developers/api/ """ import json import logging -import re -import threading import homeassistant.core as ha import homeassistant.remote as rem from homeassistant.bootstrap import ERROR_LOG_FILENAME from homeassistant.const import ( - CONTENT_TYPE_TEXT_PLAIN, EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, - HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_HEADER_CONTENT_TYPE, HTTP_NOT_FOUND, - HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, MATCH_ALL, URL_API, URL_API_COMPONENTS, + EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, + HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, + HTTP_UNPROCESSABLE_ENTITY, MATCH_ALL, URL_API, URL_API_COMPONENTS, URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_LOG_OUT, URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, @@ -23,10 +21,11 @@ from homeassistant.const import ( from homeassistant.exceptions import TemplateError from homeassistant.helpers.state import TrackStates from homeassistant.helpers import template -from homeassistant.components.wsgi import HomeAssistantView +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.components.http import HomeAssistantView DOMAIN = 'api' -DEPENDENCIES = ['http', 'wsgi'] +DEPENDENCIES = ['http'] STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds @@ -36,70 +35,6 @@ _LOGGER = logging.getLogger(__name__) def setup(hass, config): """Register the API with the HTTP interface.""" - # /api - for validation purposes - hass.http.register_path('GET', URL_API, _handle_get_api) - - # /api/config - hass.http.register_path('GET', URL_API_CONFIG, _handle_get_api_config) - - # /api/discovery_info - hass.http.register_path('GET', URL_API_DISCOVERY_INFO, - _handle_get_api_discovery_info, - require_auth=False) - - # /api/stream - hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream) - - # /api/states - hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states) - hass.http.register_path( - 'GET', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _handle_get_api_states_entity) - hass.http.register_path( - 'POST', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _handle_post_state_entity) - hass.http.register_path( - 'PUT', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _handle_post_state_entity) - hass.http.register_path( - 'DELETE', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _handle_delete_state_entity) - - # /api/events - hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events) - hass.http.register_path( - 'POST', re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'), - _handle_api_post_events_event) - - # /api/services - hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services) - hass.http.register_path( - 'POST', - re.compile((r'/api/services/' - r'(?P<domain>[a-zA-Z\._0-9]+)/' - r'(?P<service>[a-zA-Z\._0-9]+)')), - _handle_post_api_services_domain_service) - - # /api/event_forwarding - hass.http.register_path( - 'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward) - hass.http.register_path( - 'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward) - - # /api/components - hass.http.register_path( - 'GET', URL_API_COMPONENTS, _handle_get_api_components) - - # /api/error_log - hass.http.register_path('GET', URL_API_ERROR_LOG, - _handle_get_api_error_log) - - hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out) - - # /api/template - hass.http.register_path('POST', URL_API_TEMPLATE, - _handle_post_api_template) - hass.wsgi.register_view(APIStatusView) hass.wsgi.register_view(APIEventStream) hass.wsgi.register_view(APIConfigView) @@ -120,159 +55,143 @@ def setup(hass, config): class APIStatusView(HomeAssistantView): + """View to handle Status requests.""" + url = URL_API name = "api:status" def get(self, request): - return {'message': 'API running.'} - - -def _handle_get_api(handler, path_match, data): - """Render the debug interface.""" - handler.write_json_message("API running.") + """Retrieve if API is running.""" + return self.json_message('API running.') class APIEventStream(HomeAssistantView): - url = "" - name = "" - - # TODO Implement this... - - -def _handle_get_api_stream(handler, path_match, data): - """Provide a streaming interface for the event bus.""" - gracefully_closed = False - hass = handler.server.hass - wfile = handler.wfile - write_lock = threading.Lock() - block = threading.Event() - session_id = None + """View to handle EventSt requests.""" - restrict = data.get('restrict') - if restrict: - restrict = restrict.split(',') + url = URL_API_STREAM + name = "api:stream" - def write_message(payload): - """Write a message to the output.""" - with write_lock: - msg = "data: {}\n\n".format(payload) + def get(self, request): + """Provide a streaming interface for the event bus.""" + from eventlet import Queue + + queue = Queue() + stop_obj = object() + hass = self.hass + + restrict = request.args.get('restrict') + if restrict: + restrict = restrict.split(',') + + def ping(now): + """Add a ping message to queue.""" + queue.put(STREAM_PING_PAYLOAD) + + def forward_events(event): + """Forward events to the open request.""" + if event.event_type == EVENT_TIME_CHANGED: + pass + elif event.event_type == EVENT_HOMEASSISTANT_STOP: + queue.put(stop_obj) + else: + queue.put(json.dumps(event, cls=rem.JSONEncoder)) + + def stream(): + """Stream events to response.""" + if restrict: + for event in restrict: + hass.bus.listen(event, forward_events) + else: + hass.bus.listen(MATCH_ALL, forward_events) + + attached_ping = track_utc_time_change(hass, ping, second=(0, 30)) try: - wfile.write(msg.encode("UTF-8")) - wfile.flush() - except (IOError, ValueError): - # IOError: socket errors - # ValueError: raised when 'I/O operation on closed file' - block.set() - - def forward_events(event): - """Forward events to the open request.""" - nonlocal gracefully_closed - - if block.is_set() or event.event_type == EVENT_TIME_CHANGED: - return - elif event.event_type == EVENT_HOMEASSISTANT_STOP: - gracefully_closed = True - block.set() - return + while True: + payload = queue.get() - handler.server.sessions.extend_validation(session_id) - write_message(json.dumps(event, cls=rem.JSONEncoder)) + if payload is stop_obj: + break - handler.send_response(HTTP_OK) - handler.send_header('Content-type', 'text/event-stream') - session_id = handler.set_session_cookie_header() - handler.end_headers() + msg = "data: {}\n\n".format(payload) - if restrict: - for event in restrict: - hass.bus.listen(event, forward_events) - else: - hass.bus.listen(MATCH_ALL, forward_events) + yield msg.encode("UTF-8") + except GeneratorExit: + pass - while True: - write_message(STREAM_PING_PAYLOAD) + hass.bus.remove_listener(EVENT_TIME_CHANGED, attached_ping) - block.wait(STREAM_PING_INTERVAL) + if restrict: + for event in restrict: + hass.bus.remove_listener(event, forward_events) + else: + hass.bus.remove_listener(MATCH_ALL, forward_events) - if block.is_set(): - break - - if not gracefully_closed: - _LOGGER.info("Found broken event stream to %s, cleaning up", - handler.client_address[0]) - - if restrict: - for event in restrict: - hass.bus.remove_listener(event, forward_events) - else: - hass.bus.remove_listener(MATCH_ALL, forward_events) + return self.Response(stream(), mimetype='text/event-stream') class APIConfigView(HomeAssistantView): + """View to handle Config requests.""" + url = URL_API_CONFIG name = "api:config" def get(self, request): - return self.hass.config.as_dict() - - -def _handle_get_api_config(handler, path_match, data): - """Return the Home Assistant configuration.""" - handler.write_json(handler.server.hass.config.as_dict()) + """Get current configuration.""" + return self.json(self.hass.config.as_dict()) class APIDiscoveryView(HomeAssistantView): + """View to provide discovery info.""" + + requires_auth = False url = URL_API_DISCOVERY_INFO name = "api:discovery" def get(self, request): - # TODO - return {} - - -def _handle_get_api_discovery_info(handler, path_match, data): - needs_auth = (handler.server.hass.config.api.api_password is not None) - params = { - 'base_url': handler.server.hass.config.api.base_url, - 'location_name': handler.server.hass.config.location_name, - 'requires_api_password': needs_auth, - 'version': __version__ - } - handler.write_json(params) + """Get discovery info.""" + needs_auth = self.hass.config.api.api_password is not None + return self.json({ + 'base_url': self.hass.config.api.base_url, + 'location_name': self.hass.config.location_name, + 'requires_api_password': needs_auth, + 'version': __version__ + }) class APIStatesView(HomeAssistantView): + """View to handle States requests.""" + url = URL_API_STATES name = "api:states" def get(self, request): - return self.hass.states.all() - - -def _handle_get_api_states(handler, path_match, data): - """Return a dict containing all entity ids and their state.""" - handler.write_json(handler.server.hass.states.all()) + """Get current states.""" + return self.json(self.hass.states.all()) class APIEntityStateView(HomeAssistantView): + """View to handle EntityState requests.""" + url = "/api/states/<entity_id>" name = "api:entity-state" def get(self, request, entity_id): + """Retrieve state of entity.""" state = self.hass.states.get(entity_id) if state: - return state + return self.json(state) else: - raise self.NotFound("State does not exist.") + return self.json_message('Entity not found', HTTP_NOT_FOUND) def post(self, request, entity_id): + """Update state of entity.""" try: - new_state = request.values['state'] + new_state = request.json['state'] except KeyError: - raise self.BadRequest("state not specified") + return self.json_message('No state specified', HTTP_BAD_REQUEST) - attributes = request.values.get('attributes') + attributes = request.json.get('attributes') is_new_state = self.hass.states.get(entity_id) is None @@ -280,13 +199,7 @@ class APIEntityStateView(HomeAssistantView): self.hass.states.set(entity_id, new_state, attributes) # Read the state back for our response - msg = json.dumps( - self.hass.states.get(entity_id).as_dict(), - sort_keys=True, - cls=rem.JSONEncoder - ).encode('UTF-8') - - resp = Response(msg, mimetype="application/json") + resp = self.json(self.hass.states.get(entity_id)) if is_new_state: resp.status_code = HTTP_CREATED @@ -296,93 +209,37 @@ class APIEntityStateView(HomeAssistantView): return resp def delete(self, request, entity_id): + """Remove entity.""" if self.hass.states.remove(entity_id): - return {"message:" "Entity removed"} + return self.json_message('Entity removed') else: - return { - "message": "Entity not found", - "status_code": HTTP_NOT_FOUND, - } - - -def _handle_get_api_states_entity(handler, path_match, data): - """Return the state of a specific entity.""" - entity_id = path_match.group('entity_id') - - state = handler.server.hass.states.get(entity_id) - - if state: - handler.write_json(state) - else: - handler.write_json_message("State does not exist.", HTTP_NOT_FOUND) - - -def _handle_post_state_entity(handler, path_match, data): - """Handle updating the state of an entity. - - This handles the following paths: - /api/states/<entity_id> - """ - entity_id = path_match.group('entity_id') - - try: - new_state = data['state'] - except KeyError: - handler.write_json_message("state not specified", HTTP_BAD_REQUEST) - return - - attributes = data['attributes'] if 'attributes' in data else None - - is_new_state = handler.server.hass.states.get(entity_id) is None - - # Write state - handler.server.hass.states.set(entity_id, new_state, attributes) - - state = handler.server.hass.states.get(entity_id) - - status_code = HTTP_CREATED if is_new_state else HTTP_OK - - handler.write_json( - state.as_dict(), - status_code=status_code, - location=URL_API_STATES_ENTITY.format(entity_id)) - - -def _handle_delete_state_entity(handler, path_match, data): - """Handle request to delete an entity from state machine. - - This handles the following paths: - /api/states/<entity_id> - """ - entity_id = path_match.group('entity_id') - - if handler.server.hass.states.remove(entity_id): - handler.write_json_message( - "Entity not found", HTTP_NOT_FOUND) - else: - handler.write_json_message( - "Entity removed", HTTP_OK) + return self.json_message('Entity not found', HTTP_NOT_FOUND) class APIEventListenersView(HomeAssistantView): + """View to handle EventListeners requests.""" + url = URL_API_EVENTS name = "api:event-listeners" def get(self, request): - return events_json(self.hass) - - -def _handle_get_api_events(handler, path_match, data): - """Handle getting overview of event listeners.""" - handler.write_json(events_json(handler.server.hass)) + """Get event listeners.""" + return self.json(events_json(self.hass)) class APIEventView(HomeAssistantView): + """View to handle Event requests.""" + url = '/api/events/<event_type>' name = "api:event" def post(self, request, event_type): - event_data = request.values + """Fire events.""" + event_data = request.json + + if event_data is not None and not isinstance(event_data, dict): + return self.json_message('Event data should be a JSON object', + HTTP_BAD_REQUEST) # Special case handling for event STATE_CHANGED # We will try to convert state dicts back to State objects @@ -393,266 +250,150 @@ class APIEventView(HomeAssistantView): if state: event_data[key] = state - self.hass.bus.fire(event_type, request.values, ha.EventOrigin.remote) - - return {"message": "Event {} fired.".format(event_type)} - - -def _handle_api_post_events_event(handler, path_match, event_data): - """Handle firing of an event. - - This handles the following paths: /api/events/<event_type> + self.hass.bus.fire(event_type, event_data, ha.EventOrigin.remote) - Events from /api are threated as remote events. - """ - event_type = path_match.group('event_type') - - if event_data is not None and not isinstance(event_data, dict): - handler.write_json_message( - "event_data should be an object", HTTP_UNPROCESSABLE_ENTITY) - return - - event_origin = ha.EventOrigin.remote - - # Special case handling for event STATE_CHANGED - # We will try to convert state dicts back to State objects - if event_type == ha.EVENT_STATE_CHANGED and event_data: - for key in ('old_state', 'new_state'): - state = ha.State.from_dict(event_data.get(key)) - - if state: - event_data[key] = state - - handler.server.hass.bus.fire(event_type, event_data, event_origin) - - handler.write_json_message("Event {} fired.".format(event_type)) + return self.json_message("Event {} fired.".format(event_type)) class APIServicesView(HomeAssistantView): + """View to handle Services requests.""" + url = URL_API_SERVICES name = "api:services" def get(self, request): - return services_json(self.hass) - - -def _handle_get_api_services(handler, path_match, data): - """Handle getting overview of services.""" - handler.write_json(services_json(handler.server.hass)) + """Get registered services.""" + return self.json(services_json(self.hass)) class APIDomainServicesView(HomeAssistantView): + """View to handle DomainServices requests.""" + url = "/api/services/<domain>/<service>" name = "api:domain-services" - def post(self, request): - with TrackStates(self.hass) as changed_states: - self.hass.services.call(domain, service, request.values, True) - - return changed_states + 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) -# pylint: disable=invalid-name -def _handle_post_api_services_domain_service(handler, path_match, data): - """Handle calling a service. - - This handles the following paths: /api/services/<domain>/<service> - """ - domain = path_match.group('domain') - service = path_match.group('service') - - with TrackStates(handler.server.hass) as changed_states: - handler.server.hass.services.call(domain, service, data, True) - - handler.write_json(changed_states) + return self.json(changed_states) class APIEventForwardingView(HomeAssistantView): + """View to handle EventForwarding requests.""" + url = URL_API_EVENT_FORWARD name = "api:event-forward" + event_forwarder = None def post(self, request): + """Setup an event forwarder.""" + data = request.json + if data is None: + return self.json_message("No data received.", HTTP_BAD_REQUEST) try: - host = request.values['host'] - api_password = request.values['api_password'] + host = data['host'] + api_password = data['api_password'] except KeyError: - return { - "message": "No host or api_password received.", - "status_code": HTTP_BAD_REQUEST, - } + return self.json_message("No host or api_password received.", + HTTP_BAD_REQUEST) try: port = int(data['port']) if 'port' in data else None except ValueError: - return { - "message": "Invalid value received for port.", - "status_code": HTTP_UNPROCESSABLE_ENTITY, - } + return self.json_message("Invalid value received for port.", + HTTP_UNPROCESSABLE_ENTITY) api = rem.API(host, api_password, port) if not api.validate_api(): - return { - "message": "Unable to validate API.", - "status_code": HTTP_UNPROCESSABLE_ENTITY, - } + return self.json_message("Unable to validate API.", + HTTP_UNPROCESSABLE_ENTITY) - if self.hass.event_forwarder is None: - self.hass.event_forwarder = rem.EventForwarder(self.hass) + if self.event_forwarder is None: + self.event_forwarder = rem.EventForwarder(self.hass) - self.hass.event_forwarder.connect(api) + self.event_forwarder.connect(api) - return {"message": "Event forwarding setup."} + return self.json_message("Event forwarding setup.") def delete(self, request): + """Remove event forwarer.""" + data = request.json + if data is None: + return self.json_message("No data received.", HTTP_BAD_REQUEST) + try: - host = request.values['host'] + host = data['host'] except KeyError: - return { - "message": "No host received.", - "status_code": HTTP_BAD_REQUEST, - } + return self.json_message("No host received.", HTTP_BAD_REQUEST) try: port = int(data['port']) if 'port' in data else None except ValueError: - return { - "message": "Invalid value received for port", - "status_code": HTTP_UNPROCESSABLE_ENTITY, - } + return self.json_message("Invalid value received for port.", + HTTP_UNPROCESSABLE_ENTITY) - if self.hass.event_forwarder is not None: + if self.event_forwarder is not None: api = rem.API(host, None, port) - self.hass.event_forwarder.disconnect(api) - - return {"message": "Event forwarding cancelled."} - - -# pylint: disable=invalid-name -def _handle_post_api_event_forward(handler, path_match, data): - """Handle adding an event forwarding target.""" - try: - host = data['host'] - api_password = data['api_password'] - except KeyError: - handler.write_json_message( - "No host or api_password received.", HTTP_BAD_REQUEST) - return - - try: - port = int(data['port']) if 'port' in data else None - except ValueError: - handler.write_json_message( - "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) - return - - api = rem.API(host, api_password, port) - - if not api.validate_api(): - handler.write_json_message( - "Unable to validate API", HTTP_UNPROCESSABLE_ENTITY) - return + self.event_forwarder.disconnect(api) - if handler.server.event_forwarder is None: - handler.server.event_forwarder = \ - rem.EventForwarder(handler.server.hass) - - handler.server.event_forwarder.connect(api) - - handler.write_json_message("Event forwarding setup.") - - -def _handle_delete_api_event_forward(handler, path_match, data): - """Handle deleting an event forwarding target.""" - try: - host = data['host'] - except KeyError: - handler.write_json_message("No host received.", HTTP_BAD_REQUEST) - return - - try: - port = int(data['port']) if 'port' in data else None - except ValueError: - handler.write_json_message( - "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) - return - - if handler.server.event_forwarder is not None: - api = rem.API(host, None, port) - - handler.server.event_forwarder.disconnect(api) - - handler.write_json_message("Event forwarding cancelled.") + return self.json_message("Event forwarding cancelled.") class APIComponentsView(HomeAssistantView): + """View to handle Components requests.""" + url = URL_API_COMPONENTS name = "api:components" def get(self, request): - return self.hass.config.components - - -def _handle_get_api_components(handler, path_match, data): - """Return all the loaded components.""" - handler.write_json(handler.server.hass.config.components) + """Get current loaded components.""" + return self.json(self.hass.config.components) class APIErrorLogView(HomeAssistantView): + """View to handle ErrorLog requests.""" + url = URL_API_ERROR_LOG name = "api:error-log" def get(self, request): - # TODO - return {} - - -def _handle_get_api_error_log(handler, path_match, data): - """Return the logged errors for this session.""" - handler.write_file(handler.server.hass.config.path(ERROR_LOG_FILENAME), - False) + """Serve error log.""" + return self.file(request, self.hass.config.path(ERROR_LOG_FILENAME)) class APILogOutView(HomeAssistantView): + """View to handle Log Out requests.""" + url = URL_API_LOG_OUT name = "api:log-out" def post(self, request): - # TODO + """Handle log out.""" + # TODO kill session return {} -def _handle_post_api_log_out(handler, path_match, data): - """Log user out.""" - handler.send_response(HTTP_OK) - handler.destroy_session() - handler.end_headers() - - class APITemplateView(HomeAssistantView): + """View to handle requests.""" + url = URL_API_TEMPLATE name = "api:template" def post(self, request): - # TODO - return {} - - -def _handle_post_api_template(handler, path_match, data): - """Log user out.""" - template_string = data.get('template', '') - - try: - rendered = template.render(handler.server.hass, template_string) - - handler.send_response(HTTP_OK) - handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN) - handler.end_headers() - handler.wfile.write(rendered.encode('utf-8')) - except TemplateError as e: - handler.write_json_message(str(e), HTTP_UNPROCESSABLE_ENTITY) - return + """Render a template.""" + try: + return template.render(self.hass, request.json['template'], + request.json.get('variables')) + except TemplateError as ex: + return self.json_message('Error rendering template: {}'.format(ex), + HTTP_BAD_REQUEST) def services_json(hass): diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c473f159f65..e5c1bb6f77f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -6,17 +6,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/camera/ """ import logging -import re -import time - -import requests from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.components import bloomsky -from homeassistant.const import HTTP_OK, HTTP_NOT_FOUND, ATTR_ENTITY_ID from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa - +from homeassistant.components.http import HomeAssistantView DOMAIN = 'camera' DEPENDENCIES = ['http'] @@ -45,56 +40,10 @@ def setup(hass, config): logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, DISCOVERY_PLATFORMS) - component.setup(config) - - def _proxy_camera_image(handler, path_match, data): - """Serve the camera image via the HA server.""" - entity_id = path_match.group(ATTR_ENTITY_ID) - camera = component.entities.get(entity_id) - - if camera is None: - handler.send_response(HTTP_NOT_FOUND) - handler.end_headers() - return - - response = camera.camera_image() - - if response is None: - handler.send_response(HTTP_NOT_FOUND) - handler.end_headers() - return - - handler.send_response(HTTP_OK) - handler.write_content(response) + hass.wsgi.register_view(CameraImageView(hass, component.entities)) + hass.wsgi.register_view(CameraMjpegStream(hass, component.entities)) - hass.http.register_path( - 'GET', - re.compile(r'/api/camera_proxy/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _proxy_camera_image) - - def _proxy_camera_mjpeg_stream(handler, path_match, data): - """Proxy the camera image as an mjpeg stream via the HA server.""" - entity_id = path_match.group(ATTR_ENTITY_ID) - camera = component.entities.get(entity_id) - - if camera is None: - handler.send_response(HTTP_NOT_FOUND) - handler.end_headers() - return - - try: - camera.is_streaming = True - camera.update_ha_state() - camera.mjpeg_stream(handler) - - except (requests.RequestException, IOError): - camera.is_streaming = False - camera.update_ha_state() - - hass.http.register_path( - 'GET', - re.compile(r'/api/camera_proxy_stream/(?P<entity_id>[a-zA-Z\._0-9]+)'), - _proxy_camera_mjpeg_stream) + component.setup(config) return True @@ -135,32 +84,39 @@ class Camera(Entity): """Return bytes of camera image.""" raise NotImplementedError() - def mjpeg_stream(self, handler): + def mjpeg_stream(self, response): """Generate an HTTP MJPEG stream from camera images.""" - def write_string(text): - """Helper method to write a string to the stream.""" - handler.request.sendall(bytes(text + '\r\n', 'utf-8')) + import eventlet + response.mimetype = ('multipart/x-mixed-replace; ' + 'boundary={}'.format(MULTIPART_BOUNDARY)) + + boundary = bytes('\r\n{}\r\n'.format(MULTIPART_BOUNDARY), 'utf-8') - write_string('HTTP/1.1 200 OK') - write_string('Content-type: multipart/x-mixed-replace; ' - 'boundary={}'.format(MULTIPART_BOUNDARY)) - write_string('') - write_string(MULTIPART_BOUNDARY) + def stream(): + """Stream images as mjpeg stream.""" + try: + last_image = None + while True: + img_bytes = self.camera_image() - while True: - img_bytes = self.camera_image() + if img_bytes is None: + continue + elif img_bytes == last_image: + eventlet.sleep(0.5) - if img_bytes is None: - continue + yield bytes('Content-length: {}'.format(len(img_bytes)) + + '\r\nContent-type: image/jpeg\r\n\r\n', + 'utf-8') + yield img_bytes + yield boundary - write_string('Content-length: {}'.format(len(img_bytes))) - write_string('Content-type: image/jpeg') - write_string('') - handler.request.sendall(img_bytes) - write_string('') - write_string(MULTIPART_BOUNDARY) + eventlet.sleep(0.5) + except GeneratorExit: + pass - time.sleep(0.5) + response.response = stream() + + return response @property def state(self): @@ -184,3 +140,49 @@ class Camera(Entity): attr['brand'] = self.brand return attr + + +class CameraView(HomeAssistantView): + """Base CameraView.""" + + def __init__(self, hass, entities): + """Initialize a basic camera view.""" + super().__init__(hass) + self.entities = entities + + +class CameraImageView(CameraView): + """Camera view to serve an image.""" + + url = "/api/camera_proxy/<entity_id>" + name = "api:camera:image" + + def get(self, request, entity_id): + """Serve camera image.""" + camera = self.entities.get(entity_id) + + if camera is None: + return self.Response(status=404) + + response = camera.camera_image() + + if response is None: + return self.Response(status=500) + + return self.Response(response) + + +class CameraMjpegStream(CameraView): + """Camera View to serve an MJPEG stream.""" + + url = "/api/camera_proxy_stream/<entity_id>" + name = "api:camera:stream" + + def get(self, request, entity_id): + """Serve camera image.""" + camera = self.entities.get(entity_id) + + if camera is None: + return self.Response(status=404) + + return camera.mjpeg_stream(self.Response()) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 9d5c9d96b92..79c88eb8d28 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -11,7 +11,6 @@ import requests from requests.auth import HTTPBasicAuth from homeassistant.components.camera import DOMAIN, Camera -from homeassistant.const import HTTP_OK from homeassistant.helpers import validate_config CONTENT_TYPE_HEADER = 'Content-Type' @@ -68,19 +67,12 @@ class MjpegCamera(Camera): with closing(self.camera_stream()) as response: return process_response(response) - def mjpeg_stream(self, handler): + def mjpeg_stream(self, response): """Generate an HTTP MJPEG stream from the camera.""" - response = self.camera_stream() - content_type = response.headers[CONTENT_TYPE_HEADER] - - handler.send_response(HTTP_OK) - handler.send_header(CONTENT_TYPE_HEADER, content_type) - handler.end_headers() - - for chunk in response.iter_content(chunk_size=1024): - if not chunk: - break - handler.wfile.write(chunk) + stream = self.camera_stream() + response.mimetype = stream.headers[CONTENT_TYPE_HEADER] + response.response = stream.iter_content(chunk_size=1024) + return response @property def name(self): diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index 0bb5b5ed318..1b29e7083a2 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -5,95 +5,92 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.locative/ """ import logging -from functools import partial from homeassistant.components.device_tracker import DOMAIN from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME +from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['http'] -URL_API_LOCATIVE_ENDPOINT = "/api/locative" - def setup_scanner(hass, config, see): """Setup an endpoint for the Locative application.""" - # POST would be semantically better, but that currently does not work - # since Locative sends the data as key1=value1&key2=value2 - # in the request body, while Home Assistant expects json there. - hass.http.register_path( - 'GET', URL_API_LOCATIVE_ENDPOINT, - partial(_handle_get_api_locative, hass, see)) + hass.wsgi.register_view(LocativeView(hass, see)) return True -def _handle_get_api_locative(hass, see, handler, path_match, data): - """Locative message received.""" - if not _check_data(handler, data): - return - - device = data['device'].replace('-', '') - location_name = data['id'].lower() - direction = data['trigger'] - - if direction == 'enter': - see(dev_id=device, location_name=location_name) - handler.write_text("Setting location to {}".format(location_name)) +class LocativeView(HomeAssistantView): + """View to handle locative requests.""" + + url = "/api/locative" + name = "api:bootstrap" + + def __init__(self, hass, see): + """Initialize Locative url endpoints.""" + super().__init__(hass) + self.see = see + + def get(self, request): + """Locative message received as GET.""" + return self.post(request) + + def post(self, request): + """Locative message received.""" + # pylint: disable=too-many-return-statements + data = request.values + + if 'latitude' not in data or 'longitude' not in data: + return ("Latitude and longitude not specified.", + HTTP_UNPROCESSABLE_ENTITY) + + if 'device' not in data: + _LOGGER.error("Device id not specified.") + return ("Device id not specified.", + HTTP_UNPROCESSABLE_ENTITY) + + if 'id' not in data: + _LOGGER.error("Location id not specified.") + return ("Location id not specified.", + HTTP_UNPROCESSABLE_ENTITY) + + if 'trigger' not in data: + _LOGGER.error("Trigger is not specified.") + return ("Trigger is not specified.", + HTTP_UNPROCESSABLE_ENTITY) + + device = data['device'].replace('-', '') + location_name = data['id'].lower() + direction = data['trigger'] + + if direction == 'enter': + self.see(dev_id=device, location_name=location_name) + return "Setting location to {}".format(location_name) + + elif direction == 'exit': + current_state = self.hass.states.get( + "{}.{}".format(DOMAIN, device)) + + if current_state is None or current_state.state == location_name: + 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 + # aren't currently in. This occurs when a zone is entered + # before the previous zone was exited. The enter message will + # be sent first, then the exit message will be sent second. + return 'Ignoring exit from {} (already in {})'.format( + location_name, current_state) + + elif direction == 'test': + # In the app, a test message can be sent. Just return something to + # the user to let them know that it works. + return "Received test message." - elif direction == 'exit': - current_state = hass.states.get("{}.{}".format(DOMAIN, device)) - - if current_state is None or current_state.state == location_name: - see(dev_id=device, location_name=STATE_NOT_HOME) - handler.write_text("Setting location to not home") else: - # Ignore the message if it is telling us to exit a zone that we - # aren't currently in. This occurs when a zone is entered before - # the previous zone was exited. The enter message will be sent - # first, then the exit message will be sent second. - handler.write_text( - 'Ignoring exit from {} (already in {})'.format( - location_name, current_state)) - - elif direction == 'test': - # In the app, a test message can be sent. Just return something to - # the user to let them know that it works. - handler.write_text("Received test message.") - - else: - handler.write_text( - "Received unidentified message: {}".format(direction), - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.error("Received unidentified message from Locative: %s", - direction) - - -def _check_data(handler, data): - """Check the data.""" - if 'latitude' not in data or 'longitude' not in data: - handler.write_text("Latitude and longitude not specified.", - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.error("Latitude and longitude not specified.") - return False - - if 'device' not in data: - handler.write_text("Device id not specified.", - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.error("Device id not specified.") - return False - - if 'id' not in data: - handler.write_text("Location id not specified.", - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.error("Location id not specified.") - return False - - if 'trigger' not in data: - handler.write_text("Trigger is not specified.", - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.error("Trigger is not specified.") - return False - - return True + _LOGGER.error("Received unidentified message from Locative: %s", + direction) + return ("Received unidentified message: {}".format(direction), + HTTP_UNPROCESSABLE_ENTITY) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ca8e65e27a5..b7bca3cfd45 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -4,10 +4,9 @@ import os import logging from . import version, mdi_version -import homeassistant.util as util -from homeassistant.const import URL_ROOT, HTTP_OK +from homeassistant.const import URL_ROOT from homeassistant.components import api -from homeassistant.components.wsgi import HomeAssistantView +from homeassistant.components.http import HomeAssistantView DOMAIN = 'frontend' DEPENDENCIES = ['api'] @@ -29,27 +28,6 @@ _FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) def setup(hass, config): """Setup serving the frontend.""" - for url in FRONTEND_URLS: - hass.http.register_path('GET', url, _handle_get_root, False) - - hass.http.register_path('GET', '/service_worker.js', - _handle_get_service_worker, False) - - # Bootstrap API - hass.http.register_path( - 'GET', URL_API_BOOTSTRAP, _handle_get_api_bootstrap) - - # Static files - hass.http.register_path( - 'GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'), - _handle_get_static, False) - hass.http.register_path( - 'HEAD', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'), - _handle_get_static, False) - hass.http.register_path( - 'GET', re.compile(r'/local/(?P<file>[a-zA-Z\._\-0-9/]+)'), - _handle_get_local, False) - hass.wsgi.register_view(IndexView) hass.wsgi.register_view(BootstrapView) @@ -70,32 +48,37 @@ def setup(hass, config): class BootstrapView(HomeAssistantView): + """View to bootstrap frontend with all needed data.""" + url = URL_API_BOOTSTRAP name = "api:bootstrap" def get(self, request): """Return all data needed to bootstrap Home Assistant.""" - - return { + 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), - } + }) class IndexView(HomeAssistantView): + """Serve the frontend.""" + url = URL_ROOT name = "frontend:index" + requires_auth = False extra_urls = ['/logbook', '/history', '/map', '/devService', '/devState', '/devEvent', '/devInfo', '/devTemplate', '/states/<entity>'] def __init__(self, hass): + """Initialize the frontend view.""" super().__init__(hass) from jinja2 import FileSystemLoader, Environment - self.TEMPLATES = Environment( + self.templates = Environment( loader=FileSystemLoader( os.path.join(os.path.dirname(__file__), 'templates/') ) @@ -106,81 +89,12 @@ class IndexView(HomeAssistantView): app_url = "frontend-{}.html".format(version.VERSION) # auto login if no password was set, else check api_password param - auth = ('no_password_set' if request.api_password is None + auth = ('no_password_set' if self.hass.config.api.api_password is None else request.values.get('api_password', '')) - template = self.TEMPLATES.get_template('index.html') + template = self.templates.get_template('index.html') resp = template.render(app_url=app_url, auth=auth, icons=mdi_version.VERSION) return self.Response(resp, mimetype="text/html") - - -def _handle_get_api_bootstrap(handler, path_match, data): - """Return all data needed to bootstrap Home Assistant.""" - hass = handler.server.hass - - handler.write_json({ - 'config': hass.config.as_dict(), - 'states': hass.states.all(), - 'events': api.events_json(hass), - 'services': api.services_json(hass), - }) - - -def _handle_get_root(handler, path_match, data): - """Render the frontend.""" - if handler.server.development: - app_url = "home-assistant-polymer/src/home-assistant.html" - else: - app_url = "frontend-{}.html".format(version.VERSION) - - # auto login if no password was set, else check api_password param - auth = ('no_password_set' if handler.server.api_password is None - else data.get('api_password', '')) - - with open(INDEX_PATH) as template_file: - template_html = template_file.read() - - template_html = template_html.replace('{{ app_url }}', app_url) - template_html = template_html.replace('{{ auth }}', auth) - template_html = template_html.replace('{{ icons }}', mdi_version.VERSION) - - handler.send_response(HTTP_OK) - handler.write_content(template_html.encode("UTF-8"), - 'text/html; charset=utf-8') - - -def _handle_get_service_worker(handler, path_match, data): - """Return service worker for the frontend.""" - if handler.server.development: - sw_path = "home-assistant-polymer/build/service_worker.js" - else: - sw_path = "service_worker.js" - - handler.write_file(os.path.join(os.path.dirname(__file__), 'www_static', - sw_path)) - - -def _handle_get_static(handler, path_match, data): - """Return a static file for the frontend.""" - req_file = util.sanitize_path(path_match.group('file')) - - # Strip md5 hash out - fingerprinted = _FINGERPRINT.match(req_file) - if fingerprinted: - req_file = "{}.{}".format(*fingerprinted.groups()) - - path = os.path.join(os.path.dirname(__file__), 'www_static', req_file) - - handler.write_file(path) - - -def _handle_get_local(handler, path_match, data): - """Return a static file from the hass.config.path/www for the frontend.""" - req_file = util.sanitize_path(path_match.group('file')) - - path = handler.server.hass.config.path('www', req_file) - - handler.write_file(path) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index b3ddbe21415..4e1348e1fa9 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -12,6 +12,7 @@ from itertools import groupby from homeassistant.components import recorder, script import homeassistant.util.dt as dt_util from homeassistant.const import HTTP_BAD_REQUEST +from homeassistant.components.http import HomeAssistantView DOMAIN = 'history' DEPENDENCIES = ['recorder', 'http'] @@ -155,49 +156,51 @@ def get_state(utc_point_in_time, entity_id, run=None): # pylint: disable=unused-argument def setup(hass, config): """Setup the history hooks.""" - hass.http.register_path( - 'GET', - re.compile( - r'/api/history/entity/(?P<entity_id>[a-zA-Z\._0-9]+)/' - r'recent_states'), - _api_last_5_states) - - hass.http.register_path('GET', URL_HISTORY_PERIOD, _api_history_period) + hass.wsgi.register_view(Last5StatesView) + hass.wsgi.register_view(HistoryPeriodView) return True -# pylint: disable=unused-argument -# pylint: disable=invalid-name -def _api_last_5_states(handler, path_match, data): - """Return the last 5 states for an entity id as JSON.""" - entity_id = path_match.group('entity_id') +class Last5StatesView(HomeAssistantView): + """Handle last 5 state view requests.""" + + url = '/api/history/entity/<entity_id>/recent_states' + name = 'api:history:entity-recent-states' + + def get(self, request, entity_id): + """Retrieve last 5 states of entity.""" + return self.json(last_5_states(entity_id)) + - handler.write_json(last_5_states(entity_id)) +class HistoryPeriodView(HomeAssistantView): + """Handle history period requests.""" + url = '/api/history/period' + name = 'api:history:entity-recent-states' + extra_urls = ['/api/history/period/<date>'] -def _api_history_period(handler, path_match, data): - """Return history over a period of time.""" - date_str = path_match.group('date') - one_day = timedelta(seconds=86400) + def get(self, request, date=None): + """Return history over a period of time.""" + one_day = timedelta(seconds=86400) - if date_str: - start_date = dt_util.parse_date(date_str) + if date: + start_date = dt_util.parse_date(date) - if start_date is None: - handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST) - return + if start_date is None: + return self.json_message('Error parsing JSON', + HTTP_BAD_REQUEST) - start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date)) - else: - start_time = dt_util.utcnow() - one_day + start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date)) + else: + start_time = dt_util.utcnow() - one_day - end_time = start_time + one_day + end_time = start_time + one_day - entity_id = data.get('filter_entity_id') + entity_id = request.args.get('filter_entity_id') - handler.write_json( - get_significant_states(start_time, end_time, entity_id).values()) + return self.json( + get_significant_states(start_time, end_time, entity_id).values()) def _is_significant(state): diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 3f488b0f9ff..df1b043ce0b 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -1,41 +1,17 @@ -""" -This module provides an API and a HTTP interface for debug purposes. - -For more details about the RESTful API, please refer to the documentation at -https://home-assistant.io/developers/api/ -""" -import gzip +"""This module provides WSGI application to serve the Home Assistant API.""" import hmac import json import logging -import ssl import threading -import time -from datetime import timedelta -from http import cookies -from http.server import HTTPServer, SimpleHTTPRequestHandler -from socketserver import ThreadingMixIn -from urllib.parse import parse_qs, urlparse -import voluptuous as vol - -import homeassistant.bootstrap as bootstrap +import re + import homeassistant.core as ha import homeassistant.remote as rem -import homeassistant.util as util -import homeassistant.util.dt as date_util -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, HTTP_HEADER_ACCEPT_ENCODING, - HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_CONTENT_ENCODING, - HTTP_HEADER_CONTENT_LENGTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_EXPIRES, - HTTP_HEADER_HA_AUTH, HTTP_HEADER_VARY, - HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, - HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, HTTP_METHOD_NOT_ALLOWED, - HTTP_NOT_FOUND, HTTP_OK, HTTP_UNAUTHORIZED, HTTP_UNPROCESSABLE_ENTITY, - ALLOWED_CORS_HEADERS, - SERVER_PORT, URL_ROOT, URL_API_EVENT_FORWARD) +from homeassistant import util +from homeassistant.const import SERVER_PORT, HTTP_HEADER_HA_AUTH DOMAIN = "http" +REQUIREMENTS = ("eventlet==0.18.4", "static3==0.6.1", "Werkzeug==0.11.5",) CONF_API_PASSWORD = "api_password" CONF_SERVER_HOST = "server_host" @@ -43,61 +19,42 @@ CONF_SERVER_PORT = "server_port" CONF_DEVELOPMENT = "development" CONF_SSL_CERTIFICATE = 'ssl_certificate' CONF_SSL_KEY = 'ssl_key' -CONF_CORS_ORIGINS = 'cors_allowed_origins' DATA_API_PASSWORD = 'api_password' -# Throttling time in seconds for expired sessions check -SESSION_CLEAR_INTERVAL = timedelta(seconds=20) -SESSION_TIMEOUT_SECONDS = 1800 -SESSION_KEY = 'sessionId' +_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_API_PASSWORD): cv.string, - vol.Optional(CONF_SERVER_HOST): cv.string, - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): - vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), - vol.Optional(CONF_DEVELOPMENT): cv.string, - vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, - vol.Optional(CONF_SSL_KEY): cv.isfile, - vol.Optional(CONF_CORS_ORIGINS): cv.ensure_list - }), -}, extra=vol.ALLOW_EXTRA) - def setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN, {}) api_password = util.convert(conf.get(CONF_API_PASSWORD), str) - - # If no server host is given, accept all incoming requests server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0') server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT) development = str(conf.get(CONF_DEVELOPMENT, "")) == "1" ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) ssl_key = conf.get(CONF_SSL_KEY) - cors_origins = conf.get(CONF_CORS_ORIGINS, []) - try: - server = HomeAssistantHTTPServer( - (server_host, server_port), RequestHandler, hass, api_password, - development, ssl_certificate, ssl_key, cors_origins) - except OSError: - # If address already in use - _LOGGER.exception("Error setting up HTTP server") - return False + server = HomeAssistantWSGI( + hass, + development=development, + server_host=server_host, + server_port=server_port, + api_password=api_password, + ssl_certificate=ssl_certificate, + ssl_key=ssl_key, + ) hass.bus.listen_once( ha.EVENT_HOMEASSISTANT_START, lambda event: threading.Thread(target=server.start, daemon=True, - name='HTTP-server').start()) + name='WSGI-server').start()) - hass.http = server + hass.wsgi = server hass.config.api = rem.API(server_host if server_host != '0.0.0.0' else util.get_local_ip(), api_password, server_port, @@ -106,413 +63,277 @@ def setup(hass, config): return True -# pylint: disable=too-many-instance-attributes -class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer): - """Handle HTTP requests in a threaded fashion.""" +# class StaticFileServer(object): +# """Static file serving middleware.""" + +# def __call__(self, environ, start_response): +# from werkzeug.wsgi import DispatcherMiddleware +# app = DispatcherMiddleware(self.base_app, self.extra_apps) +# # Strip out any cachebusting MD% fingerprints +# fingerprinted = _FINGERPRINT.match(environ['PATH_INFO']) +# if fingerprinted: +# environ['PATH_INFO'] = "{}.{}".format(*fingerprinted.groups()) +# return app(environ, start_response) + - # pylint: disable=too-few-public-methods - allow_reuse_address = True - daemon_threads = True +class HomeAssistantWSGI(object): + """WSGI server for Home Assistant.""" + # pylint: disable=too-many-instance-attributes, too-many-locals # pylint: disable=too-many-arguments - def __init__(self, server_address, request_handler_class, - hass, api_password, development, ssl_certificate, ssl_key, - cors_origins): - """Initialize the server.""" - super().__init__(server_address, request_handler_class) - self.server_address = server_address + def __init__(self, hass, development, api_password, ssl_certificate, + ssl_key, server_host, server_port): + """Initilalize the WSGI Home Assistant server.""" + from werkzeug.exceptions import BadRequest + from werkzeug.wrappers import BaseRequest, AcceptMixin + from werkzeug.contrib.wrappers import JSONRequestMixin + from werkzeug.routing import Map + from werkzeug.utils import cached_property + from werkzeug.wrappers import Response + + class Request(BaseRequest, AcceptMixin, JSONRequestMixin): + """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') + + Response.mimetype = 'text/html' + + # pylint: disable=invalid-name + self.Request = Request + self.url_map = Map() + self.views = {} self.hass = hass - self.api_password = api_password + self.extra_apps = {} self.development = development - self.paths = [] - self.sessions = SessionStore() - self.use_ssl = ssl_certificate is not None - self.cors_origins = cors_origins - - # We will lazy init this one if needed + 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.event_forwarder = None - if development: - _LOGGER.info("running http in development mode") - - if ssl_certificate is not None: - context = ssl.create_default_context( - purpose=ssl.Purpose.CLIENT_AUTH) - context.load_cert_chain(ssl_certificate, keyfile=ssl_key) - self.socket = context.wrap_socket(self.socket, server_side=True) - - def start(self): - """Start the HTTP server.""" - def stop_http(event): - """Stop the HTTP server.""" - self.shutdown() - - self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http) - - protocol = 'https' if self.use_ssl else 'http' - - _LOGGER.info( - "Starting web interface at %s://%s:%d", - protocol, self.server_address[0], self.server_address[1]) - - # 31-1-2015: Refactored frontend/api components out of this component - # To prevent stuff from breaking, load the two extracted components - bootstrap.setup_component(self.hass, 'api') - bootstrap.setup_component(self.hass, 'frontend') - - self.serve_forever() - - def register_path(self, method, url, callback, require_auth=True): - """Register a path with the server.""" - self.paths.append((method, url, callback, require_auth)) - - def log_message(self, fmt, *args): - """Redirect built-in log to HA logging.""" - # pylint: disable=no-self-use - _LOGGER.info(fmt, *args) - - -# pylint: disable=too-many-public-methods,too-many-locals -class RequestHandler(SimpleHTTPRequestHandler): - """Handle incoming HTTP requests. + def register_view(self, view): + """Register a view with the WSGI server. - We extend from SimpleHTTPRequestHandler instead of Base so we - can use the guess content type methods. - """ - - server_version = "HomeAssistant/1.0" - - def __init__(self, req, client_addr, server): - """Constructor, call the base constructor and set up session.""" - # Track if this was an authenticated request - self.authenticated = False - SimpleHTTPRequestHandler.__init__(self, req, client_addr, server) - self.protocol_version = 'HTTP/1.1' - - def log_message(self, fmt, *arguments): - """Redirect built-in log to HA logging.""" - if self.server.api_password is None: - _LOGGER.info(fmt, *arguments) - else: - _LOGGER.info( - fmt, *(arg.replace(self.server.api_password, '*******') - if isinstance(arg, str) else arg for arg in arguments)) - - def _handle_request(self, method): # pylint: disable=too-many-branches - """Perform some common checks and call appropriate method.""" - url = urlparse(self.path) - - # Read query input. parse_qs gives a list for each value, we want last - data = {key: data[-1] for key, data in parse_qs(url.query).items()} - - # Did we get post input ? - content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) - - if content_length: - body_content = self.rfile.read(content_length).decode("UTF-8") - - try: - data.update(json.loads(body_content)) - except (TypeError, ValueError): - # TypeError if JSON object is not a dict - # ValueError if we could not parse JSON - _LOGGER.exception( - "Exception parsing JSON: %s", body_content) - self.write_json_message( - "Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY) - return - - if self.verify_session(): - # The user has a valid session already - self.authenticated = True - elif self.server.api_password is None: - # No password is set, so everyone is authenticated - self.authenticated = True - elif hmac.compare_digest(self.headers.get(HTTP_HEADER_HA_AUTH, ''), - self.server.api_password): - # A valid auth header has been set - self.authenticated = True - elif hmac.compare_digest(data.get(DATA_API_PASSWORD, ''), - self.server.api_password): - # A valid password has been specified - self.authenticated = True - else: - self.authenticated = False - - # we really shouldn't need to forward the password from here - if url.path not in [URL_ROOT, URL_API_EVENT_FORWARD]: - data.pop(DATA_API_PASSWORD, None) - - if '_METHOD' in data: - method = data.pop('_METHOD') - - # Var to keep track if we found a path that matched a handler but - # the method was different - path_matched_but_not_method = False - - # Var to hold the handler for this path and method if found - handle_request_method = False - require_auth = True - - # Check every handler to find matching result - for t_method, t_path, t_handler, t_auth in self.server.paths: - # we either do string-comparison or regular expression matching - # pylint: disable=maybe-no-member - if isinstance(t_path, str): - path_match = url.path == t_path - else: - path_match = t_path.match(url.path) - - if path_match and method == t_method: - # Call the method - handle_request_method = t_handler - require_auth = t_auth - break - - elif path_match: - path_matched_but_not_method = True - - # Did we find a handler for the incoming request? - if handle_request_method: - # For some calls we need a valid password - msg = "API password missing or incorrect." - if require_auth and not self.authenticated: - self.write_json_message(msg, HTTP_UNAUTHORIZED) - _LOGGER.warning('%s Source IP: %s', - msg, - self.client_address[0]) - return - - handle_request_method(self, path_match, data) - - elif path_matched_but_not_method: - self.send_response(HTTP_METHOD_NOT_ALLOWED) - self.end_headers() - - else: - self.send_response(HTTP_NOT_FOUND) - self.end_headers() - - def do_HEAD(self): # pylint: disable=invalid-name - """HEAD request handler.""" - self._handle_request('HEAD') - - def do_GET(self): # pylint: disable=invalid-name - """GET request handler.""" - self._handle_request('GET') - - def do_POST(self): # pylint: disable=invalid-name - """POST request handler.""" - self._handle_request('POST') + The view argument must inherit from the HomeAssistantView class, and + it must have (globally unique) 'url' and 'name' attributes. + """ + from werkzeug.routing import Rule - def do_PUT(self): # pylint: disable=invalid-name - """PUT request handler.""" - self._handle_request('PUT') + if view.name in self.views: + _LOGGER.warning("View '%s' is being overwritten", view.name) + if isinstance(view, type): + view = view(self.hass) - def do_DELETE(self): # pylint: disable=invalid-name - """DELETE request handler.""" - self._handle_request('DELETE') + self.views[view.name] = view - def write_json_message(self, message, status_code=HTTP_OK): - """Helper method to return a message to the caller.""" - self.write_json({'message': message}, status_code=status_code) + 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) - def write_json(self, data=None, status_code=HTTP_OK, location=None): - """Helper method to return JSON to the caller.""" - json_data = json.dumps(data, indent=4, sort_keys=True, - cls=rem.JSONEncoder).encode('UTF-8') - self.send_response(status_code) + def register_redirect(self, url, redirect_to): + """Register a redirect with the server. - if location: - self.send_header('Location', location) + If given this must be either a string or callable. In case of a + callable it’s called with the url adapter that triggered the match and + the values of the URL as keyword arguments and has to return the target + for the redirect, otherwise it has to be a string with placeholders in + rule syntax. + """ + from werkzeug.routing import Rule - self.set_session_cookie_header() + self.url_map.add(Rule(url, redirect_to=redirect_to)) - self.write_content(json_data, CONTENT_TYPE_JSON) + def register_static_path(self, url_root, path): + """Register a folder to serve as a static path.""" + from static import Cling - def write_text(self, message, status_code=HTTP_OK): - """Helper method to return a text message to the caller.""" - msg_data = message.encode('UTF-8') - self.send_response(status_code) - self.set_session_cookie_header() + if url_root in self.extra_apps: + _LOGGER.warning("Static path '%s' is being overwritten", path) + self.extra_apps[url_root] = Cling(path) - self.write_content(msg_data, CONTENT_TYPE_TEXT_PLAIN) + def start(self): + """Start the wsgi server.""" + from eventlet import wsgi + import eventlet + + sock = eventlet.listen((self.server_host, self.server_port)) + if self.ssl_certificate: + eventlet.wrap_ssl(sock, certfile=self.ssl_certificate, + keyfile=self.ssl_key, server_side=True) + wsgi.server(sock, self) + + def dispatch_request(self, request): + """Handle incoming request.""" + from werkzeug.exceptions import ( + MethodNotAllowed, NotFound, BadRequest, Unauthorized, + ) + from werkzeug.routing import RequestRedirect - def write_file(self, path, cache_headers=True): - """Return a file to the user.""" + adapter = self.url_map.bind_to_environ(request.environ) try: - with open(path, 'rb') as inp: - self.write_file_pointer(self.guess_type(path), inp, - cache_headers) - - except IOError: - self.send_response(HTTP_NOT_FOUND) - self.end_headers() - _LOGGER.exception("Unable to serve %s", path) - - def write_file_pointer(self, content_type, inp, cache_headers=True): - """Helper function to write a file pointer to the user.""" - self.send_response(HTTP_OK) - - if cache_headers: - self.set_cache_header() - self.set_session_cookie_header() - - self.write_content(inp.read(), content_type) - - def write_content(self, content, content_type=None): - """Helper method to write content bytes to output stream.""" - if content_type is not None: - self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type) - - if 'gzip' in self.headers.get(HTTP_HEADER_ACCEPT_ENCODING, ''): - content = gzip.compress(content) - - self.send_header(HTTP_HEADER_CONTENT_ENCODING, "gzip") - self.send_header(HTTP_HEADER_VARY, HTTP_HEADER_ACCEPT_ENCODING) - - self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(len(content))) - - cors_check = (self.headers.get("Origin") in self.server.cors_origins) - - cors_headers = ", ".join(ALLOWED_CORS_HEADERS) - - if self.server.cors_origins and cors_check: - self.send_header(HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN, - self.headers.get("Origin")) - self.send_header(HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, - cors_headers) - self.end_headers() - - if self.command == 'HEAD': - return - - self.wfile.write(content) - - def set_cache_header(self): - """Add cache headers if not in development.""" - if self.server.development: - return + endpoint, values = adapter.match() + return self.views[endpoint].handle_request(request, **values) + except RequestRedirect as ex: + return ex + except BadRequest as ex: + return self._handle_error(request, str(ex), 400) + except NotFound as ex: + return self._handle_error(request, str(ex), 404) + except MethodNotAllowed as ex: + return self._handle_error(request, str(ex), 405) + except Unauthorized as ex: + return self._handle_error(request, str(ex), 401) + # TODO This long chain of except blocks is silly. _handle_error should + # just take the exception as an argument and parse the status code + # itself + + def base_app(self, environ, start_response): + """WSGI Handler of requests to base app.""" + request = self.Request(environ) + response = self.dispatch_request(request) + return response(environ, start_response) + + def __call__(self, environ, start_response): + """Handle a request for base app + extra apps.""" + from werkzeug.wsgi import DispatcherMiddleware + + 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) + + def _handle_error(self, request, message, status): + """Handle a WSGI request error.""" + from werkzeug.wrappers import Response + if request.accept_mimetypes.accept_json: + message = json.dumps({ + "result": "error", + "message": message, + }) + mimetype = "application/json" + else: + mimetype = "text/plain" + return Response(message, status=status, mimetype=mimetype) - # 1 year in seconds - cache_time = 365 * 86400 - self.send_header( - HTTP_HEADER_CACHE_CONTROL, - "public, max-age={}".format(cache_time)) - self.send_header( - HTTP_HEADER_EXPIRES, - self.date_time_string(time.time()+cache_time)) +class HomeAssistantView(object): + """Base view for all views.""" - def set_session_cookie_header(self): - """Add the header for the session cookie and return session ID.""" - if not self.authenticated: - return None + extra_urls = [] + requires_auth = True # Views inheriting from this class can override this - session_id = self.get_cookie_session_id() + def __init__(self, hass): + """Initilalize the base view.""" + from werkzeug.wrappers import Response - if session_id is not None: - self.server.sessions.extend_validation(session_id) - return session_id + self.hass = hass + # pylint: disable=invalid-name + self.Response = Response - self.send_header( - 'Set-Cookie', - '{}={}'.format(SESSION_KEY, self.server.sessions.create()) + def handle_request(self, request, **values): + """Handle request to url.""" + from werkzeug.exceptions import ( + MethodNotAllowed, Unauthorized, BadRequest, ) - return session_id - - def verify_session(self): - """Verify that we are in a valid session.""" - return self.get_cookie_session_id() is not None - - def get_cookie_session_id(self): - """Extract the current session ID from the cookie. - - Return None if not set or invalid. - """ - if 'Cookie' not in self.headers: - return None - - cookie = cookies.SimpleCookie() try: - cookie.load(self.headers["Cookie"]) - except cookies.CookieError: - return None - - morsel = cookie.get(SESSION_KEY) - - if morsel is None: - return None - - session_id = cookie[SESSION_KEY].value - - if self.server.sessions.is_valid(session_id): - return session_id - - return None - - def destroy_session(self): - """Destroy the session.""" - session_id = self.get_cookie_session_id() - - if session_id is None: - return - - self.send_header('Set-Cookie', '') - self.server.sessions.destroy(session_id) + handler = getattr(self, request.method.lower()) + except AttributeError: + raise MethodNotAllowed + # TODO: session support + uncomment session test -def session_valid_time(): - """Time till when a session will be valid.""" - return date_util.utcnow() + timedelta(seconds=SESSION_TIMEOUT_SECONDS) + # Auth code verbose on purpose + authenticated = False + if not self.requires_auth: + authenticated = True -class SessionStore(object): - """Responsible for storing and retrieving HTTP sessions.""" + elif self.hass.wsgi.api_password is None: + authenticated = True - def __init__(self): - """Setup the session store.""" - self._sessions = {} - self._lock = threading.RLock() - - @util.Throttle(SESSION_CLEAR_INTERVAL) - def _remove_expired(self): - """Remove any expired sessions.""" - now = date_util.utcnow() - for key in [key for key, valid_time in self._sessions.items() - if valid_time < now]: - self._sessions.pop(key) - - def is_valid(self, key): - """Return True if a valid session is given.""" - with self._lock: - self._remove_expired() - - return (key in self._sessions and - self._sessions[key] > date_util.utcnow()) - - def extend_validation(self, key): - """Extend a session validation time.""" - with self._lock: - if key not in self._sessions: - return - self._sessions[key] = session_valid_time() - - def destroy(self, key): - """Destroy a session by key.""" - with self._lock: - self._sessions.pop(key, None) + elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''), + self.hass.wsgi.api_password): + # A valid auth header has been set + authenticated = True - def create(self): - """Create a new session.""" - with self._lock: - session_id = util.get_random_string(20) + elif hmac.compare_digest(request.args.get(DATA_API_PASSWORD, ''), + self.hass.wsgi.api_password): + authenticated = True - while session_id in self._sessions: - session_id = util.get_random_string(20) + else: + # Do we still want to support passing it in as post data? + try: + json_data = request.json + if (json_data is not None and + hmac.compare_digest( + json_data.get(DATA_API_PASSWORD, ''), + self.hass.wsgi.api_password)): + authenticated = True + except BadRequest: + pass + + if not authenticated: + raise Unauthorized() + + result = handler(request, **values) + + if isinstance(result, self.Response): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = 200 + + 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="application/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, content_type=None): + """Return a file.""" + from werkzeug.wsgi import wrap_file + from werkzeug.exceptions import NotFound + + if isinstance(fil, str): + try: + fil = open(fil) + except IOError: + raise NotFound() - self._sessions[session_id] = session_valid_time() + # TODO mimetypes, etc - return session_id + resp = self.Response(wrap_file(request.environ, fil)) + if content_type is not None: + resp.mimetype = content_type + return resp diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 052f30bf83b..629fb236b3c 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -21,6 +21,7 @@ from homeassistant.core import State from homeassistant.helpers.entity import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView DOMAIN = "logbook" DEPENDENCIES = ['recorder', 'http'] @@ -76,34 +77,40 @@ def setup(hass, config): message = template.render(hass, message) log_entry(hass, name, message, domain, entity_id) - hass.http.register_path('GET', URL_LOGBOOK, _handle_get_logbook) + hass.wsgi.register_view(LogbookView) + hass.services.register(DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA) return True -def _handle_get_logbook(handler, path_match, data): - """Return logbook entries.""" - date_str = path_match.group('date') +class LogbookView(HomeAssistantView): + """Handle logbook view requests.""" + + url = '/api/logbook' + name = 'api:logbook' + extra_urls = ['/api/logbook/<date>'] - if date_str: - start_date = dt_util.parse_date(date_str) + def get(self, request, date=None): + """Retrieve logbook entries.""" + if date: + start_date = dt_util.parse_date(date) - if start_date is None: - handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST) - return + if start_date is None: + return self.json_message('Error parsing JSON', + HTTP_BAD_REQUEST) - start_day = dt_util.start_of_local_day(start_date) - else: - start_day = dt_util.start_of_local_day() + start_day = dt_util.start_of_local_day(start_date) + else: + start_day = dt_util.start_of_local_day() - end_day = start_day + timedelta(days=1) + end_day = start_day + timedelta(days=1) - events = recorder.query_events( - QUERY_EVENTS_BETWEEN, - (dt_util.as_utc(start_day), dt_util.as_utc(end_day))) + events = recorder.query_events( + QUERY_EVENTS_BETWEEN, + (dt_util.as_utc(start_day), dt_util.as_utc(end_day))) - handler.write_json(humanify(events)) + return self.json(humanify(events)) class Entry(object): diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index eb9e6fdc00d..b83c11c9e26 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -14,6 +14,7 @@ from homeassistant.const import HTTP_OK, TEMP_CELSIUS from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component +from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["fitbit==0.2.2"] @@ -248,68 +249,81 @@ def setup_platform(hass, config, add_devices, discovery_info=None): redirect_uri = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) - def _start_fitbit_auth(handler, path_match, data): - """Start Fitbit OAuth2 flow.""" - url, _ = oauth.authorize_token_url(redirect_uri=redirect_uri, - scope=["activity", "heartrate", - "nutrition", "profile", - "settings", "sleep", - "weight"]) - handler.send_response(301) - handler.send_header("Location", url) - handler.end_headers() - - def _finish_fitbit_auth(handler, path_match, data): - """Finish Fitbit OAuth2 flow.""" - response_message = """Fitbit has been successfully authorized! - You can close this window now!""" - from oauthlib.oauth2.rfc6749.errors import MismatchingStateError - from oauthlib.oauth2.rfc6749.errors import MissingTokenError - if data.get("code") is not None: - try: - oauth.fetch_access_token(data.get("code"), redirect_uri) - except MissingTokenError as error: - _LOGGER.error("Missing token: %s", error) - response_message = """Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format(error) - except MismatchingStateError as error: - _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = """Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format(error) - else: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ + fitbit_auth_start_url, _ = oauth.authorize_token_url( + redirect_uri=redirect_uri, + scope=["activity", "heartrate", "nutrition", "profile", + "settings", "sleep", "weight"]) - html_response = """<html><head><title>Fitbit Auth</title></head> - <body><h1>{}</h1></body></html>""".format(response_message) + hass.wsgi.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) + hass.wsgi.register_view(FitbitAuthCallbackView(hass, config, + add_devices, oauth)) - html_response = html_response.encode("utf-8") + request_oauth_completion(hass) - handler.send_response(HTTP_OK) - handler.write_content(html_response, content_type="text/html") - config_contents = { - "access_token": oauth.token["access_token"], - "refresh_token": oauth.token["refresh_token"], - "client_id": oauth.client_id, - "client_secret": oauth.client_secret - } - if not config_from_file(config_path, config_contents): - _LOGGER.error("failed to save config file") +class FitbitAuthCallbackView(HomeAssistantView): + """Handle OAuth finish callback requests.""" - setup_platform(hass, config, add_devices, discovery_info=None) + requires_auth = False + url = '/auth/fitbit/callback' + name = 'auth:fitbit:callback' - hass.http.register_path("GET", FITBIT_AUTH_START, _start_fitbit_auth, - require_auth=False) - hass.http.register_path("GET", FITBIT_AUTH_CALLBACK_PATH, - _finish_fitbit_auth, require_auth=False) + def __init__(self, hass, config, add_devices, oauth): + """Initialize the OAuth callback view.""" + super().__init__(hass) + self.config = config + self.add_devices = add_devices + self.oauth = oauth - request_oauth_completion(hass) + 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 + + response_message = """Fitbit has been successfully authorized! + You can close this window now!""" + + if data.get("code") is not None: + redirect_uri = "{}{}".format(self.hass.config.api.base_url, + FITBIT_AUTH_CALLBACK_PATH) + + try: + self.oauth.fetch_access_token(data.get("code"), redirect_uri) + except MissingTokenError as error: + _LOGGER.error("Missing token: %s", error) + response_message = """Something went wrong when + attempting authenticating with Fitbit. The error + encountered was {}. Please try again!""".format(error) + except MismatchingStateError as error: + _LOGGER.error("Mismatched state, CSRF error: %s", error) + response_message = """Something went wrong when + attempting authenticating with Fitbit. The error + encountered was {}. Please try again!""".format(error) + else: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + + html_response = """<html><head><title>Fitbit Auth</title></head> + <body><h1>{}</h1></body></html>""".format(response_message) + + config_contents = { + "access_token": self.oauth.token["access_token"], + "refresh_token": self.oauth.token["refresh_token"], + "client_id": self.oauth.client_id, + "client_secret": self.oauth.client_secret + } + if not config_from_file(self.hass.config.path(FITBIT_CONFIG_FILE), + config_contents): + _LOGGER.error("failed to save config file") + + setup_platform(self.hass, self.config, self.add_devices) + + return html_response # pylint: disable=too-few-public-methods diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index db8f030128e..55c6aef31d0 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -7,8 +7,8 @@ https://home-assistant.io/components/sensor.torque/ import re -from homeassistant.const import HTTP_OK from homeassistant.helpers.entity import Entity +from homeassistant.components.http import HomeAssistantView DOMAIN = 'torque' DEPENDENCIES = ['http'] @@ -43,12 +43,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): email = config.get('email', None) sensors = {} - def _receive_data(handler, path_match, data): - """Received data from Torque.""" - handler.send_response(HTTP_OK) - handler.end_headers() + hass.wsgi.register_view(TorqueReceiveDataView(hass, email, vehicle, + sensors, add_devices)) + return True + + +class TorqueReceiveDataView(HomeAssistantView): + """Handle data from Torque requests.""" + + url = API_PATH + name = 'api:torque' - if email is not None and email != data[SENSOR_EMAIL_FIELD]: + # pylint: disable=too-many-arguments + def __init__(self, hass, email, vehicle, sensors, add_devices): + """Initialize a Torque view.""" + super().__init__(hass) + self.email = email + self.vehicle = vehicle + self.sensors = sensors + self.add_devices = add_devices + + def get(self, request): + """Handle Torque data request.""" + data = request.args + + if self.email is not None and self.email != data[SENSOR_EMAIL_FIELD]: return names = {} @@ -66,18 +85,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): units[pid] = decode(data[key]) elif is_value: pid = convert_pid(is_value.group(1)) - if pid in sensors: - sensors[pid].on_update(data[key]) + if pid in self.sensors: + self.sensors[pid].on_update(data[key]) for pid in names: - if pid not in sensors: - sensors[pid] = TorqueSensor( - ENTITY_NAME_FORMAT.format(vehicle, names[pid]), + if pid not in self.sensors: + self.sensors[pid] = TorqueSensor( + ENTITY_NAME_FORMAT.format(self.vehicle, names[pid]), units.get(pid, None)) - add_devices([sensors[pid]]) + self.add_devices([self.sensors[pid]]) - hass.http.register_path('GET', API_PATH, _receive_data) - return True + return None class TorqueSensor(Entity): diff --git a/homeassistant/components/wsgi.py b/homeassistant/components/wsgi.py deleted file mode 100644 index 912c4d087a0..00000000000 --- a/homeassistant/components/wsgi.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -This module provides WSGI application to serve the Home Assistant API. - -""" -import json -import logging -import threading -import re - -import homeassistant.core as ha -import homeassistant.remote as rem -from homeassistant import util -from homeassistant.const import ( - SERVER_PORT, HTTP_OK, HTTP_NOT_FOUND, HTTP_BAD_REQUEST -) - -DOMAIN = "wsgi" -REQUIREMENTS = ("eventlet==0.18.4", "static3==0.6.1", "Werkzeug==0.11.5",) - -CONF_API_PASSWORD = "api_password" -CONF_SERVER_HOST = "server_host" -CONF_SERVER_PORT = "server_port" -CONF_DEVELOPMENT = "development" -CONF_SSL_CERTIFICATE = 'ssl_certificate' -CONF_SSL_KEY = 'ssl_key' - -DATA_API_PASSWORD = 'api_password' - -_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) - -_LOGGER = logging.getLogger(__name__) - - -def setup(hass, config): - """Set up the HTTP API and debug interface.""" - conf = config.get(DOMAIN, {}) - - server = HomeAssistantWSGI( - hass, - development=str(conf.get(CONF_DEVELOPMENT, "")) == "1", - server_host=conf.get(CONF_SERVER_HOST, '0.0.0.0'), - server_port=conf.get(CONF_SERVER_PORT, SERVER_PORT), - api_password=util.convert(conf.get(CONF_API_PASSWORD), str), - ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), - ssl_key=conf.get(CONF_SSL_KEY), - ) - - hass.bus.listen_once( - ha.EVENT_HOMEASSISTANT_START, - lambda event: - threading.Thread(target=server.start, daemon=True, - name='WSGI-server').start()) - - hass.wsgi = server - - return True - - -class StaticFileServer(object): - def __call__(self, environ, start_response): - from werkzeug.wsgi import DispatcherMiddleware - app = DispatcherMiddleware(self.base_app, self.extra_apps) - # Strip out any cachebusting MD% fingerprints - fingerprinted = _FINGERPRINT.match(environ['PATH_INFO']) - if fingerprinted: - environ['PATH_INFO'] = "{}.{}".format(*fingerprinted.groups()) - return app(environ, start_response) - - -class HomeAssistantWSGI(object): - def __init__(self, hass, development, api_password, ssl_certificate, - ssl_key, server_host, server_port): - from werkzeug.wrappers import BaseRequest, AcceptMixin - from werkzeug.routing import Map - - class Request(BaseRequest, AcceptMixin): - pass - - self.Request = Request - self.url_map = Map() - self.views = {} - self.hass = hass - self.extra_apps = {} - self.development = development - self.api_password = api_password - self.ssl_certificate = ssl_certificate - self.ssl_key = ssl_key - - def register_view(self, view): - """ Register a view with the WSGI server. - - The view argument must inherit from the HomeAssistantView class, and - it must have (globally unique) 'url' and 'name' attributes. - """ - from werkzeug.routing import Rule - - if view.name in self.views: - _LOGGER.warning("View '{}' is being overwritten".format(view.name)) - self.views[view.name] = view(self.hass) - # TODO Warn if we're overriding an existing 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) - - def register_static_path(self, url_root, path): - """Register a folder to serve as a static path.""" - from static import Cling - - # TODO Warn if we're overwriting an existing path - self.extra_apps[url_root] = Cling(path) - - def start(self): - """Start the wsgi server.""" - from eventlet import wsgi - import eventlet - - sock = eventlet.listen(('', 8090)) - if self.ssl_certificate: - eventlet.wrap_ssl(sock, certfile=self.ssl_certificate, - keyfile=self.ssl_key, server_side=True) - wsgi.server(sock, self) - - def dispatch_request(self, request): - """Handle incoming request.""" - from werkzeug.exceptions import ( - MethodNotAllowed, NotFound, BadRequest, Unauthorized - ) - adapter = self.url_map.bind_to_environ(request.environ) - try: - endpoint, values = adapter.match() - return self.views[endpoint].handle_request(request, **values) - except BadRequest as e: - return self.handle_error(request, str(e), HTTP_BAD_REQUEST) - except NotFound as e: - return self.handle_error(request, str(e), HTTP_NOT_FOUND) - except MethodNotAllowed as e: - return self.handle_error(request, str(e), 405) - except Unauthorized as e: - return self.handle_error(request, str(e), 401) - # TODO This long chain of except blocks is silly. _handle_error should - # just take the exception as an argument and parse the status code - # itself - - def base_app(self, environ, start_response): - request = self.Request(environ) - request.api_password = self.api_password - request.development = self.development - response = self.dispatch_request(request) - return response(environ, start_response) - - def __call__(self, environ, start_response): - from werkzeug.wsgi import DispatcherMiddleware - - 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) - - def _handle_error(self, request, message, status): - from werkzeug.wrappers import Response - if request.accept_mimetypes.accept_json: - message = json.dumps({ - "result": "error", - "message": message, - }) - mimetype = "application/json" - else: - mimetype = "text/plain" - return Response(message, status=status, mimetype=mimetype) - - -class HomeAssistantView(object): - extra_urls = [] - requires_auth = True # Views inheriting from this class can override this - - def __init__(self, hass): - from werkzeug.wrappers import Response - from werkzeug.exceptions import NotFound, BadRequest - - self.hass = hass - self.Response = Response - self.NotFound = NotFound - self.BadRequest = BadRequest - - def handle_request(self, request, **values): - """Handle request to url.""" - from werkzeug.exceptions import MethodNotAllowed - - try: - handler = getattr(self, request.method.lower()) - except AttributeError: - raise MethodNotAllowed - # TODO This would be a good place to check the auth if - # self.requires_auth is true, and raise Unauthorized on a failure - result = handler(request, **values) - if isinstance(result, self.Response): - # The method handler returned a ready-made Response, how nice of it - return result - elif (isinstance(result, dict) or - isinstance(result, list) or - isinstance(result, ha.State)): - # There are a few result types we know we always want to jsonify - if isinstance(result, dict) and 'status_code' in result: - status_code = result['status_code'] - del result['status_code'] - else: - status_code = HTTP_OK - msg = json.dumps( - result, - sort_keys=True, - cls=rem.JSONEncoder - ).encode('UTF-8') - return self.Response(msg, mimetype="application/json", - status_code=status_code) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 50a7b290cc8..aab1178d634 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -186,8 +186,8 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, def track_time_change(hass, action, year=None, month=None, day=None, hour=None, minute=None, second=None): """Add a listener that will fire if UTC time matches a pattern.""" - track_utc_time_change(hass, action, year, month, day, hour, minute, second, - local=True) + return track_utc_time_change(hass, action, year, month, day, hour, minute, + second, local=True) def _process_match_param(parameter): diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 74d9a958355..4bfb01890cf 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -21,7 +21,8 @@ import homeassistant.core as ha from homeassistant.const import ( HTTP_HEADER_HA_AUTH, SERVER_PORT, URL_API, URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_EVENTS_EVENT, URL_API_SERVICES, - URL_API_SERVICES_SERVICE, URL_API_STATES, URL_API_STATES_ENTITY) + URL_API_SERVICES_SERVICE, URL_API_STATES, URL_API_STATES_ENTITY, + HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON) from homeassistant.exceptions import HomeAssistantError METHOD_GET = "get" @@ -59,7 +60,9 @@ class API(object): else: self.base_url = "http://{}:{}".format(host, self.port) self.status = None - self._headers = {} + self._headers = { + HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON, + } if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password @@ -126,7 +129,7 @@ class HomeAssistant(ha.HomeAssistant): def start(self): """Start the instance.""" # Ensure a local API exists to connect with remote - if self.config.api is None: + if 'api' not in self.config.components: if not bootstrap.setup_component(self, 'api'): raise HomeAssistantError( 'Unable to setup local API to receive events') @@ -136,6 +139,10 @@ class HomeAssistant(ha.HomeAssistant): self.bus.fire(ha.EVENT_HOMEASSISTANT_START, origin=ha.EventOrigin.remote) + # Give eventlet time to startup + import eventlet + eventlet.sleep(0.1) + # Setup that events from remote_api get forwarded to local_api # Do this after we fire START, otherwise HTTP is not started if not connect_remote_events(self.remote_api, self.config.api): @@ -383,7 +390,7 @@ def fire_event(api, event_type, data=None): req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data) if req.status_code != 200: - _LOGGER.error("Error firing event: %d - %d", + _LOGGER.error("Error firing event: %d - %s", req.status_code, req.text) except HomeAssistantError: diff --git a/requirements_all.txt b/requirements_all.txt index 7c89f68364d..842fd4df466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,7 +23,7 @@ SoCo==0.11.1 # homeassistant.components.notify.twitter TwitterAPI==2.4.1 -# homeassistant.components.wsgi +# homeassistant.components.http Werkzeug==0.11.5 # homeassistant.components.apcupsd @@ -56,7 +56,7 @@ dweepy==0.2.0 # homeassistant.components.sensor.eliqonline eliqonline==1.0.12 -# homeassistant.components.wsgi +# homeassistant.components.http eventlet==0.18.4 # homeassistant.components.thermostat.honeywell @@ -337,7 +337,7 @@ somecomfort==0.2.1 # homeassistant.components.sensor.speedtest speedtest-cli==0.3.4 -# homeassistant.components.wsgi +# homeassistant.components.http static3==0.6.1 # homeassistant.components.sensor.steam_online diff --git a/tests/common.py b/tests/common.py index 169b099a12b..98c61dfc16e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -120,7 +120,7 @@ def mock_state_change_event(hass, new_state, old_state=None): def mock_http_component(hass): """Mock the HTTP component.""" - hass.http = MockHTTP() + hass.wsgi = mock.MagicMock() hass.config.components.append('http') @@ -135,14 +135,6 @@ def mock_mqtt_component(hass, mock_mqtt): return mock_mqtt -class MockHTTP(object): - """Mock the HTTP module.""" - - def register_path(self, method, url, callback, require_auth=True): - """Register a path.""" - pass - - class MockModule(object): """Representation of a fake module.""" diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py index 811e9df4314..7445b5daf8c 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -2,6 +2,7 @@ import unittest from unittest.mock import patch +import eventlet import requests from homeassistant import bootstrap, const @@ -45,6 +46,7 @@ def setUpModule(): # pylint: disable=invalid-name }) hass.start() + eventlet.sleep(0.05) def tearDownModule(): # pylint: disable=invalid-name diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index 03fa5c2d33c..e1eb257577c 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -3,6 +3,7 @@ import unittest import json +import eventlet import requests from homeassistant import bootstrap, const @@ -13,7 +14,10 @@ from tests.common import get_test_instance_port, get_test_home_assistant API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() API_URL = "http://127.0.0.1:{}{}".format(SERVER_PORT, alexa.API_ENDPOINT) -HA_HEADERS = {const.HTTP_HEADER_HA_AUTH: API_PASSWORD} +HA_HEADERS = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD, + const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, +} SESSION_ID = 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000' APPLICATION_ID = 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' @@ -83,6 +87,8 @@ def setUpModule(): # pylint: disable=invalid-name hass.start() + eventlet.sleep(0.1) + def tearDownModule(): # pylint: disable=invalid-name """Stop the Home Assistant server.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index fb571fe5811..e42ae7ce323 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -1,11 +1,12 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access,too-many-public-methods -from contextlib import closing +# from contextlib import closing import json import tempfile import unittest from unittest.mock import patch +import eventlet import requests from homeassistant import bootstrap, const @@ -17,7 +18,10 @@ from tests.common import get_test_instance_port, get_test_home_assistant API_PASSWORD = "test1234" SERVER_PORT = get_test_instance_port() HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT) -HA_HEADERS = {const.HTTP_HEADER_HA_AUTH: API_PASSWORD} +HA_HEADERS = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD, + const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, +} hass = None @@ -45,6 +49,10 @@ def setUpModule(): # pylint: disable=invalid-name hass.start() + # To start HTTP + # TODO fix this + eventlet.sleep(0.05) + def tearDownModule(): # pylint: disable=invalid-name """Stop the Home Assistant server.""" @@ -80,14 +88,14 @@ class TestAPI(unittest.TestCase): self.assertEqual(200, req.status_code) - def test_access_via_session(self): - """Test access wia session.""" - session = requests.Session() - req = session.get(_url(const.URL_API), headers=HA_HEADERS) - self.assertEqual(200, req.status_code) + # def test_access_via_session(self): + # """Test access wia session.""" + # session = requests.Session() + # req = session.get(_url(const.URL_API), headers=HA_HEADERS) + # self.assertEqual(200, req.status_code) - req = session.get(_url(const.URL_API)) - self.assertEqual(200, req.status_code) + # req = session.get(_url(const.URL_API)) + # self.assertEqual(200, req.status_code) def test_api_list_state_entities(self): """Test if the debug interface allows us to list state entities.""" @@ -220,7 +228,7 @@ class TestAPI(unittest.TestCase): hass.pool.block_till_done() - self.assertEqual(422, req.status_code) + self.assertEqual(400, req.status_code) self.assertEqual(0, len(test_value)) # Try now with valid but unusable JSON @@ -231,7 +239,7 @@ class TestAPI(unittest.TestCase): hass.pool.block_till_done() - self.assertEqual(422, req.status_code) + self.assertEqual(400, req.status_code) self.assertEqual(0, len(test_value)) def test_api_get_config(self): @@ -333,8 +341,7 @@ class TestAPI(unittest.TestCase): req = requests.post( _url(const.URL_API_TEMPLATE), - data=json.dumps({"template": - '{{ states.sensor.temperature.state }}'}), + json={"template": '{{ states.sensor.temperature.state }}'}, headers=HA_HEADERS) self.assertEqual('10', req.text) @@ -349,7 +356,7 @@ class TestAPI(unittest.TestCase): '{{ states.sensor.temperature.state'}), headers=HA_HEADERS) - self.assertEqual(422, req.status_code) + self.assertEqual(400, req.status_code) def test_api_event_forward(self): """Test setting up event forwarding.""" @@ -390,23 +397,25 @@ class TestAPI(unittest.TestCase): headers=HA_HEADERS) self.assertEqual(422, req.status_code) - # Setup a real one - req = requests.post( - _url(const.URL_API_EVENT_FORWARD), - data=json.dumps({ - 'api_password': API_PASSWORD, - 'host': '127.0.0.1', - 'port': SERVER_PORT - }), - headers=HA_HEADERS) - self.assertEqual(200, req.status_code) - - # Delete it again.. - req = requests.delete( - _url(const.URL_API_EVENT_FORWARD), - data=json.dumps({}), - headers=HA_HEADERS) - self.assertEqual(400, req.status_code) + # TODO disabled because eventlet cannot validate + # a connection to itself, need a second instance + # # Setup a real one + # req = requests.post( + # _url(const.URL_API_EVENT_FORWARD), + # data=json.dumps({ + # 'api_password': API_PASSWORD, + # 'host': '127.0.0.1', + # 'port': SERVER_PORT + # }), + # headers=HA_HEADERS) + # self.assertEqual(200, req.status_code) + + # # Delete it again.. + # req = requests.delete( + # _url(const.URL_API_EVENT_FORWARD), + # data=json.dumps({}), + # headers=HA_HEADERS) + # self.assertEqual(400, req.status_code) req = requests.delete( _url(const.URL_API_EVENT_FORWARD), @@ -426,63 +435,61 @@ class TestAPI(unittest.TestCase): headers=HA_HEADERS) self.assertEqual(200, req.status_code) - def test_stream(self): - """Test the stream.""" - listen_count = self._listen_count() - with closing(requests.get(_url(const.URL_API_STREAM), - stream=True, headers=HA_HEADERS)) as req: - - data = self._stream_next_event(req) - self.assertEqual('ping', data) + # def test_stream(self): + # """Test the stream.""" + # listen_count = self._listen_count() + # with closing(requests.get(_url(const.URL_API_STREAM), + # stream=True, headers=HA_HEADERS)) as req: - self.assertEqual(listen_count + 1, self._listen_count()) + # self.assertEqual(listen_count + 1, self._listen_count()) - hass.bus.fire('test_event') - hass.pool.block_till_done() + # hass.bus.fire('test_event') + # hass.pool.block_till_done() - data = self._stream_next_event(req) + # data = self._stream_next_event(req) - self.assertEqual('test_event', data['event_type']) + # self.assertEqual('test_event', data['event_type']) - def test_stream_with_restricted(self): - """Test the stream with restrictions.""" - listen_count = self._listen_count() - with closing(requests.get(_url(const.URL_API_STREAM), - data=json.dumps({ - 'restrict': 'test_event1,test_event3'}), - stream=True, headers=HA_HEADERS)) as req: + # def test_stream_with_restricted(self): + # """Test the stream with restrictions.""" + # listen_count = self._listen_count() + # with closing(requests.get(_url(const.URL_API_STREAM), + # data=json.dumps({ + # 'restrict': + # 'test_event1,test_event3'}), + # stream=True, headers=HA_HEADERS)) as req: - data = self._stream_next_event(req) - self.assertEqual('ping', data) + # data = self._stream_next_event(req) + # self.assertEqual('ping', data) - self.assertEqual(listen_count + 2, self._listen_count()) + # self.assertEqual(listen_count + 2, self._listen_count()) - hass.bus.fire('test_event1') - hass.pool.block_till_done() - hass.bus.fire('test_event2') - hass.pool.block_till_done() - hass.bus.fire('test_event3') - hass.pool.block_till_done() + # hass.bus.fire('test_event1') + # hass.pool.block_till_done() + # hass.bus.fire('test_event2') + # hass.pool.block_till_done() + # hass.bus.fire('test_event3') + # hass.pool.block_till_done() - data = self._stream_next_event(req) - self.assertEqual('test_event1', data['event_type']) - data = self._stream_next_event(req) - self.assertEqual('test_event3', data['event_type']) + # data = self._stream_next_event(req) + # self.assertEqual('test_event1', data['event_type']) + # data = self._stream_next_event(req) + # self.assertEqual('test_event3', data['event_type']) - def _stream_next_event(self, stream): - """Test the stream for next event.""" - data = b'' - last_new_line = False - for dat in stream.iter_content(1): - if dat == b'\n' and last_new_line: - break - data += dat - last_new_line = dat == b'\n' + # def _stream_next_event(self, stream): + # """Test the stream for next event.""" + # data = b'' + # last_new_line = False + # for dat in stream.iter_content(1): + # if dat == b'\n' and last_new_line: + # break + # data += dat + # last_new_line = dat == b'\n' - conv = data.decode('utf-8').strip()[6:] + # conv = data.decode('utf-8').strip()[6:] - return conv if conv == 'ping' else json.loads(conv) + # return conv if conv == 'ping' else json.loads(conv) - def _listen_count(self): - """Return number of event listeners.""" - return sum(hass.bus.listeners.values()) + # def _listen_count(self): + # """Return number of event listeners.""" + # return sum(hass.bus.listeners.values()) diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 24ee426645e..54ca023c88e 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -3,6 +3,7 @@ import re import unittest +import eventlet import requests import homeassistant.bootstrap as bootstrap @@ -42,6 +43,10 @@ def setUpModule(): # pylint: disable=invalid-name hass.start() + # Give eventlet time to start + # TODO fix this + eventlet.sleep(0.05) + def tearDownModule(): # pylint: disable=invalid-name """Stop everything that was started.""" diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 625d73858d1..2f7fd705d20 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -12,7 +12,7 @@ from homeassistant.components import logbook from tests.common import mock_http_component, get_test_home_assistant -class TestComponentHistory(unittest.TestCase): +class TestComponentLogbook(unittest.TestCase): """Test the History component.""" def setUp(self): diff --git a/tests/test_remote.py b/tests/test_remote.py index 45224b09c90..58b2f9b359d 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -2,6 +2,8 @@ # pylint: disable=protected-access,too-many-public-methods import unittest +import eventlet + import homeassistant.core as ha import homeassistant.bootstrap as bootstrap import homeassistant.remote as remote @@ -46,6 +48,10 @@ def setUpModule(): # pylint: disable=invalid-name hass.start() + # Give eventlet time to start + # TODO fix this + eventlet.sleep(0.05) + master_api = remote.API("127.0.0.1", API_PASSWORD, MASTER_PORT) # Start slave @@ -57,6 +63,10 @@ def setUpModule(): # pylint: disable=invalid-name slave.start() + # Give eventlet time to start + # TODO fix this + eventlet.sleep(0.05) + def tearDownModule(): # pylint: disable=invalid-name """Stop the Home Assistant server and slave.""" @@ -232,6 +242,7 @@ class TestRemoteClasses(unittest.TestCase): slave.pool.block_till_done() # Wait till master gives updated state hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertEqual("remote.statemachine test", slave.states.get("remote.test").state) @@ -240,11 +251,13 @@ class TestRemoteClasses(unittest.TestCase): """Remove statemachine from master.""" hass.states.set("remote.master_remove", "remove me!") hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertIn('remote.master_remove', slave.states.entity_ids()) hass.states.remove("remote.master_remove") hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertNotIn('remote.master_remove', slave.states.entity_ids()) @@ -252,12 +265,14 @@ class TestRemoteClasses(unittest.TestCase): """Remove statemachine from slave.""" hass.states.set("remote.slave_remove", "remove me!") hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertIn('remote.slave_remove', slave.states.entity_ids()) self.assertTrue(slave.states.remove("remote.slave_remove")) slave.pool.block_till_done() hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertNotIn('remote.slave_remove', slave.states.entity_ids()) @@ -276,5 +291,6 @@ class TestRemoteClasses(unittest.TestCase): slave.pool.block_till_done() # Wait till master gives updated event hass.pool.block_till_done() + eventlet.sleep(0.01) self.assertEqual(1, len(test_value)) -- GitLab