esphome/esphomeyaml/dashboard/dashboard.py

579 lines
18 KiB
Python
Raw Normal View History

2018-06-01 23:01:31 +02:00
# pylint: disable=wrong-import-position
from __future__ import print_function
2018-11-19 22:12:24 +01:00
import collections
2018-06-07 20:47:06 +02:00
import hmac
import json
import logging
2018-11-19 22:12:24 +01:00
import multiprocessing
import os
import random
import subprocess
2018-11-19 22:12:24 +01:00
import threading
2018-11-23 18:57:13 +01:00
import tornado
import tornado.concurrent
import tornado.gen
import tornado.ioloop
import tornado.iostream
2018-11-19 22:12:24 +01:00
from tornado.log import access_log
2018-11-23 18:57:13 +01:00
import tornado.process
import tornado.web
import tornado.websocket
2018-11-19 22:12:24 +01:00
from esphomeyaml import const
from esphomeyaml.__main__ import get_serial_ports
2018-11-28 21:33:24 +01:00
from esphomeyaml.helpers import mkdir_p, run_system_command
from esphomeyaml.storage_json import EsphomeyamlStorageJSON, StorageJSON, \
esphomeyaml_storage_path, ext_storage_path
from esphomeyaml.util import shlex_quote
2018-06-01 22:49:04 +02:00
2018-11-23 18:57:13 +01:00
# pylint: disable=unused-import, wrong-import-order
from typing import Optional # noqa
2018-06-02 22:22:20 +02:00
_LOGGER = logging.getLogger(__name__)
CONFIG_DIR = ''
2018-11-23 20:52:09 +01:00
PASSWORD_DIGEST = ''
COOKIE_SECRET = None
USING_PASSWORD = False
ON_HASSIO = False
USING_HASSIO_AUTH = True
2018-11-23 23:05:20 +01:00
HASSIO_MQTT_CONFIG = None
2018-06-07 20:47:06 +02:00
# pylint: disable=abstract-method
class BaseHandler(tornado.web.RequestHandler):
def is_authenticated(self):
2018-11-23 21:41:21 +01:00
if USING_HASSIO_AUTH or USING_PASSWORD:
return self.get_secure_cookie('authenticated') == 'yes'
2018-11-23 20:52:09 +01:00
2018-11-23 21:41:21 +01:00
return True
2018-06-01 22:49:04 +02:00
# pylint: disable=abstract-method, arguments-differ
class EsphomeyamlCommandWebSocket(tornado.websocket.WebSocketHandler):
def __init__(self, application, request, **kwargs):
super(EsphomeyamlCommandWebSocket, self).__init__(application, request, **kwargs)
self.proc = None
self.closed = False
def on_message(self, message):
2018-11-23 21:14:42 +01:00
if USING_HASSIO_AUTH or USING_PASSWORD:
2018-11-23 21:41:21 +01:00
if self.get_secure_cookie('authenticated') != 'yes':
2018-11-23 21:14:42 +01:00
return
if self.proc is not None:
return
command = self.build_command(message)
2018-11-19 22:12:24 +01:00
_LOGGER.info(u"Running command '%s'", ' '.join(shlex_quote(x) for x in command))
self.proc = tornado.process.Subprocess(command,
stdout=tornado.process.Subprocess.STREAM,
stderr=subprocess.STDOUT)
self.proc.set_exit_callback(self.proc_on_exit)
tornado.ioloop.IOLoop.current().spawn_callback(self.redirect_stream)
@tornado.gen.coroutine
def redirect_stream(self):
while True:
try:
data = yield self.proc.stdout.read_until_regex('[\n\r]')
except tornado.iostream.StreamClosedError:
break
self.write_message({'event': 'line', 'data': data})
def proc_on_exit(self, returncode):
if not self.closed:
_LOGGER.debug("Process exited with return code %s", returncode)
self.write_message({'event': 'exit', 'code': returncode})
def on_close(self):
self.closed = True
if self.proc is not None and self.proc.returncode is None:
_LOGGER.debug("Terminating process")
self.proc.proc.terminate()
def build_command(self, message):
raise NotImplementedError
class EsphomeyamlLogsHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = CONFIG_DIR + '/' + js['configuration']
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "logs", '--serial-port', js["port"]]
class EsphomeyamlRunHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "run", '--upload-port', js["port"]]
class EsphomeyamlCompileHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "compile"]
2018-06-03 12:16:43 +02:00
class EsphomeyamlValidateHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "config"]
2018-06-03 12:16:43 +02:00
class EsphomeyamlCleanMqttHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "clean-mqtt"]
class EsphomeyamlCleanHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "clean"]
class EsphomeyamlHassConfigHandler(EsphomeyamlCommandWebSocket):
def build_command(self, message):
js = json.loads(message)
config_file = os.path.join(CONFIG_DIR, js['configuration'])
2018-11-30 13:46:15 +01:00
return ["esphomeyaml", config_file, "hass-config"]
2018-06-07 20:47:06 +02:00
class SerialPortRequestHandler(BaseHandler):
def get(self):
2018-06-07 20:47:06 +02:00
if not self.is_authenticated():
self.redirect('/login')
return
ports = get_serial_ports()
data = []
for port, desc in ports:
if port == '/dev/ttyAMA0':
2018-06-03 11:18:53 +02:00
desc = 'UART pins on GPIO header'
split_desc = desc.split(' - ')
if len(split_desc) == 2 and split_desc[0] == split_desc[1]:
# Some serial ports repeat their values
desc = split_desc[0]
data.append({'port': port, 'desc': desc})
2018-06-03 11:18:53 +02:00
data.append({'port': 'OTA', 'desc': 'Over-The-Air'})
self.write(json.dumps(sorted(data, reverse=True)))
2018-06-07 20:47:06 +02:00
class WizardRequestHandler(BaseHandler):
def post(self):
from esphomeyaml import wizard
2018-06-07 20:47:06 +02:00
if not self.is_authenticated():
self.redirect('/login')
return
kwargs = {k: ''.join(v) for k, v in self.request.arguments.iteritems()}
destination = os.path.join(CONFIG_DIR, kwargs['name'] + '.yaml')
2018-11-19 22:12:24 +01:00
wizard.wizard_write(path=destination, **kwargs)
2018-06-03 11:18:53 +02:00
self.redirect('/?begin=True')
2018-06-07 20:47:06 +02:00
class DownloadBinaryRequestHandler(BaseHandler):
def get(self):
2018-06-07 20:47:06 +02:00
if not self.is_authenticated():
self.redirect('/login')
return
configuration = self.get_argument('configuration')
2018-11-19 22:12:24 +01:00
storage_path = ext_storage_path(CONFIG_DIR, configuration)
storage_json = StorageJSON.load(storage_path)
if storage_json is None:
self.send_error()
return
path = storage_json.firmware_bin_path
self.set_header('Content-Type', 'application/octet-stream')
2018-11-19 22:12:24 +01:00
filename = '{}.bin'.format(storage_json.name)
self.set_header("Content-Disposition", 'attachment; filename="{}"'.format(filename))
with open(path, 'rb') as f:
while 1:
data = f.read(16384) # or some other nice-sized chunk
if not data:
break
self.write(data)
self.finish()
2018-11-19 22:12:24 +01:00
def _list_yaml_files():
files = []
for file in os.listdir(CONFIG_DIR):
if not file.endswith('.yaml'):
continue
if file.startswith('.'):
continue
if file == 'secrets.yaml':
continue
files.append(file)
files.sort()
return files
def _list_dashboard_entries():
files = _list_yaml_files()
return [DashboardEntry(file) for file in files]
class DashboardEntry(object):
def __init__(self, filename):
self.filename = filename
self._storage = None
self._loaded_storage = False
@property
def full_path(self): # type: () -> str
return os.path.join(CONFIG_DIR, self.filename)
@property
def storage(self): # type: () -> Optional[StorageJSON]
if not self._loaded_storage:
self._storage = StorageJSON.load(ext_storage_path(CONFIG_DIR, self.filename))
self._loaded_storage = True
return self._storage
@property
def address(self):
if self.storage is None:
return None
return self.storage.address
@property
def name(self):
if self.storage is None:
return self.filename[:-len('.yaml')]
return self.storage.name
@property
def esp_platform(self):
if self.storage is None:
return None
return self.storage.esp_platform
@property
def board(self):
if self.storage is None:
return None
return self.storage.board
2018-11-27 16:54:11 +01:00
@property
def update_available(self):
if self.storage is None:
return True
2018-11-28 21:33:24 +01:00
return self.update_old != self.update_new
@property
def update_old(self):
if self.storage is None:
return ''
return self.storage.esphomeyaml_version or ''
@property
def update_new(self):
return const.__version__
2018-11-27 16:54:11 +01:00
2018-11-19 22:12:24 +01:00
2018-06-07 20:47:06 +02:00
class MainRequestHandler(BaseHandler):
def get(self):
2018-06-07 20:47:06 +02:00
if not self.is_authenticated():
self.redirect('/login')
return
2018-06-03 11:18:53 +02:00
begin = bool(self.get_argument('begin', False))
2018-11-19 22:12:24 +01:00
entries = _list_dashboard_entries()
version = const.__version__
docs_link = 'https://beta.esphomelib.com/esphomeyaml/' if 'b' in version else \
'https://esphomelib.com/esphomeyaml/'
2018-11-23 18:57:13 +01:00
mqtt_config = get_mqtt_config_lazy()
2018-11-19 22:12:24 +01:00
self.render("templates/index.html", entries=entries,
2018-11-23 18:57:13 +01:00
version=version, begin=begin, docs_link=docs_link, mqtt_config=mqtt_config)
2018-11-19 22:12:24 +01:00
def _ping_func(filename, address):
if os.name == 'nt':
command = ['ping', '-n', '1', address]
else:
command = ['ping', '-c', '1', address]
rc, _, _ = run_system_command(*command)
return filename, rc == 0
class PingThread(threading.Thread):
def run(self):
pool = multiprocessing.Pool(processes=8)
while not STOP_EVENT.is_set():
# Only do pings if somebody has the dashboard open
PING_REQUEST.wait()
PING_REQUEST.clear()
def callback(ret):
2018-11-24 14:23:42 +01:00
PING_RESULT[ret[0]] = ret[1]
2018-11-19 22:12:24 +01:00
entries = _list_dashboard_entries()
queue = collections.deque()
for entry in entries:
if entry.address is None:
PING_RESULT[entry.filename] = None
continue
result = pool.apply_async(_ping_func, (entry.filename, entry.address),
callback=callback)
queue.append(result)
while queue:
item = queue[0]
if item.ready():
queue.popleft()
continue
try:
item.get(0.1)
2018-11-23 19:11:48 +01:00
except OSError:
# ping not installed
pass
2018-11-19 22:12:24 +01:00
except multiprocessing.TimeoutError:
pass
if STOP_EVENT.is_set():
pool.terminate()
return
class PingRequestHandler(BaseHandler):
def get(self):
if not self.is_authenticated():
self.redirect('/login')
return
PING_REQUEST.set()
self.write(json.dumps(PING_RESULT))
def is_allowed(configuration):
return os.path.sep not in configuration
class EditRequestHandler(BaseHandler):
def get(self):
if not self.is_authenticated():
self.redirect('/login')
return
configuration = self.get_argument('configuration')
if not is_allowed(configuration):
self.set_status(401)
return
with open(os.path.join(CONFIG_DIR, configuration), 'r') as f:
content = f.read()
self.write(content)
def post(self):
if not self.is_authenticated():
self.redirect('/login')
return
configuration = self.get_argument('configuration')
if not is_allowed(configuration):
self.set_status(401)
return
with open(os.path.join(CONFIG_DIR, configuration), 'w') as f:
f.write(self.request.body)
self.set_status(200)
return
2018-11-19 22:12:24 +01:00
PING_RESULT = {} # type: dict
STOP_EVENT = threading.Event()
PING_REQUEST = threading.Event()
2018-06-07 20:47:06 +02:00
class LoginHandler(BaseHandler):
def get(self):
2018-11-23 20:52:09 +01:00
if USING_HASSIO_AUTH:
self.render_hassio_login()
return
2018-06-07 20:47:06 +02:00
self.write('<html><body><form action="/login" method="post">'
'Password: <input type="password" name="password">'
'<input type="submit" value="Sign in">'
'</form></body></html>')
2018-11-23 20:52:09 +01:00
def render_hassio_login(self, error=None):
version = const.__version__
docs_link = 'https://beta.esphomelib.com/esphomeyaml/' if 'b' in version else \
'https://esphomelib.com/esphomeyaml/'
self.render("templates/login.html", version=version, docs_link=docs_link, error=error)
def post_hassio_login(self):
import requests
headers = {
'X-HASSIO-KEY': os.getenv('HASSIO_TOKEN'),
}
data = {
'username': str(self.get_argument('username', '')),
'password': str(self.get_argument('password', ''))
}
try:
req = requests.post('http://hassio/auth', headers=headers, data=data)
if req.status_code == 200:
self.set_secure_cookie("authenticated", "yes")
self.redirect('/')
return
except Exception as err: # pylint: disable=broad-except
2018-11-26 16:50:19 +01:00
_LOGGER.warn("Error during Hass.io auth request: %s", err)
2018-11-23 20:52:09 +01:00
self.set_status(500)
self.render_hassio_login(error="Internal server error")
return
self.set_status(401)
self.render_hassio_login(error="Invalid username or password")
2018-06-07 20:47:06 +02:00
def post(self):
2018-11-23 20:52:09 +01:00
if USING_HASSIO_AUTH:
self.post_hassio_login()
return
2018-06-07 20:47:06 +02:00
password = str(self.get_argument("password", ''))
password = hmac.new(password).digest()
2018-11-23 20:52:09 +01:00
if hmac.compare_digest(PASSWORD_DIGEST, password):
2018-06-07 20:47:06 +02:00
self.set_secure_cookie("authenticated", "yes")
self.redirect("/")
2018-06-03 11:18:53 +02:00
def make_app(debug=False):
2018-11-19 22:12:24 +01:00
def log_function(handler):
if handler.get_status() < 400:
log_method = access_log.info
if isinstance(handler, SerialPortRequestHandler) and not debug:
return
if isinstance(handler, PingRequestHandler) and not debug:
return
elif handler.get_status() < 500:
log_method = access_log.warning
else:
log_method = access_log.error
request_time = 1000.0 * handler.request.request_time()
2018-11-23 18:57:13 +01:00
# pylint: disable=protected-access
2018-11-19 22:12:24 +01:00
log_method("%d %s %.2fms", handler.get_status(),
handler._request_summary(), request_time)
2018-11-28 21:33:24 +01:00
class StaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
if debug:
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
static_path = os.path.join(os.path.dirname(__file__), 'static')
2018-11-19 22:12:24 +01:00
app = tornado.web.Application([
(r"/", MainRequestHandler),
2018-06-07 20:47:06 +02:00
(r"/login", LoginHandler),
(r"/logs", EsphomeyamlLogsHandler),
(r"/run", EsphomeyamlRunHandler),
(r"/compile", EsphomeyamlCompileHandler),
2018-06-03 12:16:43 +02:00
(r"/validate", EsphomeyamlValidateHandler),
(r"/clean-mqtt", EsphomeyamlCleanMqttHandler),
(r"/clean", EsphomeyamlCleanHandler),
(r"/hass-config", EsphomeyamlHassConfigHandler),
(r"/edit", EditRequestHandler),
(r"/download.bin", DownloadBinaryRequestHandler),
(r"/serial-ports", SerialPortRequestHandler),
2018-11-19 22:12:24 +01:00
(r"/ping", PingRequestHandler),
(r"/wizard.html", WizardRequestHandler),
2018-11-28 21:33:24 +01:00
(r'/static/(.*)', StaticFileHandler, {'path': static_path}),
2018-11-23 20:52:09 +01:00
], debug=debug, cookie_secret=COOKIE_SECRET, log_function=log_function)
2018-11-19 22:12:24 +01:00
return app
2018-11-23 20:52:09 +01:00
def _get_mqtt_config_impl():
import requests
2018-11-23 18:57:13 +01:00
2018-11-23 20:52:09 +01:00
headers = {
'X-HASSIO-KEY': os.getenv('HASSIO_TOKEN'),
}
2018-11-23 18:57:13 +01:00
2018-11-23 23:55:47 +01:00
mqtt_config = requests.get('http://hassio/services/mqtt', headers=headers).json()['data']
info = requests.get('http://hassio/info', headers=headers).json()['data']
2018-11-23 18:57:13 +01:00
return {
2018-11-23 23:05:20 +01:00
'ssl': mqtt_config['ssl'],
2018-11-23 23:55:47 +01:00
'host': info['hostname'] + '.local:' + str(mqtt_config['port']),
2018-11-23 18:57:13 +01:00
'username': mqtt_config.get('username', ''),
'password': mqtt_config.get('password', '')
}
def get_mqtt_config_lazy():
global HASSIO_MQTT_CONFIG
2018-11-23 23:05:20 +01:00
if not ON_HASSIO:
2018-11-23 18:57:13 +01:00
return None
2018-11-23 23:05:20 +01:00
if HASSIO_MQTT_CONFIG is None:
2018-11-23 18:57:13 +01:00
try:
HASSIO_MQTT_CONFIG = _get_mqtt_config_impl()
except Exception: # pylint: disable=broad-except
2018-11-23 23:05:20 +01:00
pass
2018-11-23 18:57:13 +01:00
return HASSIO_MQTT_CONFIG
def start_web_server(args):
global CONFIG_DIR
2018-11-23 20:52:09 +01:00
global PASSWORD_DIGEST
global USING_PASSWORD
global ON_HASSIO
global USING_HASSIO_AUTH
global COOKIE_SECRET
2018-06-02 22:22:20 +02:00
CONFIG_DIR = args.configuration
2018-11-23 23:05:20 +01:00
mkdir_p(CONFIG_DIR)
mkdir_p(os.path.join(CONFIG_DIR, ".esphomeyaml"))
2018-11-23 21:14:42 +01:00
ON_HASSIO = args.hassio
if ON_HASSIO:
2018-11-23 20:52:09 +01:00
USING_HASSIO_AUTH = not bool(os.getenv('DISABLE_HA_AUTHENTICATION'))
2018-11-23 21:14:42 +01:00
USING_PASSWORD = False
else:
USING_HASSIO_AUTH = False
USING_PASSWORD = args.password
if USING_PASSWORD:
2018-11-23 20:52:09 +01:00
PASSWORD_DIGEST = hmac.new(args.password).digest()
if USING_HASSIO_AUTH or USING_PASSWORD:
2018-11-28 21:33:24 +01:00
path = esphomeyaml_storage_path(CONFIG_DIR)
storage = EsphomeyamlStorageJSON.load(path)
if storage is None:
storage = EsphomeyamlStorageJSON.get_default()
storage.save(path)
COOKIE_SECRET = storage.cookie_secret
2018-06-07 20:47:06 +02:00
2018-05-27 14:15:24 +02:00
_LOGGER.info("Starting dashboard web server on port %s and configuration dir %s...",
args.port, CONFIG_DIR)
2018-06-03 11:18:53 +02:00
app = make_app(args.verbose)
app.listen(args.port)
if args.open_ui:
import webbrowser
webbrowser.open('localhost:{}'.format(args.port))
2018-11-19 22:12:24 +01:00
ping_thread = PingThread()
ping_thread.start()
try:
tornado.ioloop.IOLoop.current().start()
except KeyboardInterrupt:
_LOGGER.info("Shutting down...")
2018-11-19 22:12:24 +01:00
STOP_EVENT.set()
PING_REQUEST.set()
ping_thread.join()