diff --git a/docker/rootfs/etc/services.d/esphome/run b/docker/rootfs/etc/services.d/esphome/run index 8dea41abf4..e3e95b520a 100755 --- a/docker/rootfs/etc/services.d/esphome/run +++ b/docker/rootfs/etc/services.d/esphome/run @@ -6,9 +6,23 @@ # shellcheck disable=SC1091 source /usr/lib/hassio-addons/base.sh +export ESPHOME_IS_HASSIO=true + if hass.config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi +if hass.config.true 'streamer_mode'; then + export ESPHOME_STREAMER_MODE=true +fi + +if hass.config.true 'status_use_ping'; then + export ESPHOME_DASHBOARD_USE_PING=true +fi + +if hass.config.has_value 'relative_url'; then + export ESPHOME_DASHBOARD_RELATIVE_URL=$(hass.config.get 'relative_url') +fi + hass.log.info "Starting ESPHome dashboard..." exec esphome /config/esphome dashboard --socket /var/run/esphome.sock --hassio diff --git a/esphome/__main__.py b/esphome/__main__.py index 63ad14fa7b..ab5ebad42d 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -171,7 +171,7 @@ def compile_program(args, config): def upload_using_esptool(config, port): - path = os.path.join(CORE.build_path, '.pioenvs', CORE.name, 'firmware.bin') + path = CORE.firmware_bin cmd = ['esptool.py', '--before', 'default_reset', '--after', 'hard_reset', '--chip', 'esp8266', '--port', port, 'write_flash', '0x0', path] diff --git a/esphome/core.py b/esphome/core.py index 57393c448f..92e75f9c40 100644 --- a/esphome/core.py +++ b/esphome/core.py @@ -12,7 +12,7 @@ from typing import Any, Dict, List # noqa from esphome.const import CONF_ARDUINO_VERSION, CONF_ESPHOME, CONF_ESPHOME_CORE_VERSION, \ CONF_LOCAL, CONF_USE_ADDRESS, CONF_WIFI, ESP_PLATFORM_ESP32, ESP_PLATFORM_ESP8266, \ CONF_REPOSITORY, CONF_BRANCH -from esphome.helpers import ensure_unique_string +from esphome.helpers import ensure_unique_string, is_hassio from esphome.py_compat import IS_PY2, integer_types _LOGGER = logging.getLogger(__name__) @@ -358,9 +358,19 @@ class EsphomeCore(object): path_ = os.path.expanduser(os.path.join(*path)) return os.path.join(self.build_path, path_) + def relative_pioenvs_path(self, *path): + if is_hassio(): + return os.path.join('/data', self.name, '.pioenvs', *path) + return self.relative_build_path('.pioenvs', *path) + + def relative_piolibdeps_path(self, *path): + if is_hassio(): + return os.path.join('/data', self.name, '.piolibdeps', *path) + return self.relative_build_path('.piolibdeps', *path) + @property def firmware_bin(self): - return self.relative_build_path('.pioenvs', self.name, 'firmware.bin') + return self.relative_pioenvs_path(self.name, 'firmware.bin') @property def is_esp8266(self): diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index b420306456..adc3a79d0d 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -2,9 +2,11 @@ from __future__ import print_function import codecs +import collections import hmac import json import logging +import multiprocessing import os import subprocess import threading @@ -23,7 +25,7 @@ import tornado.websocket from esphome import const from esphome.__main__ import get_serial_ports -from esphome.helpers import mkdir_p +from esphome.helpers import mkdir_p, get_bool_env, run_system_command from esphome.py_compat import IS_PY2 from esphome.storage_json import EsphomeStorageJSON, StorageJSON, \ esphome_storage_path, ext_storage_path @@ -42,6 +44,8 @@ USING_PASSWORD = False ON_HASSIO = False USING_HASSIO_AUTH = True HASSIO_MQTT_CONFIG = None +RELATIVE_URL = os.getenv('ESPHOME_DASHBOARD_RELATIVE_URL', '/') +STATUS_USE_PING = get_bool_env('ESPHOME_DASHBOARD_USE_PING') if IS_PY2: cookie_authenticated_yes = 'yes' @@ -49,6 +53,17 @@ else: cookie_authenticated_yes = b'yes' +def template_args(): + version = const.__version__ + return { + 'version': version, + 'docs_link': 'https://beta.esphome.io/' if 'b' in version else 'https://esphome.io/', + 'get_static_file_url': get_static_file_url, + 'relative_url': RELATIVE_URL, + 'streamer_mode': get_bool_env('ESPHOME_STREAMER_MODE'), + } + + # pylint: disable=abstract-method class BaseHandler(tornado.web.RequestHandler): def is_authenticated(self): @@ -165,7 +180,7 @@ class EsphomeHassConfigHandler(EsphomeCommandWebSocket): class SerialPortRequestHandler(BaseHandler): def get(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return ports = get_serial_ports() data = [] @@ -187,7 +202,7 @@ class WizardRequestHandler(BaseHandler): from esphome import wizard if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return kwargs = {k: ''.join(v) for k, v in self.request.arguments.items()} destination = os.path.join(CONFIG_DIR, kwargs['name'] + '.yaml') @@ -198,7 +213,7 @@ class WizardRequestHandler(BaseHandler): class DownloadBinaryRequestHandler(BaseHandler): def get(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return # pylint: disable=no-value-for-parameter @@ -214,8 +229,8 @@ class DownloadBinaryRequestHandler(BaseHandler): 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 + while True: + data = f.read(16384) if not data: break self.write(data) @@ -302,21 +317,26 @@ class DashboardEntry(object): class MainRequestHandler(BaseHandler): def get(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return begin = bool(self.get_argument('begin', False)) entries = _list_dashboard_entries() - version = const.__version__ - docs_link = 'https://beta.esphome.io/' if 'b' in version else \ - 'https://esphome.io/' - self.render("templates/index.html", entries=entries, - version=version, begin=begin, docs_link=docs_link, - get_static_file_url=get_static_file_url) + self.render("templates/index.html", entries=entries, begin=begin, + **template_args()) -class PingThread(threading.Thread): +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 MDNSStatusThread(threading.Thread): def run(self): zc = Zeroconf() @@ -337,10 +357,52 @@ class PingThread(threading.Thread): zc.close() +class PingStatusThread(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 + + def callback(ret): + PING_RESULT[ret[0]] = ret[1] + + 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) + except OSError: + # ping not installed + pass + except multiprocessing.TimeoutError: + pass + + if STOP_EVENT.is_set(): + pool.terminate() + return + + PING_REQUEST.wait() + PING_REQUEST.clear() + + class PingRequestHandler(BaseHandler): def get(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return PING_REQUEST.set() @@ -354,7 +416,7 @@ def is_allowed(configuration): class EditRequestHandler(BaseHandler): def get(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return # pylint: disable=no-value-for-parameter configuration = self.get_argument('configuration') @@ -368,7 +430,7 @@ class EditRequestHandler(BaseHandler): def post(self): if not self.is_authenticated(): - self.redirect('/login') + self.redirect(RELATIVE_URL + 'login') return # pylint: disable=no-value-for-parameter configuration = self.get_argument('configuration') @@ -392,18 +454,13 @@ class LoginHandler(BaseHandler): if USING_HASSIO_AUTH: self.render_hassio_login() return - self.write('
' + self.write('' 'Password: ' '' '
') def render_hassio_login(self, error=None): - version = const.__version__ - docs_link = 'https://beta.esphome.io/' if 'b' in version else \ - 'https://esphome.io/' - - self.render("templates/login.html", version=version, docs_link=docs_link, error=error, - get_static_file_url=get_static_file_url) + self.render("templates/login.html", error=error, **template_args()) def post_hassio_login(self): import requests @@ -454,9 +511,9 @@ def get_static_file_url(name): else: path = os.path.join(static_path, name) with open(path, 'rb') as f_handle: - hash_ = hash(f_handle.read()) + hash_ = hash(f_handle.read()) & (2**32-1) _STATIC_FILE_HASHES[name] = hash_ - return u'/static/{}?hash={}'.format(name, hash_) + return RELATIVE_URL + u'static/{}?hash={:08X}'.format(name, hash_) def make_app(debug=False): @@ -491,21 +548,21 @@ def make_app(debug=False): 'websocket_ping_interval': 30.0, } app = tornado.web.Application([ - (r"/", MainRequestHandler), - (r"/login", LoginHandler), - (r"/logs", EsphomeLogsHandler), - (r"/run", EsphomeRunHandler), - (r"/compile", EsphomeCompileHandler), - (r"/validate", EsphomeValidateHandler), - (r"/clean-mqtt", EsphomeCleanMqttHandler), - (r"/clean", EsphomeCleanHandler), - (r"/hass-config", EsphomeHassConfigHandler), - (r"/edit", EditRequestHandler), - (r"/download.bin", DownloadBinaryRequestHandler), - (r"/serial-ports", SerialPortRequestHandler), - (r"/ping", PingRequestHandler), - (r"/wizard.html", WizardRequestHandler), - (r'/static/(.*)', StaticFileHandler, {'path': static_path}), + (RELATIVE_URL + "", MainRequestHandler), + (RELATIVE_URL + "login", LoginHandler), + (RELATIVE_URL + "logs", EsphomeLogsHandler), + (RELATIVE_URL + "run", EsphomeRunHandler), + (RELATIVE_URL + "compile", EsphomeCompileHandler), + (RELATIVE_URL + "validate", EsphomeValidateHandler), + (RELATIVE_URL + "clean-mqtt", EsphomeCleanMqttHandler), + (RELATIVE_URL + "clean", EsphomeCleanHandler), + (RELATIVE_URL + "hass-config", EsphomeHassConfigHandler), + (RELATIVE_URL + "edit", EditRequestHandler), + (RELATIVE_URL + "download.bin", DownloadBinaryRequestHandler), + (RELATIVE_URL + "serial-ports", SerialPortRequestHandler), + (RELATIVE_URL + "ping", PingRequestHandler), + (RELATIVE_URL + "wizard.html", WizardRequestHandler), + (RELATIVE_URL + r"static/(.*)", StaticFileHandler, {'path': static_path}), ], **settings) if debug: @@ -528,7 +585,7 @@ def start_web_server(args): ON_HASSIO = args.hassio if ON_HASSIO: - USING_HASSIO_AUTH = not bool(os.getenv('DISABLE_HA_AUTHENTICATION')) + USING_HASSIO_AUTH = not get_bool_env('DISABLE_HA_AUTHENTICATION') USING_PASSWORD = False else: USING_HASSIO_AUTH = False @@ -565,14 +622,17 @@ def start_web_server(args): webbrowser.open('localhost:{}'.format(args.port)) - ping_thread = PingThread() - ping_thread.start() + if STATUS_USE_PING: + status_thread = PingStatusThread() + else: + status_thread = MDNSStatusThread() + status_thread.start() try: tornado.ioloop.IOLoop.current().start() except KeyboardInterrupt: _LOGGER.info("Shutting down...") STOP_EVENT.set() PING_REQUEST.set() - ping_thread.join() + status_thread.join() if args.socket is not None: os.remove(args.socket) diff --git a/esphome/dashboard/static/esphome.js b/esphome/dashboard/static/esphome.js index ca33231a06..9c04f4f69e 100644 --- a/esphome/dashboard/static/esphome.js +++ b/esphome/dashboard/static/esphome.js @@ -1,3 +1,5 @@ +// Disclaimer: This file was written in a hurry and by someone +// who does not know JS at all. This file desperately needs cleanup. document.addEventListener('DOMContentLoaded', () => { M.AutoInit(document.body); }); @@ -183,7 +185,7 @@ let wsProtocol = "ws:"; if (window.location.protocol === "https:") { wsProtocol = 'wss:'; } -const wsUrl = wsProtocol + '//' + window.location.hostname + ':' + window.location.port; +const wsUrl = `${wsProtocol}//${window.location.hostname}:${window.location.port}${relative_url}`; let isFetchingPing = false; const fetchPing = () => { @@ -191,7 +193,7 @@ const fetchPing = () => { return; isFetchingPing = true; - fetch('/ping', {credentials: "same-origin"}).then(res => res.json()) + fetch(`${relative_url}ping`, {credentials: "same-origin"}).then(res => res.json()) .then(response => { for (let filename in response) { let node = document.querySelector(`.status-indicator[data-node="${filename}"]`); @@ -233,7 +235,7 @@ const portSelect = document.querySelector('.nav-wrapper select'); let ports = []; const fetchSerialPorts = (begin=false) => { - fetch('/serial-ports', {credentials: "same-origin"}).then(res => res.json()) + fetch(`${relative_url}serial-ports`, {credentials: "same-origin"}).then(res => res.json()) .then(response => { if (ports.length === response.length) { let allEqual = true; @@ -301,7 +303,7 @@ document.querySelectorAll(".action-show-logs").forEach((showLogs) => { const filenameField = logsModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/logs"); + const logSocket = new WebSocket(wsUrl + "logs"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -350,7 +352,7 @@ document.querySelectorAll(".action-upload").forEach((upload) => { const filenameField = uploadModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/run"); + const logSocket = new WebSocket(wsUrl + "run"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -399,7 +401,7 @@ document.querySelectorAll(".action-validate").forEach((upload) => { const filenameField = validateModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/validate"); + const logSocket = new WebSocket(wsUrl + "validate"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -457,7 +459,7 @@ document.querySelectorAll(".action-compile").forEach((upload) => { const filenameField = compileModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/compile"); + const logSocket = new WebSocket(wsUrl + "compile"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -492,7 +494,7 @@ document.querySelectorAll(".action-compile").forEach((upload) => { downloadButton.addEventListener('click', () => { const link = document.createElement("a"); link.download = name; - link.href = '/download.bin?configuration=' + encodeURIComponent(configuration); + link.href = `${relative_url}download.bin?configuration=${encodeURIComponent(configuration)}`; document.body.appendChild(link); link.click(); link.remove(); @@ -515,7 +517,7 @@ document.querySelectorAll(".action-clean-mqtt").forEach((btn) => { const filenameField = cleanMqttModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/clean-mqtt"); + const logSocket = new WebSocket(wsUrl + "clean-mqtt"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -557,7 +559,7 @@ document.querySelectorAll(".action-clean").forEach((btn) => { const filenameField = cleanModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/clean"); + const logSocket = new WebSocket(wsUrl + "clean"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -605,7 +607,7 @@ document.querySelectorAll(".action-hass-config").forEach((btn) => { const filenameField = hassConfigModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - const logSocket = new WebSocket(wsUrl + "/hass-config"); + const logSocket = new WebSocket(wsUrl + "hass-config"); logSocket.addEventListener('message', (event) => { const data = JSON.parse(event.data); if (data.event === "line") { @@ -646,7 +648,7 @@ editor.session.setOption('tabSize', 2); const saveButton = editModalElem.querySelector(".save-button"); const saveEditor = () => { - fetch(`/edit?configuration=${configuration}`, { + fetch(`${relative_url}edit?configuration=${configuration}`, { credentials: "same-origin", method: "POST", body: editor.getValue() @@ -673,7 +675,7 @@ document.querySelectorAll(".action-edit").forEach((btn) => { const filenameField = editModalElem.querySelector('.filename'); filenameField.innerHTML = configuration; - fetch(`/edit?configuration=${configuration}`, {credentials: "same-origin"}) + fetch(`${relative_url}edit?configuration=${configuration}`, {credentials: "same-origin"}) .then(res => res.text()).then(response => { editor.setValue(response, -1); }); diff --git a/esphome/dashboard/templates/index.html b/esphome/dashboard/templates/index.html index a478bdf190..fa184c2b2c 100644 --- a/esphome/dashboard/templates/index.html +++ b/esphome/dashboard/templates/index.html @@ -16,6 +16,15 @@ + + + {% if streamer_mode %} + + {% end %} diff --git a/esphome/dashboard/templates/login.html b/esphome/dashboard/templates/login.html index 7445f312aa..320ca0cc58 100644 --- a/esphome/dashboard/templates/login.html +++ b/esphome/dashboard/templates/login.html @@ -28,7 +28,7 @@
-
+
Enter credentials

diff --git a/esphome/helpers.py b/esphome/helpers.py index e958315c22..4def9568f4 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -134,6 +134,14 @@ def resolve_ip_address(host): return ip +def get_bool_env(var, default=False): + return bool(os.getenv(var, default)) + + +def is_hassio(): + return get_bool_env('ESPHOME_IS_HASSIO') + + def symlink(src, dst): if hasattr(os, 'symlink'): os.symlink(src, dst) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index f51bbde9a5..c91679062a 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) def run_platformio_cli(*args, **kwargs): os.environ["PLATFORMIO_FORCE_COLOR"] = "true" + os.environ["PLATFORMIO_BUILD_DIR"] = os.path.abspath(CORE.relative_pioenvs_path()) + os.environ["PLATFORMIO_LIBDEPS_DIR"] = os.path.abspath(CORE.relative_piolibdeps_path()) cmd = ['platformio'] + list(args) if os.environ.get('ESPHOME_USE_SUBPROCESS') is None: diff --git a/esphome/wizard.py b/esphome/wizard.py index 2bd5c4aad2..4b960fec07 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -8,7 +8,7 @@ import voluptuous as vol import esphome.config_validation as cv from esphome.const import ESP_PLATFORMS, ESP_PLATFORM_ESP32, ESP_PLATFORM_ESP8266 -from esphome.helpers import color +from esphome.helpers import color, get_bool_env # pylint: disable=anomalous-backslash-in-string from esphome.pins import ESP32_BOARD_PINS, ESP8266_BOARD_PINS from esphome.py_compat import safe_input, text_type @@ -86,7 +86,7 @@ def wizard_write(path, **kwargs): storage.save(storage_path) -if os.getenv('ESPHOME_QUICKWIZARD', ''): +if get_bool_env('ESPHOME_QUICKWIZARD'): def sleep(time): pass else: diff --git a/esphome/writer.py b/esphome/writer.py index fdcba4687f..22206624dd 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -105,7 +105,7 @@ def update_esphome_core_repo(): # Git commit hash or tag cannot be updated return - esphome_core_path = CORE.relative_build_path('.piolibdeps', 'esphome-core') + esphome_core_path = CORE.relative_piolibdeps_path('esphome-core') rc, _, _ = run_system_command('git', '-C', esphome_core_path, '--help') if rc != 0: @@ -503,12 +503,14 @@ def write_cpp(code_s): def clean_build(): - for directory in ('.piolibdeps', '.pioenvs'): - dir_path = CORE.relative_build_path(directory) - if not os.path.isdir(dir_path): - continue - _LOGGER.info("Deleting %s", dir_path) - shutil.rmtree(dir_path) + pioenvs = CORE.relative_pioenvs_path() + if os.path.isdir(pioenvs): + _LOGGER.info("Deleting %s", pioenvs) + shutil.rmtree(pioenvs) + piolibdeps = CORE.relative_piolibdeps_path() + if os.path.isdir(piolibdeps): + _LOGGER.info("Deleting %s", piolibdeps) + shutil.rmtree(piolibdeps) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome