diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 3b2972a702c410ac7235b15c2d57f46c52319e5b..fbfcce7d50d06d637c802805ee1fec4128656a62 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,6 +9,8 @@ import logging import re import threading +from werkzeug.exceptions import NotFound, BadRequest + import homeassistant.core as ha import homeassistant.remote as rem from homeassistant.bootstrap import ERROR_LOG_FILENAME @@ -23,9 +25,10 @@ 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 DOMAIN = 'api' -DEPENDENCIES = ['http'] +DEPENDENCIES = ['http', 'wsgi'] STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds @@ -99,14 +102,38 @@ def setup(hass, config): hass.http.register_path('POST', URL_API_TEMPLATE, _handle_post_api_template) + hass.wsgi.register_view(APIStatusView) + hass.wsgi.register_view(APIConfigView) + hass.wsgi.register_view(APIDiscoveryView) + hass.wsgi.register_view(APIEntityStateView) + hass.wsgi.register_view(APIStatesView) + hass.wsgi.register_view(APIEventListenersView) + hass.wsgi.register_view(APIServicesView) + hass.wsgi.register_view(APIDomainServicesView) + return True +class APIStatusView(HomeAssistantView): + 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.") +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 @@ -177,11 +204,28 @@ def _handle_get_api_stream(handler, path_match, data): hass.bus.remove_listener(MATCH_ALL, forward_events) +class APIConfigView(HomeAssistantView): + 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()) +class APIDiscoveryView(HomeAssistantView): + 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 = { @@ -193,11 +237,69 @@ def _handle_get_api_discovery_info(handler, path_match, data): handler.write_json(params) +class APIStatesView(HomeAssistantView): + 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()) +class APIEntityStateView(HomeAssistantView): + url = "/api/states/<entity_id>" + name = "api:entity-state" + + def get(self, request, entity_id): + state = self.hass.states.get(entity_id) + if state: + return state + else: + raise NotFound("State does not exist.") + + def post(self, request, entity_id): + try: + new_state = request.values['state'] + except KeyError: + raise BadRequest("state not specified") + + attributes = request.values.get('attributes') + + is_new_state = self.hass.states.get(entity_id) is None + + # Write state + 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") + + if is_new_state: + resp.status_code = HTTP_CREATED + + resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id)) + + return resp + + def delete(self, request, entity_id): + if self.hass.states.remove(entity_id): + return {"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') @@ -257,11 +359,40 @@ def _handle_delete_state_entity(handler, path_match, data): "Entity removed", HTTP_OK) +class APIEventListenersView(HomeAssistantView): + 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)) +class APIEventView(HomeAssistantView): + url = '/api/events/<event_type>' + name = "api:event" + + def post(self, request, event_type): + event_data = request.values + + # 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 + + 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. @@ -292,11 +423,30 @@ def _handle_api_post_events_event(handler, path_match, event_data): handler.write_json_message("Event {} fired.".format(event_type)) +class APIServicesView(HomeAssistantView): + 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)) +class APIDomainServicesView(HomeAssistantView): + 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 + + # pylint: disable=invalid-name def _handle_post_api_services_domain_service(handler, path_match, data): """Handle calling a service. @@ -312,6 +462,68 @@ def _handle_post_api_services_domain_service(handler, path_match, data): handler.write_json(changed_states) +class APIEventForwardingView(HomeAssistantView): + url = URL_API_EVENT_FORWARD + name = "api:event-forward" + + def post(self, request): + try: + host = request.values['host'] + api_password = request.values['api_password'] + except KeyError: + return { + "message": "No host or api_password received.", + "status_code": 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, + } + + api = rem.API(host, api_password, port) + + if not api.validate_api(): + return { + "message": "Unable to validate API.", + "status_code": HTTP_UNPROCESSABLE_ENTITY, + } + + if self.hass.event_forwarder is None: + self.hass.event_forwarder = rem.EventForwarder(self.hass) + + self.hass.event_forwarder.connect(api) + + return {"message": "Event forwarding setup."} + + def delete(self, request): + try: + host = request.values['host'] + except KeyError: + return { + "message": "No host received.", + "status_code": 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, + } + + if self.hass.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.""" @@ -369,17 +581,43 @@ def _handle_delete_api_event_forward(handler, path_match, data): handler.write_json_message("Event forwarding cancelled.") +class APIComponentsView(HomeAssistantView): + 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) +class APIErrorLogView(HomeAssistantView): + 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) +class APILogOutView(HomeAssistantView): + url = URL_API_LOG_OUT + name = "api:log-out" + + def post(self, request): + # TODO + return {} + + def _handle_post_api_log_out(handler, path_match, data): """Log user out.""" handler.send_response(HTTP_OK) @@ -387,6 +625,15 @@ def _handle_post_api_log_out(handler, path_match, data): handler.end_headers() +class APITemplateView(HomeAssistantView): + 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', '') diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c1025fd16578a193c4c37075d560f6746855514e..f591fdd14be2b9392403030bdd7801254640b7f1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -3,16 +3,26 @@ import re import os import logging +from jinja2 import FileSystemLoader, Environment +from werkzeug.wrappers import Response + from . import version, mdi_version import homeassistant.util as util from homeassistant.const import URL_ROOT, HTTP_OK from homeassistant.components import api +from homeassistant.components.wsgi import HomeAssistantView DOMAIN = 'frontend' DEPENDENCIES = ['api'] INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template') +TEMPLATES = Environment( + loader=FileSystemLoader( + os.path.join(os.path.dirname(__file__), 'templates/') + ) +) + _LOGGER = logging.getLogger(__name__) FRONTEND_URLS = [ @@ -49,9 +59,61 @@ def setup(hass, config): '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) + + www_static_path = os.path.join(os.path.dirname(__file__), 'www_static') + if hass.wsgi.development: + sw_path = "home-assistant-polymer/build/service_worker.js" + else: + sw_path = "service_worker.js" + + hass.wsgi.register_static_path( + "/service_worker.js", + os.path.join(www_static_path, sw_path) + ) + hass.wsgi.register_static_path("/static", www_static_path) + hass.wsgi.register_static_path("/local", hass.config.path('www')) + return True +class BootstrapView(HomeAssistantView): + url = URL_API_BOOTSTRAP + name = "api:bootstrap" + + def get(self, request): + """Return all data needed to bootstrap Home Assistant.""" + + return { + '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): + url = URL_ROOT + name = "frontend:index" + extra_urls = ['/logbook', '/history', '/map', '/devService', '/devState', + '/devEvent', '/devInfo', '/devTemplate', '/states/<entity>'] + + def get(self, request): + 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 + else request.values.get('api_password', '')) + + template = TEMPLATES.get_template('index.html') + + resp = template.render(app_url=app_url, auth=auth, + icons=mdi_version.VERSION) + + return 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 diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e21d00e86bc53b12629a9f45c32354e57b2fb33f --- /dev/null +++ b/homeassistant/components/frontend/templates/index.html @@ -0,0 +1,51 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Home Assistant</title> + + <link rel='manifest' href='/static/manifest.json'> + <link rel='icon' href='/static/favicon.ico'> + <link rel='apple-touch-icon' sizes='180x180' + href='/static/favicon-apple-180x180.png'> + <meta name='apple-mobile-web-app-capable' content='yes'> + <meta name='mobile-web-app-capable' content='yes'> + <meta name='viewport' content='width=device-width, user-scalable=no'> + <meta name='theme-color' content='#03a9f4'> + <style> + #ha-init-skeleton { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + -webkit-justify-content: center; + -webkit-align-items: center; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin-bottom: 123px; + } + </style> + <link rel='import' href='/static/{{ app_url }}' async> + </head> + <body fullbleed> + <div id='ha-init-skeleton'><img src='/static/favicon-192x192.png' height='192'></div> + <script> + var webComponentsSupported = ('registerElement' in document && + 'import' in document.createElement('link') && + 'content' in document.createElement('template')) + if (!webComponentsSupported) { + var script = document.createElement('script') + script.async = true + script.src = '/static/webcomponents-lite.min.js' + document.head.appendChild(script) + } + </script> + <home-assistant auth='{{ auth }}' icons='{{ icons }}'></home-assistant> + </body> +</html> diff --git a/homeassistant/components/wsgi.py b/homeassistant/components/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..f2b1e319887d9d190c8c8bc984ca880df627b416 --- /dev/null +++ b/homeassistant/components/wsgi.py @@ -0,0 +1,201 @@ +""" +This module provides WSGI application to serve the Home Assistant API. + +""" +import json +import logging +import threading +import re + +from eventlet import wsgi +import eventlet + +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 +) + +from static import Cling + +from werkzeug.wsgi import DispatcherMiddleware +from werkzeug.wrappers import Response, BaseRequest, AcceptMixin +from werkzeug.routing import Map, Rule +from werkzeug.exceptions import ( + MethodNotAllowed, NotFound, BadRequest, Unauthorized +) + + +class Request(BaseRequest, AcceptMixin): + pass + + +class StaticFileServer(object): + def __call__(self, environ, start_response): + 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) + +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 HomeAssistantWSGI(object): + def __init__(self, hass, development, api_password, ssl_certificate, + ssl_key, server_host, server_port): + 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. + """ + 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): + # TODO Warn if we're overwriting an existing path + self.extra_apps[url_root] = Cling(path) + + def start(self): + 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): + 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 = 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): + 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): + 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): + self.hass = hass + + def handle_request(self, request, **values): + 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, 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 Response(msg, mimetype="application/json", + status_code=status_code) diff --git a/requirements_all.txt b/requirements_all.txt index 1c441790028e5d92d6494a96048cf89866c3c846..7c89f68364df6367fe5ebafcc1411be3552cb673 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,6 +23,9 @@ SoCo==0.11.1 # homeassistant.components.notify.twitter TwitterAPI==2.4.1 +# homeassistant.components.wsgi +Werkzeug==0.11.5 + # homeassistant.components.apcupsd apcaccess==0.0.4 @@ -53,6 +56,9 @@ dweepy==0.2.0 # homeassistant.components.sensor.eliqonline eliqonline==1.0.12 +# homeassistant.components.wsgi +eventlet==0.18.4 + # homeassistant.components.thermostat.honeywell evohomeclient==0.2.5 @@ -331,6 +337,9 @@ somecomfort==0.2.1 # homeassistant.components.sensor.speedtest speedtest-cli==0.3.4 +# homeassistant.components.wsgi +static3==0.6.1 + # homeassistant.components.sensor.steam_online steamodd==4.21