WIP: Add WSGI stack

This is a fair chunk of the way towards adding a WSGI compatible stack
for Home Assistant. The majot missing piece is auth/sessions. I was
undecided on implementing the current auth mechanism, or adding a new
mechanism (likely based on Werkzeug's signed cookies).

Plenty of TODOs...
pull/2063/head
Josh Wright 2016-05-09 21:09:38 -04:00 committed by Paulus Schoutsen
parent 9116eb166b
commit d0320a9099
5 changed files with 571 additions and 1 deletions

View File

@ -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', '')

View File

@ -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

View File

@ -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>

View File

@ -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)

View File

@ -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