mirror of
https://github.com/esphome/esphome.git
synced 2024-11-14 02:58:11 +01:00
dashboard: fix subprocesses blocking the event loop
- break apart the util module - adds a new util to run subprocesses with asyncio
This commit is contained in:
parent
c795dbde26
commit
74e9196dad
9 changed files with 101 additions and 70 deletions
|
@ -10,7 +10,7 @@ from esphome.helpers import get_bool_env
|
||||||
from esphome.storage_json import ext_storage_path
|
from esphome.storage_json import ext_storage_path
|
||||||
|
|
||||||
from .entries import DashboardEntry
|
from .entries import DashboardEntry
|
||||||
from .util import password_hash
|
from .util.password import password_hash
|
||||||
|
|
||||||
|
|
||||||
class DashboardSettings:
|
class DashboardSettings:
|
||||||
|
|
|
@ -7,22 +7,15 @@ from typing import cast
|
||||||
from ..core import DASHBOARD
|
from ..core import DASHBOARD
|
||||||
from ..entries import DashboardEntry
|
from ..entries import DashboardEntry
|
||||||
from ..core import list_dashboard_entries
|
from ..core import list_dashboard_entries
|
||||||
from ..util import chunked
|
from ..util.itertools import chunked
|
||||||
|
from ..util.subprocess import async_system_command_status
|
||||||
|
|
||||||
|
|
||||||
async def _async_ping_host(host: str) -> bool:
|
async def _async_ping_host(host: str) -> bool:
|
||||||
"""Ping a host."""
|
"""Ping a host."""
|
||||||
ping_command = ["ping", "-n" if os.name == "nt" else "-c", "1"]
|
return await async_system_command_status(
|
||||||
process = await asyncio.create_subprocess_exec(
|
["ping", "-n" if os.name == "nt" else "-c", "1", host]
|
||||||
*ping_command,
|
|
||||||
host,
|
|
||||||
stdin=asyncio.subprocess.DEVNULL,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
close_fds=False,
|
|
||||||
)
|
)
|
||||||
await process.wait()
|
|
||||||
return process.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
class PingStatus:
|
class PingStatus:
|
||||||
|
|
|
@ -1,52 +0,0 @@
|
||||||
import hashlib
|
|
||||||
import unicodedata
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from functools import partial
|
|
||||||
from itertools import islice
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from esphome.const import ALLOWED_NAME_CHARS
|
|
||||||
|
|
||||||
|
|
||||||
def password_hash(password: str) -> bytes:
|
|
||||||
"""Create a hash of a password to transform it to a fixed-length digest.
|
|
||||||
|
|
||||||
Note this is not meant for secure storage, but for securely comparing passwords.
|
|
||||||
"""
|
|
||||||
return hashlib.sha256(password.encode()).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def strip_accents(value):
|
|
||||||
return "".join(
|
|
||||||
c
|
|
||||||
for c in unicodedata.normalize("NFD", str(value))
|
|
||||||
if unicodedata.category(c) != "Mn"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def friendly_name_slugify(value):
|
|
||||||
value = (
|
|
||||||
strip_accents(value)
|
|
||||||
.lower()
|
|
||||||
.replace(" ", "-")
|
|
||||||
.replace("_", "-")
|
|
||||||
.replace("--", "-")
|
|
||||||
.strip("-")
|
|
||||||
)
|
|
||||||
return "".join(c for c in value if c in ALLOWED_NAME_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
def take(take_num: int, iterable: Iterable) -> list[Any]:
|
|
||||||
"""Return first n items of the iterable as a list.
|
|
||||||
|
|
||||||
From itertools recipes
|
|
||||||
"""
|
|
||||||
return list(islice(iterable, take_num))
|
|
||||||
|
|
||||||
|
|
||||||
def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]:
|
|
||||||
"""Break *iterable* into lists of length *n*.
|
|
||||||
|
|
||||||
From more-itertools
|
|
||||||
"""
|
|
||||||
return iter(partial(take, chunked_num, iter(iterable)), [])
|
|
0
esphome/dashboard/util/__init__.py
Normal file
0
esphome/dashboard/util/__init__.py
Normal file
22
esphome/dashboard/util/itertools.py
Normal file
22
esphome/dashboard/util/itertools.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from functools import partial
|
||||||
|
from itertools import islice
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def take(take_num: int, iterable: Iterable) -> list[Any]:
|
||||||
|
"""Return first n items of the iterable as a list.
|
||||||
|
|
||||||
|
From itertools recipes
|
||||||
|
"""
|
||||||
|
return list(islice(iterable, take_num))
|
||||||
|
|
||||||
|
|
||||||
|
def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]:
|
||||||
|
"""Break *iterable* into lists of length *n*.
|
||||||
|
|
||||||
|
From more-itertools
|
||||||
|
"""
|
||||||
|
return iter(partial(take, chunked_num, iter(iterable)), [])
|
11
esphome/dashboard/util/password.py
Normal file
11
esphome/dashboard/util/password.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def password_hash(password: str) -> bytes:
|
||||||
|
"""Create a hash of a password to transform it to a fixed-length digest.
|
||||||
|
|
||||||
|
Note this is not meant for secure storage, but for securely comparing passwords.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(password.encode()).digest()
|
31
esphome/dashboard/util/subprocess.py
Normal file
31
esphome/dashboard/util/subprocess.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
async def async_system_command_status(command: Iterable[str]) -> bool:
|
||||||
|
"""Run a system command checking only the status."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
close_fds=False,
|
||||||
|
)
|
||||||
|
await process.wait()
|
||||||
|
return process.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]:
|
||||||
|
"""Run a system command and return a tuple of returncode, stdout, stderr."""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
close_fds=False,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
await process.wait()
|
||||||
|
return process.returncode, stdout, stderr
|
25
esphome/dashboard/util/text.py
Normal file
25
esphome/dashboard/util/text.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from esphome.const import ALLOWED_NAME_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def strip_accents(value):
|
||||||
|
return "".join(
|
||||||
|
c
|
||||||
|
for c in unicodedata.normalize("NFD", str(value))
|
||||||
|
if unicodedata.category(c) != "Mn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def friendly_name_slugify(value):
|
||||||
|
value = (
|
||||||
|
strip_accents(value)
|
||||||
|
.lower()
|
||||||
|
.replace(" ", "-")
|
||||||
|
.replace("_", "-")
|
||||||
|
.replace("--", "-")
|
||||||
|
.strip("-")
|
||||||
|
)
|
||||||
|
return "".join(c for c in value if c in ALLOWED_NAME_CHARS)
|
|
@ -31,13 +31,14 @@ import yaml
|
||||||
from tornado.log import access_log
|
from tornado.log import access_log
|
||||||
|
|
||||||
from esphome import const, platformio_api, yaml_util
|
from esphome import const, platformio_api, yaml_util
|
||||||
from esphome.helpers import get_bool_env, mkdir_p, run_system_command
|
from esphome.helpers import get_bool_env, mkdir_p
|
||||||
from esphome.storage_json import StorageJSON, ext_storage_path, trash_storage_path
|
from esphome.storage_json import StorageJSON, ext_storage_path, trash_storage_path
|
||||||
from esphome.util import get_serial_ports, shlex_quote
|
from esphome.util import get_serial_ports, shlex_quote
|
||||||
|
|
||||||
from .core import DASHBOARD, list_dashboard_entries
|
from .core import DASHBOARD, list_dashboard_entries
|
||||||
from .entries import DashboardEntry
|
from .entries import DashboardEntry
|
||||||
from .util import friendly_name_slugify
|
from .util.text import friendly_name_slugify
|
||||||
|
from .util.subprocess import async_run_system_command
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -522,7 +523,7 @@ class DownloadListRequestHandler(BaseHandler):
|
||||||
class DownloadBinaryRequestHandler(BaseHandler):
|
class DownloadBinaryRequestHandler(BaseHandler):
|
||||||
@authenticated
|
@authenticated
|
||||||
@bind_config
|
@bind_config
|
||||||
def get(self, configuration=None):
|
async def get(self, configuration=None):
|
||||||
compressed = self.get_argument("compressed", "0") == "1"
|
compressed = self.get_argument("compressed", "0") == "1"
|
||||||
|
|
||||||
storage_path = ext_storage_path(configuration)
|
storage_path = ext_storage_path(configuration)
|
||||||
|
@ -548,7 +549,7 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
||||||
|
|
||||||
if not Path(path).is_file():
|
if not Path(path).is_file():
|
||||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||||
rc, stdout, _ = run_system_command(*args)
|
rc, stdout, _ = await async_run_system_command(*args)
|
||||||
|
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
self.send_error(404 if rc == 2 else 500)
|
self.send_error(404 if rc == 2 else 500)
|
||||||
|
@ -902,7 +903,7 @@ SafeLoaderIgnoreUnknown.add_constructor(
|
||||||
class JsonConfigRequestHandler(BaseHandler):
|
class JsonConfigRequestHandler(BaseHandler):
|
||||||
@authenticated
|
@authenticated
|
||||||
@bind_config
|
@bind_config
|
||||||
def get(self, configuration=None):
|
async def get(self, configuration=None):
|
||||||
filename = settings.rel_path(configuration)
|
filename = settings.rel_path(configuration)
|
||||||
if not os.path.isfile(filename):
|
if not os.path.isfile(filename):
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
@ -910,7 +911,7 @@ class JsonConfigRequestHandler(BaseHandler):
|
||||||
|
|
||||||
args = ["esphome", "config", filename, "--show-secrets"]
|
args = ["esphome", "config", filename, "--show-secrets"]
|
||||||
|
|
||||||
rc, stdout, _ = run_system_command(*args)
|
rc, stdout, _ = await async_run_system_command(*args)
|
||||||
|
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
self.send_error(422)
|
self.send_error(422)
|
||||||
|
|
Loading…
Reference in a new issue