1
0
mirror of https://github.com/esphome/esphome.git synced 2026-06-24 01:48:54 +02:00

[dashboard] Remove legacy web dashboard (#17124)

This commit is contained in:
Jesse Hills
2026-06-22 09:33:27 +12:00
committed by GitHub
parent c4abc5476e
commit d0e3e98d55
47 changed files with 109 additions and 6693 deletions

View File

@@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) {
*/
function hasDashboardChanges(changedFiles) {
return changedFiles.some(file =>
file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/')
);
}

View File

@@ -1,119 +0,0 @@
name: Add Dashboard Deprecation Comment
on:
pull_request_target:
types: [opened, synchronize]
# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
# roll up everything merged into dev since the last cut, which can
# include dashboard changes that have already been reviewed once.
# The bot's purpose is to warn new contributors before they invest
# time -- that only applies to PRs entering dev.
if: github.event.pull_request.base.ref == 'dev'
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
# pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
# the issues.*Comment APIs require the pull-requests scope, not issues.
permission-pull-requests: write
- name: Add dashboard deprecation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const commentMarker = "<!-- This comment was generated automatically by the dashboard-deprecation-comment workflow. -->";
const commentBody = `Thanks for opening this PR!
Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
What this means for your PR:
- **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
- **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
- **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
---
(Added by the PR bot)
${commentMarker}`;
async function getDashboardChanges(github, owner, repo, prNumber) {
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner: owner,
repo: repo,
pull_number: prNumber,
per_page: 100,
}
);
return changedFiles.filter(file =>
file.filename.startsWith('esphome/dashboard/') ||
file.filename.startsWith('tests/dashboard/')
);
}
async function findBotComment(github, owner, repo, prNumber) {
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: owner,
repo: repo,
issue_number: prNumber,
per_page: 100,
}
);
return comments.find(comment =>
comment.body.includes(commentMarker) && comment.user.type === "Bot"
);
}
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
const existingComment = await findBotComment(github, owner, repo, prNumber);
if (dashboardChanges.length === 0) {
// PR doesn't (or no longer) touches the legacy dashboard. If we previously
// commented (e.g. files were removed in a later push), leave the comment in
// place for history rather than thrash on edit/delete.
return;
}
if (existingComment) {
if (existingComment.body === commentBody) {
return;
}
await github.rest.issues.updateComment({
owner: owner,
repo: repo,
comment_id: existingComment.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: prNumber,
body: commentBody,
});
}

View File

@@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro
2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management.
3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management.
4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration.
5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates.
* **Platform Support:**
1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3).
@@ -456,7 +455,6 @@ This document provides essential context for AI models interacting with this pro
* **Debug Tools:**
- `esphome config <file>.yaml` to validate configuration.
- `esphome compile <file>.yaml` to compile without uploading.
- Check the Dashboard for real-time logs.
- Use component-specific debug logging.
* **Common Issues:**
- **Import Errors**: Check component dependencies and `PYTHONPATH`.
@@ -658,7 +656,7 @@ This document provides essential context for AI models interacting with this pro
If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs.
**Why this matters:**
- Module-level globals persist between compilation runs if the dashboard doesn't fork/exec
- Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec
- `CORE.data` automatically clears between runs
- Namespacing under `DOMAIN` prevents key collisions between components
- `@dataclass` provides type safety and cleaner attribute access

View File

@@ -527,7 +527,7 @@ def has_resolvable_address() -> bool:
if has_ip_address():
return True
# The dashboard pre-resolves the device and passes the IPs via
# device-builder pre-resolves the device and passes the IPs via
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
# device has mDNS disabled (e.g. a .local host found via ping).
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
@@ -1715,9 +1715,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_dashboard(args: ArgsProtocol) -> int | None:
from esphome.dashboard import dashboard
return dashboard.start_dashboard(args)
raise EsphomeError(
"The built-in dashboard has been removed from ESPHome. "
"Install and run ESPHome Device Builder instead:\n"
" pip install esphome-device-builder\n"
" esphome-device-builder\n"
"See https://github.com/esphome/device-builder for more information."
)
def run_multiple_configs(
@@ -2379,44 +2383,22 @@ def parse_args(argv):
"configuration", help="Your YAML file or configuration directory.", nargs="*"
)
parser_dashboard = subparsers.add_parser(
"dashboard", help="Create a simple web server for a dashboard."
# The dashboard moved to ESPHome Device Builder; the command is kept only to
# print a redirect (see command_dashboard). Accept and ignore the old flags
# so legacy invocations reach that message instead of failing on argparse
# "unrecognized arguments".
parser_dashboard = subparsers.add_parser("dashboard")
parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS)
parser_dashboard.add_argument("--port", help=argparse.SUPPRESS)
parser_dashboard.add_argument("--address", help=argparse.SUPPRESS)
parser_dashboard.add_argument("--username", help=argparse.SUPPRESS)
parser_dashboard.add_argument("--password", help=argparse.SUPPRESS)
parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS)
parser_dashboard.add_argument(
"--open-ui", action="store_true", help=argparse.SUPPRESS
)
parser_dashboard.add_argument(
"configuration", help="Your YAML configuration file directory."
)
parser_dashboard.add_argument(
"--port",
help="The HTTP port to open connections on. Defaults to 6052.",
type=int,
default=6052,
)
parser_dashboard.add_argument(
"--address",
help="The address to bind to.",
type=str,
default="0.0.0.0",
)
parser_dashboard.add_argument(
"--username",
help="The optional username to require for authentication.",
type=str,
default="",
)
parser_dashboard.add_argument(
"--password",
help="The optional password to require for authentication.",
type=str,
default="",
)
parser_dashboard.add_argument(
"--open-ui", help="Open the dashboard UI in a browser.", action="store_true"
)
parser_dashboard.add_argument(
"--ha-addon", help=argparse.SUPPRESS, action="store_true"
)
parser_dashboard.add_argument(
"--socket", help="Make the dashboard serve under a unix socket", type=str
"--ha-addon", action="store_true", help=argparse.SUPPRESS
)
parser_vscode = subparsers.add_parser("vscode")
@@ -2511,11 +2493,7 @@ def run_esphome(argv):
elif args.quiet:
args.log_level = "CRITICAL"
setup_log(
log_level=args.log_level,
# Show timestamp for dashboard access logs
include_timestamp=args.command == "dashboard",
)
setup_log(log_level=args.log_level)
if args.command in PRE_CONFIG_ACTIONS:
try:

View File

@@ -92,7 +92,6 @@ def import_config(
"""Materialise a dashboard-imported device's YAML on disk.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) — called from the
``devices/import`` WS handler to seed the YAML for an adopted
factory firmware. Coordinate before changing the kwargs or the

View File

@@ -533,15 +533,13 @@ def get_board(core_obj=None):
def get_download_types(storage_json):
"""Binary-download entries for a built ESP32 firmware.
Used by:
- esphome.dashboard (legacy "Download .bin" button)
- device-builder (esphome/device-builder) — same dispatch via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the new dashboard's download panel
doesn't have to special-case per-platform schemas.
Used by device-builder (esphome/device-builder), via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the download panel
doesn't have to special-case per-platform schemas.
"""
return [
{

View File

@@ -97,15 +97,13 @@ def set_core_data(config):
def get_download_types(storage_json):
"""Binary-download entries for a built ESP8266 firmware.
Used by:
- esphome.dashboard (legacy "Download .bin" button)
- device-builder (esphome/device-builder) — same dispatch via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the new dashboard's download panel
doesn't have to special-case per-platform schemas.
Used by device-builder (esphome/device-builder), via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the download panel
doesn't have to special-case per-platform schemas.
"""
return [
{

View File

@@ -158,15 +158,13 @@ def only_on_family(*, supported=None, unsupported=None):
def get_download_types(storage_json: StorageJSON = None):
"""Binary-download entries for a built LibreTiny firmware.
Used by:
- esphome.dashboard (legacy "Download .bin" button)
- device-builder (esphome/device-builder) — same dispatch via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the new dashboard's download panel
doesn't have to special-case per-platform schemas.
Used by device-builder (esphome/device-builder), via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the download panel
doesn't have to special-case per-platform schemas.
"""
types = [
{

View File

@@ -140,15 +140,13 @@ def only_on_variant(
def get_download_types(storage_json):
"""Binary-download entries for a built RP2040 firmware.
Used by:
- esphome.dashboard (legacy "Download .bin" button)
- device-builder (esphome/device-builder) — same dispatch via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the new dashboard's download panel
doesn't have to special-case per-platform schemas.
Used by device-builder (esphome/device-builder), via
``importlib.import_module(f"esphome.components.{platform}")``
then ``module.get_download_types(storage)``. The contract is
"returns ``list[dict]`` with at least ``title`` /
``description`` / ``file`` / ``download`` keys"; please keep
the shape stable so the download panel
doesn't have to special-case per-platform schemas.
"""
return [
{

View File

@@ -1,32 +0,0 @@
from __future__ import annotations
import sys
from esphome.enum import StrEnum
class DashboardEvent(StrEnum):
"""Dashboard WebSocket event types."""
# Server -> Client events (backend sends to frontend)
ENTRY_ADDED = "entry_added"
ENTRY_REMOVED = "entry_removed"
ENTRY_UPDATED = "entry_updated"
ENTRY_STATE_CHANGED = "entry_state_changed"
IMPORTABLE_DEVICE_ADDED = "importable_device_added"
IMPORTABLE_DEVICE_REMOVED = "importable_device_removed"
INITIAL_STATE = "initial_state" # Sent on WebSocket connection
PONG = "pong" # Response to client ping
# Client -> Server events (frontend sends to backend)
PING = "ping" # WebSocket keepalive from client
REFRESH = "refresh" # Force backend to poll for changes
MAX_EXECUTOR_WORKERS = 48
SENTINEL = object()
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"]

View File

@@ -1,190 +0,0 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass
from functools import partial
import json
import logging
import threading
from typing import Any
from esphome.storage_json import ignored_devices_storage_path
from ..zeroconf import DiscoveredImport
from .const import DashboardEvent
from .dns import DNSCache
from .entries import DashboardEntries
from .settings import DashboardSettings
from .status.mdns import MDNSStatus
from .status.ping import PingStatus
_LOGGER = logging.getLogger(__name__)
IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json"
MDNS_BOOTSTRAP_TIME = 7.5
@dataclass
class Event:
"""Dashboard Event."""
event_type: DashboardEvent
data: dict[str, Any]
class EventBus:
"""Dashboard event bus."""
def __init__(self) -> None:
"""Initialize the Dashboard event bus."""
self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {}
def async_add_listener(
self, event_type: DashboardEvent, listener: Callable[[Event], None]
) -> Callable[[], None]:
"""Add a listener to the event bus."""
self._listeners.setdefault(event_type, set()).add(listener)
return partial(self._async_remove_listener, event_type, listener)
def _async_remove_listener(
self, event_type: DashboardEvent, listener: Callable[[Event], None]
) -> None:
"""Remove a listener from the event bus."""
self._listeners[event_type].discard(listener)
def async_fire(
self, event_type: DashboardEvent, event_data: dict[str, Any]
) -> None:
"""Fire an event."""
event = Event(event_type, event_data)
_LOGGER.debug("Firing event: %s", event)
for listener in self._listeners.get(event_type, set()):
listener(event)
class ESPHomeDashboard:
"""Class that represents the dashboard."""
__slots__ = (
"bus",
"entries",
"loop",
"import_result",
"stop_event",
"ping_request",
"mqtt_ping_request",
"mdns_status",
"settings",
"dns_cache",
"_background_tasks",
"ignored_devices",
"_ping_status_task",
)
def __init__(self) -> None:
"""Initialize the ESPHomeDashboard."""
self.bus = EventBus()
self.entries: DashboardEntries | None = None
self.loop: asyncio.AbstractEventLoop | None = None
self.import_result: dict[str, DiscoveredImport] = {}
self.stop_event = threading.Event()
self.ping_request: asyncio.Event | None = None
self.mqtt_ping_request = threading.Event()
self.mdns_status: MDNSStatus | None = None
self.settings = DashboardSettings()
self.dns_cache = DNSCache()
self._background_tasks: set[asyncio.Task] = set()
self.ignored_devices: set[str] = set()
self._ping_status_task: asyncio.Task | None = None
async def async_setup(self) -> None:
"""Setup the dashboard."""
self.loop = asyncio.get_running_loop()
self.ping_request = asyncio.Event()
self.entries = DashboardEntries(self)
await self.loop.run_in_executor(None, self.load_ignored_devices)
def load_ignored_devices(self) -> None:
storage_path = ignored_devices_storage_path()
try:
with storage_path.open("r", encoding="utf-8") as f_handle:
data = json.load(f_handle)
self.ignored_devices = set(data.get("ignored_devices", set()))
except FileNotFoundError:
pass
def save_ignored_devices(self) -> None:
storage_path = ignored_devices_storage_path()
with storage_path.open("w", encoding="utf-8") as f_handle:
json.dump(
{"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle
)
def _async_start_ping_status(self, ping_status: PingStatus) -> None:
self._ping_status_task = asyncio.create_task(ping_status.async_run())
async def async_run(self) -> None:
"""Run the dashboard."""
settings = self.settings
mdns_task: asyncio.Task | None = None
await self.entries.async_update_entries()
mdns_status = MDNSStatus(self)
ping_status = PingStatus(self)
start_ping_timer: asyncio.TimerHandle | None = None
self.mdns_status = mdns_status
if mdns_status.async_setup():
mdns_task = asyncio.create_task(mdns_status.async_run())
# Start ping MDNS_BOOTSTRAP_TIME seconds after startup to ensure
# MDNS has had a chance to resolve the devices
start_ping_timer = self.loop.call_later(
MDNS_BOOTSTRAP_TIME, self._async_start_ping_status, ping_status
)
else:
# If mDNS is not available, start the ping status immediately
self._async_start_ping_status(ping_status)
if settings.status_use_mqtt:
from .status.mqtt import MqttStatusThread
status_thread_mqtt = MqttStatusThread(self)
status_thread_mqtt.start()
try:
await asyncio.Event().wait()
finally:
_LOGGER.info("Shutting down...")
self.stop_event.set()
self.ping_request.set()
if start_ping_timer:
start_ping_timer.cancel()
if self._ping_status_task:
self._ping_status_task.cancel()
self._ping_status_task = None
if mdns_task:
mdns_task.cancel()
if settings.status_use_mqtt:
status_thread_mqtt.join()
self.mqtt_ping_request.set()
for task in self._background_tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await asyncio.sleep(0)
def async_create_background_task(
self, coro: Coroutine[Any, Any, Any]
) -> asyncio.Task:
"""Create a background task."""
task = self.loop.create_task(coro)
task.add_done_callback(self._background_tasks.discard)
return task
DASHBOARD = ESPHomeDashboard()

View File

@@ -1,153 +0,0 @@
from __future__ import annotations
import asyncio
from asyncio import events
from concurrent.futures import ThreadPoolExecutor
import contextlib
import logging
import os
from pathlib import Path
import socket
import threading
from time import monotonic
import traceback
from typing import Any
from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path
from .const import MAX_EXECUTOR_WORKERS
from .core import DASHBOARD
from .web_server import make_app, start_web_server
ENV_DEV = "ESPHOME_DASHBOARD_DEV"
settings = DASHBOARD.settings
def can_use_pidfd() -> bool:
"""Check if pidfd_open is available.
Back ported from cpython 3.12
"""
if not hasattr(os, "pidfd_open"):
return False
try:
pid = os.getpid()
os.close(os.pidfd_open(pid, 0))
except OSError:
# blocked by security policy like SECCOMP
return False
return True
class DashboardEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
"""Event loop policy for Home Assistant."""
def __init__(self, debug: bool) -> None:
"""Init the event loop policy."""
super().__init__()
self.debug = debug
self._watcher: asyncio.AbstractChildWatcher | None = None
def _init_watcher(self) -> None:
"""Initialize the watcher for child processes.
Back ported from cpython 3.12
"""
with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access
if self._watcher is None: # pragma: no branch
if can_use_pidfd():
self._watcher = asyncio.PidfdChildWatcher()
else:
self._watcher = asyncio.ThreadedChildWatcher()
if threading.current_thread() is threading.main_thread():
self._watcher.attach_loop(
self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access
)
@property
def loop_name(self) -> str:
"""Return name of the loop."""
return self._loop_factory.__name__ # type: ignore[no-any-return,attr-defined]
def new_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get the event loop."""
loop: asyncio.AbstractEventLoop = super().new_event_loop()
loop.set_exception_handler(_async_loop_exception_handler)
if self.debug:
loop.set_debug(True)
executor = ThreadPoolExecutor(
thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS
)
loop.set_default_executor(executor)
# bind the built-in time.monotonic directly as loop.time to avoid the
# overhead of the additional method call since its the most called loop
# method and its roughly 10%+ of all the call time in base_events.py
loop.time = monotonic # type: ignore[method-assign]
return loop
def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None:
"""Handle all exception inside the core loop."""
kwargs = {}
if exception := context.get("exception"):
kwargs["exc_info"] = (type(exception), exception, exception.__traceback__)
logger = logging.getLogger(__package__)
if source_traceback := context.get("source_traceback"):
stack_summary = "".join(traceback.format_list(source_traceback))
logger.error(
"Error doing job: %s: %s",
context["message"],
stack_summary,
**kwargs, # type: ignore[arg-type]
)
return
logger.error(
"Error doing job: %s",
context["message"],
**kwargs, # type: ignore[arg-type]
)
def start_dashboard(args) -> None:
"""Start the dashboard."""
settings.parse_args(args)
if settings.using_auth:
path = esphome_storage_path()
storage = EsphomeStorageJSON.load(path)
if storage is None:
storage = EsphomeStorageJSON.get_default()
storage.save(path)
settings.cookie_secret = storage.cookie_secret
asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose))
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(async_start(args))
async def async_start(args) -> None:
"""Start the dashboard."""
dashboard = DASHBOARD
await dashboard.async_setup()
sock: socket.socket | None = args.socket
address: str | None = args.address
port: int | None = args.port
start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir)
if args.open_ui:
import webbrowser
webbrowser.open(f"http://{args.address}:{args.port}")
try:
await dashboard.async_run()
finally:
if sock:
Path(sock).unlink()

View File

@@ -1,77 +0,0 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from ipaddress import ip_address
import logging
from icmplib import NameLookupError, async_resolve
RESOLVE_TIMEOUT = 3.0
_LOGGER = logging.getLogger(__name__)
_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError)
async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception:
"""Wrap the icmplib async_resolve function."""
with suppress(ValueError):
return [str(ip_address(hostname))]
try:
async with asyncio.timeout(RESOLVE_TIMEOUT):
return await async_resolve(hostname)
except _RESOLVE_EXCEPTIONS as ex:
# If the hostname ends with .local and resolution failed,
# try the bare hostname as a fallback since mDNS may not be
# working on the system but unicast DNS might resolve it
if hostname.endswith(".local"):
bare_hostname = hostname[:-6] # Remove ".local"
try:
async with asyncio.timeout(RESOLVE_TIMEOUT):
result = await async_resolve(bare_hostname)
_LOGGER.debug(
"Bare hostname %s resolved to %s", bare_hostname, result
)
return result
except _RESOLVE_EXCEPTIONS:
_LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname)
return ex
class DNSCache:
"""DNS cache for the dashboard."""
def __init__(self, ttl: int | None = 120) -> None:
"""Initialize the DNSCache."""
self._cache: dict[str, tuple[float, list[str] | Exception]] = {}
self._ttl = ttl
def get_cached_addresses(
self, hostname: str, now_monotonic: float
) -> list[str] | None:
"""Get cached addresses without triggering resolution.
Returns None if not in cache, list of addresses if found.
"""
# Normalize hostname for consistent lookups
normalized = hostname.rstrip(".").lower()
if expire_time_addresses := self._cache.get(normalized):
expire_time, addresses = expire_time_addresses
if expire_time > now_monotonic and not isinstance(addresses, Exception):
return addresses
return None
async def async_resolve(
self, hostname: str, now_monotonic: float
) -> list[str] | Exception:
"""Resolve a hostname to a list of IP address."""
if expire_time_addresses := self._cache.get(hostname):
expire_time, addresses = expire_time_addresses
if expire_time > now_monotonic:
return addresses
expires = now_monotonic + self._ttl
addresses = await _async_resolve_wrapper(hostname)
self._cache[hostname] = (expires, addresses)
return addresses

View File

@@ -1,458 +0,0 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from functools import lru_cache
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
from esphome import const, util
from esphome.enum import StrEnum
from esphome.storage_json import StorageJSON, ext_storage_path
from .const import DASHBOARD_COMMAND, DashboardEvent
from .util.subprocess import async_run_system_command
if TYPE_CHECKING:
from .core import ESPHomeDashboard
_LOGGER = logging.getLogger(__name__)
DashboardCacheKeyType = tuple[int, int, float, int]
@dataclass(frozen=True)
class EntryState:
"""Represents the state of an entry."""
reachable: ReachableState
source: EntryStateSource
class EntryStateSource(StrEnum):
MDNS = "mdns"
PING = "ping"
MQTT = "mqtt"
UNKNOWN = "unknown"
class ReachableState(StrEnum):
ONLINE = "online"
OFFLINE = "offline"
DNS_FAILURE = "dns_failure"
UNKNOWN = "unknown"
_BOOL_TO_REACHABLE_STATE = {
True: ReachableState.ONLINE,
False: ReachableState.OFFLINE,
None: ReachableState.UNKNOWN,
}
_REACHABLE_STATE_TO_BOOL = {
ReachableState.ONLINE: True,
ReachableState.OFFLINE: False,
ReachableState.DNS_FAILURE: False,
ReachableState.UNKNOWN: None,
}
UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN)
@lru_cache # creating frozen dataclass instances is expensive, so we cache them
def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState:
"""Convert a bool to an entry state."""
return EntryState(_BOOL_TO_REACHABLE_STATE[value], source)
def entry_state_to_bool(value: EntryState) -> bool | None:
"""Convert an entry state to a bool."""
return _REACHABLE_STATE_TO_BOOL[value.reachable]
class DashboardEntries:
"""Represents all dashboard entries."""
__slots__ = (
"_dashboard",
"_loop",
"_config_dir",
"_entries",
"_entry_states",
"_loaded_entries",
"_update_lock",
"_name_to_entry",
)
def __init__(self, dashboard: ESPHomeDashboard) -> None:
"""Initialize the DashboardEntries."""
self._dashboard = dashboard
self._loop = asyncio.get_running_loop()
self._config_dir = dashboard.settings.config_dir
# Entries are stored as
# {
# "path/to/file.yaml": DashboardEntry,
# ...
# }
self._entries: dict[Path, DashboardEntry] = {}
self._loaded_entries = False
self._update_lock = asyncio.Lock()
self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set)
def get(self, path: Path) -> DashboardEntry | None:
"""Get an entry by path."""
return self._entries.get(path)
def get_by_name(self, name: str) -> set[DashboardEntry] | None:
"""Get an entry by name."""
return self._name_to_entry.get(name)
async def _async_all(self) -> list[DashboardEntry]:
"""Return all entries."""
return list(self._entries.values())
def all(self) -> list[DashboardEntry]:
"""Return all entries."""
return asyncio.run_coroutine_threadsafe(self._async_all(), self._loop).result()
def async_all(self) -> list[DashboardEntry]:
"""Return all entries."""
return list(self._entries.values())
def set_state(self, entry: DashboardEntry, state: EntryState) -> None:
"""Set the state for an entry."""
asyncio.run_coroutine_threadsafe(
self._async_set_state(entry, state), self._loop
).result()
async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None:
"""Set the state for an entry."""
self.async_set_state(entry, state)
def set_state_if_online_or_source(
self, entry: DashboardEntry, state: EntryState
) -> None:
"""Set the state for an entry if its online or provided by the source or unknown."""
asyncio.run_coroutine_threadsafe(
self._async_set_state_if_online_or_source(entry, state), self._loop
).result()
async def _async_set_state_if_online_or_source(
self, entry: DashboardEntry, state: EntryState
) -> None:
"""Set the state for an entry if its online or provided by the source or unknown."""
self.async_set_state_if_online_or_source(entry, state)
def async_set_state_if_online_or_source(
self, entry: DashboardEntry, state: EntryState
) -> None:
"""Set the state for an entry if its online or provided by the source or unknown."""
if (
state.reachable is ReachableState.ONLINE
and entry.state.reachable is not ReachableState.ONLINE
) or entry.state.source in (
EntryStateSource.UNKNOWN,
state.source,
):
self.async_set_state(entry, state)
def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None:
"""Set the state for an entry if provided by the source or unknown."""
asyncio.run_coroutine_threadsafe(
self._async_set_state_if_source(entry, state), self._loop
).result()
async def _async_set_state_if_source(
self, entry: DashboardEntry, state: EntryState
) -> None:
"""Set the state for an entry if rovided by the source or unknown."""
self.async_set_state_if_source(entry, state)
def async_set_state_if_source(
self, entry: DashboardEntry, state: EntryState
) -> None:
"""Set the state for an entry if provided by the source or unknown."""
if entry.state.source in (
EntryStateSource.UNKNOWN,
state.source,
):
self.async_set_state(entry, state)
def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None:
"""Set the state for an entry."""
if entry.state == state:
return
entry.state = state
self._dashboard.bus.async_fire(
DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
)
async def async_request_update_entries(self) -> None:
"""Request an update of the dashboard entries from disk.
If an update is already in progress, this will do nothing.
"""
if self._update_lock.locked():
_LOGGER.debug("Dashboard entries are already being updated")
return
await self.async_update_entries()
async def async_update_entries(self) -> None:
"""Update the dashboard entries from disk."""
async with self._update_lock:
await self._async_update_entries()
def _load_entries(
self, entries: dict[DashboardEntry, DashboardCacheKeyType]
) -> None:
"""Load all entries from disk."""
for entry, cache_key in entries.items():
_LOGGER.debug(
"Loading dashboard entry %s because cache key changed: %s",
entry.path,
cache_key,
)
entry.load_from_disk(cache_key)
async def _async_update_entries(self) -> list[DashboardEntry]:
"""Sync the dashboard entries from disk."""
_LOGGER.debug("Updating dashboard entries")
# At some point it would be nice to use watchdog to avoid polling
path_to_cache_key = await self._loop.run_in_executor(
None, self._get_path_to_cache_key
)
entries = self._entries
name_to_entry = self._name_to_entry
added: dict[DashboardEntry, DashboardCacheKeyType] = {}
updated: dict[DashboardEntry, DashboardCacheKeyType] = {}
removed: set[DashboardEntry] = {
entry
for filename, entry in entries.items()
if filename not in path_to_cache_key
}
original_names: dict[DashboardEntry, str] = {}
for path, cache_key in path_to_cache_key.items():
if not (entry := entries.get(path)):
entry = DashboardEntry(path, cache_key)
added[entry] = cache_key
continue
if entry.cache_key != cache_key:
updated[entry] = cache_key
original_names[entry] = entry.name
if added or updated:
await self._loop.run_in_executor(
None, self._load_entries, {**added, **updated}
)
bus = self._dashboard.bus
for entry in added:
entries[entry.path] = entry
name_to_entry[entry.name].add(entry)
bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry})
for entry in removed:
del entries[entry.path]
name_to_entry[entry.name].discard(entry)
bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry})
for entry in updated:
if (original_name := original_names[entry]) != (current_name := entry.name):
name_to_entry[original_name].discard(entry)
name_to_entry[current_name].add(entry)
bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry})
def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]:
"""Return a dict of path to cache key."""
path_to_cache_key: dict[Path, DashboardCacheKeyType] = {}
#
# The cache key is (inode, device, mtime, size)
# which allows us to avoid locking since it ensures
# every iteration of this call will always return the newest
# items from disk at the cost of a stat() call on each
# file which is much faster than reading the file
# for the cache hit case which is the common case.
#
for file in util.list_yaml_files([self._config_dir]):
try:
# Prefer the json storage path if it exists
stat = ext_storage_path(file.name).stat()
except OSError:
try:
# Fallback to the yaml file if the storage
# file does not exist or could not be generated
stat = file.stat()
except OSError:
# File was deleted, ignore
continue
path_to_cache_key[file] = (
stat.st_ino,
stat.st_dev,
stat.st_mtime,
stat.st_size,
)
return path_to_cache_key
def async_schedule_storage_json_update(self, filename: str) -> None:
"""Schedule a task to update the storage JSON file."""
self._dashboard.async_create_background_task(
async_run_system_command(
[*DASHBOARD_COMMAND, "compile", "--only-generate", filename]
)
)
class DashboardEntry:
"""Represents a single dashboard entry.
This class is thread-safe and read-only.
"""
__slots__ = (
"path",
"filename",
"_storage_path",
"cache_key",
"storage",
"state",
"_to_dict",
)
def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None:
"""Initialize the DashboardEntry."""
self.path = path
self.filename: str = path.name
self._storage_path = ext_storage_path(self.filename)
self.cache_key = cache_key
self.storage: StorageJSON | None = None
self.state = UNKNOWN_STATE
self._to_dict: dict[str, Any] | None = None
def __repr__(self) -> str:
"""Return the representation of this entry."""
return (
f"DashboardEntry(path={self.path} "
f"address={self.address} "
f"web_port={self.web_port} "
f"name={self.name} "
f"no_mdns={self.no_mdns} "
f"state={self.state} "
")"
)
def to_dict(self) -> dict[str, Any]:
"""Return a dict representation of this entry.
The dict includes the loaded configuration but not
the current state of the entry.
"""
if self._to_dict is None:
self._to_dict = {
"name": self.name,
"friendly_name": self.friendly_name,
"configuration": self.filename,
"loaded_integrations": sorted(self.loaded_integrations),
"deployed_version": self.update_old,
"current_version": self.update_new,
"path": str(self.path),
"comment": self.comment,
"address": self.address,
"web_port": self.web_port,
"target_platform": self.target_platform,
}
return self._to_dict
def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None:
"""Load this entry from disk."""
self.storage = StorageJSON.load(self._storage_path)
self._to_dict = None
#
# Currently StorageJSON.load() will return None if the file does not exist
#
# StorageJSON currently does not provide an updated cache key so we use the
# one that is passed in.
#
# The cache key was read from the disk moments ago and may be stale but
# it does not matter since we are polling anyways, and the next call to
# async_update_entries() will load it again in the extremely rare case that
# it changed between the two calls.
#
if cache_key:
self.cache_key = cache_key
@property
def address(self) -> str | None:
"""Return the address of this entry."""
if self.storage is None:
return None
return self.storage.address
@property
def no_mdns(self) -> bool | None:
"""Return the no_mdns of this entry."""
if self.storage is None:
return None
return self.storage.no_mdns
@property
def web_port(self) -> int | None:
"""Return the web port of this entry."""
if self.storage is None:
return None
return self.storage.web_port
@property
def name(self) -> str:
"""Return the name of this entry."""
if self.storage is None:
return self.filename.replace(".yml", "").replace(".yaml", "")
return self.storage.name
@property
def friendly_name(self) -> str:
"""Return the friendly name of this entry."""
if self.storage is None:
return self.name
return self.storage.friendly_name
@property
def comment(self) -> str | None:
"""Return the comment of this entry."""
if self.storage is None:
return None
return self.storage.comment
@property
def target_platform(self) -> str | None:
"""Return the target platform of this entry."""
if self.storage is None:
return None
return self.storage.target_platform
@property
def update_available(self) -> bool:
"""Return if an update is available for this entry."""
if self.storage is None:
return True
return self.update_old != self.update_new
@property
def update_old(self) -> str:
if self.storage is None:
return ""
return self.storage.esphome_version or ""
@property
def update_new(self) -> str:
return const.__version__
@property
def loaded_integrations(self) -> set[str]:
if self.storage is None:
return []
return self.storage.loaded_integrations

View File

@@ -1,76 +0,0 @@
"""Data models and builders for the dashboard."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
if TYPE_CHECKING:
from esphome.zeroconf import DiscoveredImport
from .core import ESPHomeDashboard
from .entries import DashboardEntry
class ImportableDeviceDict(TypedDict):
"""Dictionary representation of an importable device."""
name: str
friendly_name: str | None
package_import_url: str
project_name: str
project_version: str
network: str
ignored: bool
class ConfiguredDeviceDict(TypedDict, total=False):
"""Dictionary representation of a configured device."""
name: str
friendly_name: str | None
configuration: str
loaded_integrations: list[str] | None
deployed_version: str | None
current_version: str | None
path: str
comment: str | None
address: str | None
web_port: int | None
target_platform: str | None
class DeviceListResponse(TypedDict):
"""Response for device list API."""
configured: list[ConfiguredDeviceDict]
importable: list[ImportableDeviceDict]
def build_importable_device_dict(
dashboard: ESPHomeDashboard, discovered: DiscoveredImport
) -> ImportableDeviceDict:
"""Build the importable device dictionary."""
return ImportableDeviceDict(
name=discovered.device_name,
friendly_name=discovered.friendly_name,
package_import_url=discovered.package_import_url,
project_name=discovered.project_name,
project_version=discovered.project_version,
network=discovered.network,
ignored=discovered.device_name in dashboard.ignored_devices,
)
def build_device_list_response(
dashboard: ESPHomeDashboard, entries: list[DashboardEntry]
) -> DeviceListResponse:
"""Build the device list response data."""
configured = {entry.name for entry in entries}
return DeviceListResponse(
configured=[entry.to_dict() for entry in entries],
importable=[
build_importable_device_dict(dashboard, res)
for res in dashboard.import_result.values()
if res.device_name not in configured
],
)

View File

@@ -1,101 +0,0 @@
from __future__ import annotations
import hmac
import os
from pathlib import Path
from typing import Any
from esphome.core import CORE
from esphome.helpers import get_bool_env
from .util.password import password_hash
# Sentinel file name used for CORE.config_path when dashboard initializes.
# This ensures .parent returns the config directory instead of root.
_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml"
class DashboardSettings:
"""Settings for the dashboard."""
__slots__ = (
"config_dir",
"password_hash",
"username",
"using_password",
"on_ha_addon",
"cookie_secret",
"absolute_config_dir",
"verbose",
)
def __init__(self) -> None:
"""Initialize the dashboard settings."""
self.config_dir: Path = None
self.password_hash: bytes = b""
self.username: str = ""
self.using_password: bool = False
self.on_ha_addon: bool = False
self.cookie_secret: str | None = None
self.absolute_config_dir: Path | None = None
self.verbose: bool = False
def parse_args(self, args: Any) -> None:
"""Parse the arguments."""
self.on_ha_addon: bool = args.ha_addon
password = args.password or os.getenv("PASSWORD") or ""
if not self.on_ha_addon:
self.username = args.username or os.getenv("USERNAME") or ""
self.using_password = bool(password)
if self.using_password:
self.password_hash = password_hash(password)
self.config_dir = Path(args.configuration)
self.absolute_config_dir = self.config_dir.resolve()
self.verbose = args.verbose
# Set to a sentinel file so .parent gives us the config directory.
# Previously this was `os.path.join(self.config_dir, ".")` which worked because
# os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent
# normalizes to Path("/config") first, then .parent returns Path("/"), breaking
# secret resolution. Using a sentinel file ensures .parent gives the correct directory.
CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE
@property
def relative_url(self) -> str:
return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/"
@property
def status_use_mqtt(self) -> bool:
return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT")
@property
def using_ha_addon_auth(self) -> bool:
if not self.on_ha_addon:
return False
return not get_bool_env("DISABLE_HA_AUTHENTICATION")
@property
def using_auth(self) -> bool:
return self.using_password or self.using_ha_addon_auth
@property
def streamer_mode(self) -> bool:
return get_bool_env("ESPHOME_STREAMER_MODE")
def check_password(self, username: str, password: str) -> bool:
if not self.using_auth:
return True
# Compare in constant running time (to prevent timing attacks)
username_matches = hmac.compare_digest(
username.encode("utf-8"), self.username.encode("utf-8")
)
password_matches = hmac.compare_digest(
self.password_hash, password_hash(password)
)
return username_matches and password_matches
def rel_path(self, *args: Any) -> Path:
"""Return a path relative to the ESPHome config folder."""
joined_path = self.config_dir / Path(*args)
# Raises ValueError if not relative to ESPHome config folder
joined_path.resolve().relative_to(self.absolute_config_dir)
return joined_path

View File

@@ -1,170 +0,0 @@
from __future__ import annotations
import asyncio
import logging
import typing
from zeroconf import AddressResolver, IPVersion
from esphome.address_cache import normalize_hostname
from esphome.zeroconf import (
ESPHOME_SERVICE_TYPE,
AsyncEsphomeZeroconf,
DashboardBrowser,
DashboardImportDiscovery,
DashboardStatus,
DiscoveredImport,
)
from ..const import SENTINEL, DashboardEvent
from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state
from ..models import build_importable_device_dict
if typing.TYPE_CHECKING:
from ..core import ESPHomeDashboard
_LOGGER = logging.getLogger(__name__)
class MDNSStatus:
"""Class that updates the mdns status."""
def __init__(self, dashboard: ESPHomeDashboard) -> None:
"""Initialize the MDNSStatus class."""
super().__init__()
self.aiozc: AsyncEsphomeZeroconf | None = None
# This is the current mdns state for each host (True, False, None)
self.host_mdns_state: dict[str, bool | None] = {}
self._loop = asyncio.get_running_loop()
self.dashboard = dashboard
def async_setup(self) -> bool:
"""Set up the MDNSStatus class."""
try:
self.aiozc = AsyncEsphomeZeroconf()
except OSError as e:
_LOGGER.warning(
"Failed to initialize zeroconf, will fallback to ping: %s", e
)
return False
return True
async def async_resolve_host(self, host_name: str) -> list[str] | None:
"""Resolve a host name to an address in a thread-safe manner."""
if aiozc := self.aiozc:
return await aiozc.async_resolve_host(host_name)
return None
def get_cached_addresses(self, host_name: str) -> list[str] | None:
"""Get cached addresses for a host without triggering resolution.
Returns None if not in cache or no zeroconf available.
"""
if not self.aiozc:
_LOGGER.debug("No zeroconf instance available for %s", host_name)
return None
# Normalize hostname and get the base name
normalized = normalize_hostname(host_name)
base_name = normalized.partition(".")[0]
# Try to load from zeroconf cache without triggering resolution
resolver_name = f"{base_name}.local."
info = AddressResolver(resolver_name)
# Let zeroconf use its own current time for cache checking
if info.load_from_cache(self.aiozc.zeroconf):
addresses = info.parsed_scoped_addresses(IPVersion.All)
_LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses)
return addresses
_LOGGER.debug("Not found in zeroconf cache: %s", resolver_name)
return None
def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None:
"""Handle importable device updates."""
if discovered is None:
# Device removed
self.dashboard.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name}
)
else:
# Device added
self.dashboard.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_ADDED,
{"device": build_importable_device_dict(self.dashboard, discovered)},
)
async def async_refresh_hosts(self) -> None:
"""Refresh the hosts to track."""
dashboard = self.dashboard
host_mdns_state = self.host_mdns_state
entries = dashboard.entries
poll_names: dict[str, set[DashboardEntry]] = {}
for entry in entries.async_all():
if entry.no_mdns:
continue
# If we just adopted/imported this host, we likely
# already have a state for it, so we should make sure
# to set it so the dashboard shows it as online
if entry.loaded_integrations and "api" not in entry.loaded_integrations:
# No api available so we have to poll since
# the device won't respond to a request to ._esphomelib._tcp.local.
poll_names.setdefault(entry.name, set()).add(entry)
elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL:
self._async_set_state(entry, online)
if poll_names and self.aiozc:
results = await asyncio.gather(
*(self.aiozc.async_resolve_host(name) for name in poll_names)
)
for name, address_list in zip(poll_names, results, strict=True):
result = bool(address_list)
host_mdns_state[name] = result
for entry in poll_names[name]:
self._async_set_state(entry, result)
def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None:
"""Set the state of an entry."""
state = bool_to_entry_state(result, EntryStateSource.MDNS)
if result:
# If we can reach it via mDNS, we always set it online
# since its the fastest source if its working
self.dashboard.entries.async_set_state(entry, state)
else:
# However if we can't reach it via mDNS
# we only set it to offline if the state is unknown
# or from mDNS
self.dashboard.entries.async_set_state_if_source(entry, state)
async def async_run(self) -> None:
"""Run the mdns status."""
dashboard = self.dashboard
entries = dashboard.entries
host_mdns_state = self.host_mdns_state
def on_update(dat: dict[str, bool | None]) -> None:
"""Update the entry state."""
for name, result in dat.items():
host_mdns_state[name] = result
if matching_entries := entries.get_by_name(name):
for entry in matching_entries:
self._async_set_state(entry, result)
stat = DashboardStatus(on_update)
imports = DashboardImportDiscovery(self._on_import_update)
dashboard.import_result = imports.import_state
browser = DashboardBrowser(
self.aiozc.zeroconf,
ESPHOME_SERVICE_TYPE,
[stat.browser_callback, imports.browser_callback],
)
ping_request = dashboard.ping_request
while not dashboard.stop_event.is_set():
await self.async_refresh_hosts()
await ping_request.wait()
ping_request.clear()
await browser.async_cancel()
await self.aiozc.async_close()
self.aiozc = None

View File

@@ -1,78 +0,0 @@
from __future__ import annotations
import binascii
import json
import os
import threading
import typing
from esphome import mqtt
from ..entries import EntryStateSource, bool_to_entry_state
if typing.TYPE_CHECKING:
from ..core import ESPHomeDashboard
class MqttStatusThread(threading.Thread):
"""Status thread to get the status of the devices via MQTT."""
def __init__(self, dashboard: ESPHomeDashboard) -> None:
"""Initialize the status thread."""
super().__init__()
self.dashboard = dashboard
def run(self) -> None:
"""Run the status thread."""
dashboard = self.dashboard
entries = dashboard.entries
current_entries = entries.all()
config = mqtt.config_from_env()
topic = "esphome/discover/#"
def on_message(client, userdata, msg):
payload = msg.payload.decode(errors="backslashreplace")
if len(payload) > 0:
data = json.loads(payload)
if "name" not in data:
return
if matching_entries := entries.get_by_name(data["name"]):
for entry in matching_entries:
# Only override state if we don't have a state from another source
# or we have a state from MQTT and the device is reachable
entries.set_state_if_online_or_source(
entry, bool_to_entry_state(True, EntryStateSource.MQTT)
)
def on_connect(client, userdata, flags, return_code):
client.publish("esphome/discover", None, retain=False)
mqttid = str(binascii.hexlify(os.urandom(6)).decode())
client = mqtt.prepare(
config,
[topic],
on_message,
on_connect,
None,
None,
f"esphome-dashboard-{mqttid}",
)
client.loop_start()
while not dashboard.stop_event.wait(2):
current_entries = entries.all()
# will be set to true on on_message
for entry in current_entries:
# Only override state if we don't have a state from another source
entries.set_state_if_source(
entry, bool_to_entry_state(False, EntryStateSource.MQTT)
)
client.publish("esphome/discover", None, retain=False)
dashboard.mqtt_ping_request.wait()
dashboard.mqtt_ping_request.clear()
client.disconnect()
client.loop_stop()

View File

@@ -1,151 +0,0 @@
from __future__ import annotations
import asyncio
import logging
import time
import typing
from typing import cast
from icmplib import Host, SocketPermissionError, async_ping
from ..const import MAX_EXECUTOR_WORKERS
from ..entries import (
DashboardEntry,
EntryState,
EntryStateSource,
ReachableState,
bool_to_entry_state,
)
from ..util.itertools import chunked
if typing.TYPE_CHECKING:
from ..core import ESPHomeDashboard
_LOGGER = logging.getLogger(__name__)
GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2)
DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING)
MIN_PING_INTERVAL = 5 # ensure we don't ping too often
class PingStatus:
def __init__(self, dashboard: ESPHomeDashboard) -> None:
"""Initialize the PingStatus class."""
super().__init__()
self._loop = asyncio.get_running_loop()
self.dashboard = dashboard
async def async_run(self) -> None:
"""Run the ping status."""
dashboard = self.dashboard
entries = dashboard.entries
privileged = await _can_use_icmp_lib_with_privilege()
if privileged is None:
_LOGGER.warning("Cannot use icmplib because privileges are insufficient")
return
while not dashboard.stop_event.is_set():
# Only ping if the dashboard is open
await dashboard.ping_request.wait()
dashboard.ping_request.clear()
iteration_start = time.monotonic()
current_entries = dashboard.entries.async_all()
to_ping: list[DashboardEntry] = []
for entry in current_entries:
if entry.address is None:
# No address or we already have a state from another source
# so no need to ping
continue
if (
entry.state.reachable is ReachableState.ONLINE
and entry.state.source
not in (EntryStateSource.PING, EntryStateSource.UNKNOWN)
):
# If we already have a state from another source and
# it's online, we don't need to ping
continue
to_ping.append(entry)
# Resolve DNS for all entries
entries_with_addresses: dict[DashboardEntry, list[str]] = {}
for ping_group in chunked(to_ping, GROUP_SIZE):
ping_group = cast(list[DashboardEntry], ping_group)
now_monotonic = time.monotonic()
dns_results = await asyncio.gather(
*(
dashboard.dns_cache.async_resolve(entry.address, now_monotonic)
for entry in ping_group
),
return_exceptions=True,
)
for entry, result in zip(ping_group, dns_results, strict=True):
if isinstance(result, Exception):
# Only update state if its unknown or from ping
# so we don't mark it as offline if we have a state
# from mDNS or MQTT
entries.async_set_state_if_source(entry, DNS_FAILURE_STATE)
continue
if isinstance(result, BaseException):
raise result
entries_with_addresses[entry] = result
# Ping all entries with valid addresses
for ping_group in chunked(entries_with_addresses.items(), GROUP_SIZE):
entry_addresses = cast(tuple[DashboardEntry, list[str]], ping_group)
results = await asyncio.gather(
*(
async_ping(addresses[0], privileged=privileged)
for _, addresses in entry_addresses
),
return_exceptions=True,
)
for entry_address, result in zip(entry_addresses, results, strict=True):
if isinstance(result, Exception):
ping_result = False
elif isinstance(result, BaseException):
raise result
else:
host: Host = result
ping_result = host.is_alive
entry: DashboardEntry = entry_address[0]
# If we can reach it via ping, we always set it
# online, however if we can't reach it via ping
# we only set it to offline if the state is unknown
# or from ping
entries.async_set_state_if_online_or_source(
entry,
bool_to_entry_state(ping_result, EntryStateSource.PING),
)
if not dashboard.stop_event.is_set():
iteration_duration = time.monotonic() - iteration_start
if iteration_duration < MIN_PING_INTERVAL:
await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration)
async def _can_use_icmp_lib_with_privilege() -> None | bool:
"""Verify we can create a raw socket."""
try:
await async_ping("127.0.0.1", count=0, timeout=0, privileged=True)
except SocketPermissionError:
try:
await async_ping("127.0.0.1", count=0, timeout=0, privileged=False)
except SocketPermissionError:
_LOGGER.debug(
"Cannot use icmplib because privileges are insufficient to create the"
" socket"
)
return None
_LOGGER.debug("Using icmplib in privileged=False mode")
return False
_LOGGER.debug("Using icmplib in privileged=True mode")
return True

View File

@@ -1,22 +0,0 @@
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)), [])

View File

@@ -1,11 +0,0 @@
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()

View File

@@ -1,31 +0,0 @@
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

View File

@@ -1,15 +0,0 @@
"""Back-compat shim for ``friendly_name_slugify``.
The function moved to :mod:`esphome.helpers` so it survives the legacy
dashboard's eventual removal — see the
``esphome.helpers.friendly_name_slugify`` docstring. This module
re-exports the name so existing
``from esphome.dashboard.util.text import friendly_name_slugify``
imports keep working while downstream consumers migrate.
"""
from __future__ import annotations
from esphome.helpers import friendly_name_slugify
__all__ = ["friendly_name_slugify"]

View File

@@ -1,1645 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import binascii
from collections.abc import Callable, Iterable
import contextlib
import datetime
import functools
from functools import partial
import gzip
import hashlib
import importlib
import json
import logging
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import threading
import time
from typing import TYPE_CHECKING, Any, TypeVar
from urllib.parse import urlparse
import tornado
import tornado.concurrent
import tornado.gen
import tornado.httpserver
import tornado.httputil
import tornado.ioloop
import tornado.iostream
from tornado.log import access_log
import tornado.netutil
import tornado.process
import tornado.queues
import tornado.web
import tornado.websocket
import voluptuous as vol
import yaml
from yaml.nodes import Node
from esphome import const, yaml_util
from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses
from esphome.platformio import toolchain
from esphome.storage_json import (
StorageJSON,
archive_storage_path,
ext_storage_path,
trash_storage_path,
)
from esphome.util import get_serial_ports, shlex_quote
from esphome.yaml_util import FastestAvailableSafeLoader
from ..helpers import write_file
from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent
from .core import DASHBOARD, ESPHomeDashboard, Event
from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool
from .models import build_device_list_response
from .util.subprocess import async_run_system_command
from .util.text import friendly_name_slugify
if TYPE_CHECKING:
from requests import Response
_LOGGER = logging.getLogger(__name__)
ENV_DEV = "ESPHOME_DASHBOARD_DEV"
COOKIE_AUTHENTICATED_YES = b"yes"
AUTH_COOKIE_NAME = "authenticated"
settings = DASHBOARD.settings
def template_args() -> dict[str, Any]:
version = const.__version__
if "b" in version:
docs_link = "https://beta.esphome.io/"
elif "dev" in version:
docs_link = "https://next.esphome.io/"
else:
docs_link = "https://www.esphome.io/"
return {
"version": version,
"docs_link": docs_link,
"get_static_file_url": get_static_file_url,
"relative_url": settings.relative_url,
"streamer_mode": settings.streamer_mode,
"config_dir": settings.config_dir,
}
T = TypeVar("T", bound=Callable[..., Any])
def authenticated(func: T) -> T:
@functools.wraps(func)
def decorator(self, *args: Any, **kwargs: Any):
if not is_authenticated(self):
self.redirect("./login")
return None
return func(self, *args, **kwargs)
return decorator
def is_authenticated(handler: BaseHandler) -> bool:
"""Check if the request is authenticated."""
if settings.on_ha_addon:
# Handle ingress - disable auth on ingress port
# X-HA-Ingress is automatically stripped on the non-ingress server in nginx
header = handler.request.headers.get("X-HA-Ingress", "NO")
if str(header) == "YES":
return True
if settings.using_auth:
if auth_header := handler.request.headers.get("Authorization"):
assert isinstance(auth_header, str)
if auth_header.startswith("Basic "):
try:
auth_decoded = base64.b64decode(auth_header[6:]).decode()
username, password = auth_decoded.split(":", 1)
except (binascii.Error, ValueError, UnicodeDecodeError):
return False
return settings.check_password(username, password)
return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES
return True
def bind_config(func):
def decorator(self, *args, **kwargs):
configuration = self.get_argument("configuration")
kwargs = kwargs.copy()
kwargs["configuration"] = configuration
return func(self, *args, **kwargs)
return decorator
# pylint: disable=abstract-method
class BaseHandler(tornado.web.RequestHandler):
pass
def websocket_class(cls):
# pylint: disable=protected-access
if not hasattr(cls, "_message_handlers"):
cls._message_handlers = {}
for method in cls.__dict__.values():
if hasattr(method, "_message_handler"):
cls._message_handlers[method._message_handler] = method
return cls
def websocket_method(name):
def wrap(fn):
# pylint: disable=protected-access
fn._message_handler = name
return fn
return wrap
class CheckOriginMixin:
"""Mixin to handle WebSocket origin checks for reverse proxy setups."""
def check_origin(self, origin: str) -> bool:
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
@websocket_class
class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""Base class for ESPHome websocket commands."""
def __init__(
self,
application: tornado.web.Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any,
) -> None:
"""Initialize the websocket."""
super().__init__(application, request, **kwargs)
self._proc = None
self._queue = None
self._is_closed = False
# Windows doesn't support non-blocking pipes,
# use Popen() with a reading thread instead
self._use_popen = os.name == "nt"
def open(self, *args: str, **kwargs: str) -> None:
"""Handle new WebSocket connection."""
# Ensure messages from the subprocess are sent immediately
# to avoid a 200-500ms delay when nodelay is not set.
self.set_nodelay(True)
@authenticated
async def on_message( # pylint: disable=invalid-overridden-method
self, message: str
) -> None:
# Since tornado 4.5, on_message is allowed to be a coroutine
# Messages are always JSON, 500 when not
json_message = json.loads(message)
type_ = json_message["type"]
# pylint: disable=no-member
handlers = type(self)._message_handlers
if type_ not in handlers:
_LOGGER.warning("Requested unknown message type %s", type_)
return
await handlers[type_](self, json_message)
@websocket_method("spawn")
async def handle_spawn(self, json_message: dict[str, Any]) -> None:
if self._proc is not None:
# spawn can only be called once
return
command = await self.build_command(json_message)
_LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command))
if self._use_popen:
self._queue = tornado.queues.Queue()
# pylint: disable=consider-using-with
self._proc = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=False,
)
stdout_thread = threading.Thread(target=self._stdout_thread)
stdout_thread.daemon = True
stdout_thread.start()
else:
self._proc = tornado.process.Subprocess(
command,
stdout=tornado.process.Subprocess.STREAM,
stderr=subprocess.STDOUT,
stdin=tornado.process.Subprocess.STREAM,
close_fds=False,
)
self._proc.set_exit_callback(self._proc_on_exit)
tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout)
@property
def is_process_active(self) -> bool:
return self._proc is not None and self._proc.returncode is None
@websocket_method("stdin")
async def handle_stdin(self, json_message: dict[str, Any]) -> None:
if not self.is_process_active:
return
text: str = json_message["data"]
data = text.encode("utf-8", "replace")
_LOGGER.debug("< stdin: %s", data)
self._proc.stdin.write(data)
@tornado.gen.coroutine
def _redirect_stdout(self) -> None:
reg = b"[\n\r]"
while True:
try:
if self._use_popen:
data: bytes = yield self._queue.get()
if data is None:
self._proc_on_exit(self._proc.poll())
break
else:
data: bytes = yield self._proc.stdout.read_until_regex(reg)
except tornado.iostream.StreamClosedError:
break
text = data.decode("utf-8", "replace")
_LOGGER.debug("> stdout: %s", text)
self.write_message({"event": "line", "data": text})
def _stdout_thread(self) -> None:
if not self._use_popen:
return
line = b""
cr = False
while True:
data = self._proc.stdout.read(1)
if data:
if data == b"\r":
cr = True
elif data == b"\n":
self._queue.put_nowait(line + b"\n")
line = b""
cr = False
elif cr:
self._queue.put_nowait(line + b"\r")
line = data
cr = False
else:
line += data
if self._proc.poll() is not None:
break
self._proc.wait(1.0)
self._queue.put_nowait(None)
def _proc_on_exit(self, returncode: int) -> None:
if not self._is_closed:
# Check if the proc was not forcibly closed
_LOGGER.info("Process exited with return code %s", returncode)
self.write_message({"event": "exit", "code": returncode})
self.close()
def on_close(self) -> None:
# Check if proc exists (if 'start' has been run)
if self.is_process_active:
_LOGGER.debug("Terminating process")
if self._use_popen:
self._proc.terminate()
else:
self._proc.proc.terminate()
# Shutdown proc on WS close
self._is_closed = True
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
raise NotImplementedError
def build_cache_arguments(
entry: DashboardEntry | None,
dashboard: ESPHomeDashboard,
now: float,
) -> list[str]:
"""Build cache arguments for passing to CLI.
Args:
entry: Dashboard entry for the configuration
dashboard: Dashboard instance with cache access
now: Current monotonic time for DNS cache expiry checks
Returns:
List of cache arguments to pass to CLI
"""
cache_args: list[str] = []
if not entry:
return cache_args
_LOGGER.debug(
"Building cache for entry (address=%s, name=%s)",
entry.address,
entry.name,
)
def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None:
"""Add a cache entry to the command arguments."""
if not addresses:
return
normalized = hostname.rstrip(".").lower()
cache_args.extend(
[
f"--{cache_type}-address-cache",
f"{normalized}={','.join(sort_ip_addresses(addresses))}",
]
)
# Check entry.address for cached addresses
if use_address := entry.address:
if use_address.endswith(".local"):
# mDNS cache for .local addresses
if (mdns := dashboard.mdns_status) and (
cached := mdns.get_cached_addresses(use_address)
):
_LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached)
add_cache_entry(use_address, cached, "mdns")
# DNS cache for non-.local addresses
elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now):
_LOGGER.debug("DNS cache hit for %s: %s", use_address, cached)
add_cache_entry(use_address, cached, "dns")
# Check entry.name if we haven't already cached via address
# For mDNS devices, entry.name typically doesn't have .local suffix
if entry.name and not use_address:
mdns_name = (
f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name
)
if (mdns := dashboard.mdns_status) and (
cached := mdns.get_cached_addresses(mdns_name)
):
_LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached)
add_cache_entry(mdns_name, cached, "mdns")
return cache_args
class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
"""Base class for commands that require a port."""
async def build_device_command(
self, args: list[str], json_message: dict[str, Any]
) -> list[str]:
"""Build the command to run."""
dashboard = DASHBOARD
entries = dashboard.entries
configuration = json_message["configuration"]
config_file = settings.rel_path(configuration)
port = json_message["port"]
# Build cache arguments to pass to CLI
cache_args: list[str] = []
if (
port == "OTA" # pylint: disable=too-many-boolean-expressions
and (entry := entries.get(config_file))
and entry.loaded_integrations
and "api" in entry.loaded_integrations
):
cache_args = build_cache_arguments(entry, dashboard, time.monotonic())
# Cache arguments must come before the subcommand
cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port]
_LOGGER.debug("Built command: %s", cmd)
return cmd
class EsphomeLogsHandler(EsphomePortCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
"""Build the command to run."""
cmd = await self.build_device_command(["logs"], json_message)
if json_message.get("no_states"):
cmd.append("--no-states")
_LOGGER.debug("Built command: %s", cmd)
return cmd
class EsphomeRenameHandler(EsphomeCommandWebSocket):
old_name: str
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
self.old_name = json_message["configuration"]
return [
*DASHBOARD_COMMAND,
"rename",
config_file,
json_message["newName"],
]
def _proc_on_exit(self, returncode):
super()._proc_on_exit(returncode)
if returncode != 0:
return
# Remove the old ping result from the cache
entries = DASHBOARD.entries
if entry := entries.get(self.old_name):
entries.async_set_state(entry, UNKNOWN_STATE)
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
"""Build the command to run."""
return await self.build_device_command(["upload"], json_message)
class EsphomeRunHandler(EsphomePortCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
"""Build the command to run."""
return await self.build_device_command(["run"], json_message)
class EsphomeCompileHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
command = [*DASHBOARD_COMMAND, "compile"]
if json_message.get("only_generate", False):
command.append("--only-generate")
command.append(config_file)
return command
class EsphomeValidateHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
command = [*DASHBOARD_COMMAND, "config", config_file]
if not settings.streamer_mode:
command.append("--show-secrets")
return command
class EsphomeCleanMqttHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
return [*DASHBOARD_COMMAND, "clean-mqtt", config_file]
class EsphomeCleanAllHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
clean_build_dir = json_message.get("clean_build_dir", True)
if clean_build_dir:
return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir]
return [*DASHBOARD_COMMAND, "clean-all"]
class EsphomeCleanHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
config_file = settings.rel_path(json_message["configuration"])
return [*DASHBOARD_COMMAND, "clean", config_file]
class EsphomeVscodeHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"]
class EsphomeAceEditorHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir]
class EsphomeUpdateAllHandler(EsphomeCommandWebSocket):
async def build_command(self, json_message: dict[str, Any]) -> list[str]:
return [*DASHBOARD_COMMAND, "update-all", settings.config_dir]
# Dashboard polling constants
DASHBOARD_POLL_INTERVAL = 2 # seconds
DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds
DASHBOARD_ENTRIES_UPDATE_ITERATIONS = (
DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL
)
class DashboardSubscriber:
"""Manages dashboard event polling task lifecycle based on active subscribers."""
def __init__(self) -> None:
"""Initialize the dashboard subscriber."""
self._subscribers: set[DashboardEventsWebSocket] = set()
self._event_loop_task: asyncio.Task | None = None
self._refresh_event: asyncio.Event = asyncio.Event()
def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]:
"""Subscribe to dashboard updates and start event loop if needed."""
self._subscribers.add(subscriber)
if not self._event_loop_task or self._event_loop_task.done():
self._event_loop_task = asyncio.create_task(self._event_loop())
_LOGGER.info("Started dashboard event loop")
return partial(self._unsubscribe, subscriber)
def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None:
"""Unsubscribe from dashboard updates and stop event loop if no subscribers."""
self._subscribers.discard(subscriber)
if (
not self._subscribers
and self._event_loop_task
and not self._event_loop_task.done()
):
self._event_loop_task.cancel()
self._event_loop_task = None
_LOGGER.info("Stopped dashboard event loop - no subscribers")
def request_refresh(self) -> None:
"""Signal the polling loop to refresh immediately."""
self._refresh_event.set()
async def _event_loop(self) -> None:
"""Run the event polling loop while there are subscribers."""
dashboard = DASHBOARD
entries_update_counter = 0
while self._subscribers:
# Signal that we need ping updates (non-blocking)
dashboard.ping_request.set()
if settings.status_use_mqtt:
dashboard.mqtt_ping_request.set()
# Check if it's time to update entries or if refresh was requested
entries_update_counter += 1
if (
entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS
or self._refresh_event.is_set()
):
entries_update_counter = 0
await dashboard.entries.async_request_update_entries()
# Clear the refresh event if it was set
self._refresh_event.clear()
# Wait for either timeout or refresh event
try:
async with asyncio.timeout(DASHBOARD_POLL_INTERVAL):
await self._refresh_event.wait()
# If we get here, refresh was requested - continue loop immediately
except TimeoutError:
# Normal timeout - continue with regular polling
pass
# Global dashboard subscriber instance
DASHBOARD_SUBSCRIBER = DashboardSubscriber()
@websocket_class
class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""WebSocket handler for real-time dashboard events."""
_event_listeners: list[Callable[[], None]] | None = None
_dashboard_unsubscribe: Callable[[], None] | None = None
async def get(self, *args: str, **kwargs: str) -> None:
"""Handle WebSocket upgrade request."""
if not is_authenticated(self):
self.set_status(401)
self.finish("Unauthorized")
return
await super().get(*args, **kwargs)
async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method
"""Handle new WebSocket connection."""
# Ensure messages are sent immediately to avoid
# a 200-500ms delay when nodelay is not set.
self.set_nodelay(True)
# Update entries first
await DASHBOARD.entries.async_request_update_entries()
# Send initial state
self._send_initial_state()
# Subscribe to events
self._subscribe_to_events()
# Subscribe to dashboard updates
self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self)
_LOGGER.debug("Dashboard status WebSocket opened")
def _send_initial_state(self) -> None:
"""Send initial device list and ping status."""
entries = DASHBOARD.entries.async_all()
# Send initial state
self._safe_send_message(
{
"event": DashboardEvent.INITIAL_STATE,
"data": {
"devices": build_device_list_response(DASHBOARD, entries),
"ping": {
entry.filename: entry_state_to_bool(entry.state)
for entry in entries
},
},
}
)
def _subscribe_to_events(self) -> None:
"""Subscribe to dashboard events."""
async_add_listener = DASHBOARD.bus.async_add_listener
# Subscribe to all events
self._event_listeners = [
async_add_listener(
DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed
),
async_add_listener(
DashboardEvent.ENTRY_ADDED,
self._make_entry_handler(DashboardEvent.ENTRY_ADDED),
),
async_add_listener(
DashboardEvent.ENTRY_REMOVED,
self._make_entry_handler(DashboardEvent.ENTRY_REMOVED),
),
async_add_listener(
DashboardEvent.ENTRY_UPDATED,
self._make_entry_handler(DashboardEvent.ENTRY_UPDATED),
),
async_add_listener(
DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added
),
async_add_listener(
DashboardEvent.IMPORTABLE_DEVICE_REMOVED,
self._on_importable_removed,
),
]
def _on_entry_state_changed(self, event: Event) -> None:
"""Handle entry state change event."""
entry = event.data["entry"]
state = event.data["state"]
self._safe_send_message(
{
"event": DashboardEvent.ENTRY_STATE_CHANGED,
"data": {
"filename": entry.filename,
"name": entry.name,
"state": entry_state_to_bool(state),
},
}
)
def _make_entry_handler(
self, event_type: DashboardEvent
) -> Callable[[Event], None]:
"""Create an entry event handler."""
def handler(event: Event) -> None:
self._safe_send_message(
{"event": event_type, "data": {"device": event.data["entry"].to_dict()}}
)
return handler
def _on_importable_added(self, event: Event) -> None:
"""Handle importable device added event."""
# Don't send if device is already configured
device_name = event.data.get("device", {}).get("name")
if device_name and DASHBOARD.entries.get_by_name(device_name):
return
self._safe_send_message(
{"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data}
)
def _on_importable_removed(self, event: Event) -> None:
"""Handle importable device removed event."""
self._safe_send_message(
{"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data}
)
def _safe_send_message(self, message: dict[str, Any]) -> None:
"""Send a message to the WebSocket client, ignoring closed errors."""
with contextlib.suppress(tornado.websocket.WebSocketClosedError):
self.write_message(json.dumps(message))
def on_message(self, message: str) -> None:
"""Handle incoming WebSocket messages."""
_LOGGER.debug("WebSocket received message: %s", message)
try:
data = json.loads(message)
except json.JSONDecodeError as err:
_LOGGER.debug("Failed to parse WebSocket message: %s", err)
return
event = data.get("event")
_LOGGER.debug("WebSocket message event: %s", event)
if event == DashboardEvent.PING:
# Send pong response for client ping
_LOGGER.debug("Received client ping, sending pong")
self._safe_send_message({"event": DashboardEvent.PONG})
elif event == DashboardEvent.REFRESH:
# Signal the polling loop to refresh immediately
_LOGGER.debug("Received refresh request, signaling polling loop")
DASHBOARD_SUBSCRIBER.request_refresh()
def on_close(self) -> None:
"""Handle WebSocket close."""
# Unsubscribe from dashboard updates
if self._dashboard_unsubscribe:
self._dashboard_unsubscribe()
self._dashboard_unsubscribe = None
# Unsubscribe from events
for remove_listener in self._event_listeners or []:
remove_listener()
_LOGGER.debug("Dashboard status WebSocket closed")
class SerialPortRequestHandler(BaseHandler):
@authenticated
async def get(self) -> None:
ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports)
data = []
for port in ports:
desc = port.description
if port.path == "/dev/ttyAMA0":
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.path, "desc": desc})
data.append({"port": "OTA", "desc": "Over-The-Air"})
data.sort(key=lambda x: x["port"], reverse=True)
self.set_header("content-type", "application/json")
self.write(json.dumps(data))
class WizardRequestHandler(BaseHandler):
@authenticated
def post(self) -> None:
from esphome import wizard
kwargs = {
k: v
for k, v in json.loads(self.request.body.decode()).items()
if k
in (
"type",
"name",
"platform",
"board",
"ssid",
"psk",
"password",
"file_content",
)
}
if not kwargs["name"]:
self.set_status(422)
self.set_header("content-type", "application/json")
self.write(json.dumps({"error": "Name is required"}))
return
if "type" not in kwargs:
# Default to basic wizard type for backwards compatibility
kwargs["type"] = "basic"
kwargs["friendly_name"] = kwargs["name"]
kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"])
if kwargs["type"] == "basic":
kwargs["ota_password"] = secrets.token_hex(16)
noise_psk = secrets.token_bytes(32)
kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode()
elif kwargs["type"] == "upload":
try:
kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode(
"utf-8"
)
except (binascii.Error, UnicodeDecodeError):
self.set_status(422)
self.set_header("content-type", "application/json")
self.write(
json.dumps({"error": "The uploaded file is not correctly encoded."})
)
return
elif kwargs["type"] != "empty":
self.set_status(422)
self.set_header("content-type", "application/json")
self.write(
json.dumps(
{"error": f"Invalid wizard type specified: {kwargs['type']}"}
)
)
return
filename = f"{kwargs['name']}.yaml"
destination = settings.rel_path(filename)
# Check if destination file already exists
if destination.exists():
self.set_status(409) # Conflict status code
self.set_header("content-type", "application/json")
self.write(
json.dumps({"error": f"Configuration file '{filename}' already exists"})
)
self.finish()
return
success = wizard.wizard_write(path=destination, **kwargs)
if success:
self.set_status(200)
self.set_header("content-type", "application/json")
self.write(json.dumps({"configuration": filename}))
self.finish()
else:
self.set_status(500)
self.set_header("content-type", "application/json")
self.write(
json.dumps(
{"error": "Failed to write configuration, see logs for details"}
)
)
self.finish()
class ImportRequestHandler(BaseHandler):
@authenticated
def post(self) -> None:
from esphome.components.dashboard_import import import_config
dashboard = DASHBOARD
args = json.loads(self.request.body.decode())
try:
name = args["name"]
friendly_name = args.get("friendly_name")
encryption = args.get("encryption", False)
imported_device = next(
(
res
for res in dashboard.import_result.values()
if res.device_name == name
),
None,
)
if imported_device is not None:
network = imported_device.network
if friendly_name is None:
friendly_name = imported_device.friendly_name
else:
network = const.CONF_WIFI
import_config(
settings.rel_path(f"{name}.yaml"),
name,
friendly_name,
args["project_name"],
args["package_import_url"],
network,
encryption,
)
# Make sure the device gets marked online right away
dashboard.ping_request.set()
except FileExistsError:
self.set_status(500)
self.write("File already exists")
return
except ValueError as e:
_LOGGER.error(e)
self.set_status(422)
self.write("Invalid package url")
return
self.set_status(200)
self.set_header("content-type", "application/json")
self.write(json.dumps({"configuration": f"{name}.yaml"}))
self.finish()
class IgnoreDeviceRequestHandler(BaseHandler):
@authenticated
async def post(self) -> None:
dashboard = DASHBOARD
try:
args = json.loads(self.request.body.decode())
device_name = args["name"]
ignore = args["ignore"]
except (json.JSONDecodeError, KeyError):
self.set_status(400)
self.set_header("content-type", "application/json")
self.write(json.dumps({"error": "Invalid payload"}))
return
ignored_device = next(
(
res
for res in dashboard.import_result.values()
if res.device_name == device_name
),
None,
)
if ignored_device is None:
self.set_status(404)
self.set_header("content-type", "application/json")
self.write(json.dumps({"error": "Device not found"}))
return
if ignore:
dashboard.ignored_devices.add(ignored_device.device_name)
else:
dashboard.ignored_devices.discard(ignored_device.device_name)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, dashboard.save_ignored_devices)
self.set_status(204)
self.finish()
class DownloadListRequestHandler(BaseHandler):
@authenticated
@bind_config
async def get(self, configuration: str | None = None) -> None:
loop = asyncio.get_running_loop()
try:
downloads_json = await loop.run_in_executor(None, self._get, configuration)
except vol.Invalid as exc:
_LOGGER.exception("Error while fetching downloads", exc_info=exc)
self.send_error(404)
return
if downloads_json is None:
_LOGGER.error("Configuration %s not found", configuration)
self.send_error(404)
return
self.set_status(200)
self.set_header("content-type", "application/json")
self.write(downloads_json)
self.finish()
def _get(self, configuration: str | None = None) -> dict[str, Any] | None:
storage_path = ext_storage_path(configuration)
storage_json = StorageJSON.load(storage_path)
if storage_json is None:
return None
try:
config = yaml_util.load_yaml(settings.rel_path(configuration))
if const.CONF_EXTERNAL_COMPONENTS in config:
from esphome.components.external_components import (
do_external_components_pass,
)
do_external_components_pass(config)
except vol.Invalid:
_LOGGER.info("Could not parse `external_components`, skipping")
from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS
downloads: list[dict[str, Any]] = []
platform: str = storage_json.target_platform.lower()
if platform.upper() in ESP32_VARIANTS:
platform = "esp32"
elif platform in (
const.PLATFORM_RTL87XX,
const.PLATFORM_BK72XX,
const.PLATFORM_LN882X,
):
platform = "libretiny"
try:
module = importlib.import_module(f"esphome.components.{platform}")
get_download_types = module.get_download_types
except AttributeError as exc:
raise ValueError(f"Unknown platform {platform}") from exc
downloads = get_download_types(storage_json)
return json.dumps(downloads)
class DownloadBinaryRequestHandler(BaseHandler):
def _load_file(self, path: str, compressed: bool) -> bytes:
"""Load a file from disk and compress it if requested."""
with Path(path).open("rb") as f:
data = f.read()
if compressed:
return gzip.compress(data, 9)
return data
@authenticated
@bind_config
async def get(self, configuration: str | None = None) -> None:
"""Download a binary file."""
loop = asyncio.get_running_loop()
compressed = self.get_argument("compressed", "0") == "1"
storage_path = ext_storage_path(configuration)
storage_json = StorageJSON.load(storage_path)
if storage_json is None:
self.send_error(404)
return
# fallback to type=, but prioritize file=
file_name = self.get_argument("type", None)
file_name = self.get_argument("file", file_name)
if file_name is None or not file_name.strip():
self.send_error(400)
return
# get requested download name, or build it based on filename
download_name = self.get_argument(
"download",
f"{storage_json.name}-{file_name}",
)
if storage_json.firmware_bin_path is None:
self.send_error(404)
return
base_dir = storage_json.firmware_bin_path.parent.resolve()
path = base_dir.joinpath(file_name).resolve()
try:
path.relative_to(base_dir)
except ValueError:
self.send_error(403)
return
if not path.is_file():
args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)]
rc, stdout, _ = await async_run_system_command(args)
if rc != 0:
self.send_error(404 if rc == 2 else 500)
return
idedata = toolchain.IDEData(json.loads(stdout))
found = False
for image in idedata.extra_flash_images:
if image.path.as_posix().endswith(file_name):
path = image.path
download_name = file_name
found = True
break
if not found:
self.send_error(404)
return
download_name = download_name + ".gz" if compressed else download_name
self.set_header("Content-Type", "application/octet-stream")
self.set_header(
"Content-Disposition", f'attachment; filename="{download_name}"'
)
self.set_header("Cache-Control", "no-cache")
if not Path(path).is_file():
self.send_error(404)
return
data = await loop.run_in_executor(None, self._load_file, path, compressed)
self.write(data)
self.finish()
class EsphomeVersionHandler(BaseHandler):
@authenticated
def get(self) -> None:
self.set_header("Content-Type", "application/json")
self.write(json.dumps({"version": const.__version__}))
self.finish()
class ListDevicesHandler(BaseHandler):
@authenticated
async def get(self) -> None:
dashboard = DASHBOARD
await dashboard.entries.async_request_update_entries()
entries = dashboard.entries.async_all()
self.set_header("content-type", "application/json")
self.write(json.dumps(build_device_list_response(dashboard, entries)))
class MainRequestHandler(BaseHandler):
@authenticated
def get(self) -> None:
begin = bool(self.get_argument("begin", False))
if settings.using_password:
# Simply accessing the xsrf_token sets the cookie for us
self.xsrf_token # pylint: disable=pointless-statement # noqa: B018
else:
self.clear_cookie("_xsrf")
self.render(
"index.template.html",
begin=begin,
**template_args(),
login_enabled=settings.using_password,
)
class PrometheusServiceDiscoveryHandler(BaseHandler):
@authenticated
async def get(self) -> None:
dashboard = DASHBOARD
await dashboard.entries.async_request_update_entries()
entries = dashboard.entries.async_all()
self.set_header("content-type", "application/json")
sd = []
for entry in entries:
if entry.web_port is None:
continue
labels = {
"__meta_name": entry.name,
"__meta_esp_platform": entry.target_platform,
"__meta_esphome_version": entry.storage.esphome_version,
}
for integration in entry.storage.loaded_integrations:
labels[f"__meta_integration_{integration}"] = "true"
sd.append(
{
"targets": [
f"{entry.address}:{entry.web_port}",
],
"labels": labels,
}
)
self.write(json.dumps(sd))
class BoardsRequestHandler(BaseHandler):
@authenticated
def get(self, platform: str) -> None:
# filter all ESP32 variants by requested platform
if platform.startswith("esp32"):
from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS
boards = {
k: v
for k, v in ESP32_BOARDS.items()
if v[const.KEY_VARIANT] == platform.upper()
}
elif platform == const.PLATFORM_ESP8266:
from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS
boards = ESP8266_BOARDS
elif platform == const.PLATFORM_RP2040:
from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS
boards = RP2040_BOARDS
elif platform == const.PLATFORM_BK72XX:
from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS
boards = BK72XX_BOARDS
elif platform == const.PLATFORM_LN882X:
from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS
boards = LN882X_BOARDS
elif platform == const.PLATFORM_RTL87XX:
from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS
boards = RTL87XX_BOARDS
else:
raise ValueError(f"Unknown platform {platform}")
# map to a {board_name: board_title} dict
platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()}
# sort by board title
boards_items = sorted(platform_boards.items(), key=lambda item: item[1])
output = [{"items": dict(boards_items)}]
self.set_header("content-type", "application/json")
self.write(json.dumps(output))
class PingRequestHandler(BaseHandler):
@authenticated
def get(self) -> None:
dashboard = DASHBOARD
dashboard.ping_request.set()
if settings.status_use_mqtt:
dashboard.mqtt_ping_request.set()
self.set_header("content-type", "application/json")
self.write(
json.dumps(
{
entry.filename: entry_state_to_bool(entry.state)
for entry in dashboard.entries.async_all()
}
)
)
class InfoRequestHandler(BaseHandler):
@authenticated
@bind_config
async def get(self, configuration: str | None = None) -> None:
yaml_path = settings.rel_path(configuration)
dashboard = DASHBOARD
entry = dashboard.entries.get(yaml_path)
if not entry or entry.storage is None:
self.set_status(404)
return
self.set_header("content-type", "application/json")
self.write(entry.storage.to_json())
class EditRequestHandler(BaseHandler):
@authenticated
@bind_config
async def get(self, configuration: str | None = None) -> None:
"""Get the content of a file."""
if not configuration.endswith((".yaml", ".yml")):
self.send_error(404)
return
filename = settings.rel_path(configuration)
if filename.resolve().parent != settings.absolute_config_dir:
self.send_error(404)
return
loop = asyncio.get_running_loop()
content = await loop.run_in_executor(
None, self._read_file, filename, configuration
)
if content is not None:
self.set_header("Content-Type", "application/yaml")
self.write(content)
def _read_file(self, filename: str, configuration: str) -> bytes | None:
"""Read a file and return the content as bytes."""
try:
with Path(filename).open(encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
if configuration in const.SECRETS_FILES:
return ""
self.set_status(404)
return None
@authenticated
@bind_config
async def post(self, configuration: str | None = None) -> None:
"""Write the content of a file."""
if not configuration.endswith((".yaml", ".yml")):
self.send_error(404)
return
filename = settings.rel_path(configuration)
if filename.resolve().parent != settings.absolute_config_dir:
self.send_error(404)
return
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, write_file, filename, self.request.body)
# Ensure the StorageJSON is updated as well
DASHBOARD.entries.async_schedule_storage_json_update(filename)
self.set_status(200)
class ArchiveRequestHandler(BaseHandler):
@authenticated
@bind_config
def post(self, configuration: str | None = None) -> None:
config_file = settings.rel_path(configuration)
storage_path = ext_storage_path(configuration)
archive_path = archive_storage_path()
mkdir_p(archive_path)
shutil.move(config_file, archive_path / configuration)
storage_json = StorageJSON.load(storage_path)
if storage_json is not None and storage_json.build_path:
# Delete build folder (if exists)
shutil.rmtree(storage_json.build_path, ignore_errors=True)
class UnArchiveRequestHandler(BaseHandler):
@authenticated
@bind_config
def post(self, configuration: str | None = None) -> None:
config_file = settings.rel_path(configuration)
archive_path = archive_storage_path()
shutil.move(archive_path / configuration, config_file)
class LoginHandler(BaseHandler):
def get(self) -> None:
if is_authenticated(self):
self.redirect("./")
else:
self.render_login_page()
def render_login_page(self, error: str | None = None) -> None:
self.render(
"login.template.html",
error=error,
ha_addon=settings.using_ha_addon_auth,
has_username=bool(settings.username),
**template_args(),
)
def _make_supervisor_auth_request(self) -> Response:
"""Make a request to the supervisor auth endpoint."""
import requests
headers = {"X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN")}
data = {
"username": self.get_argument("username", ""),
"password": self.get_argument("password", ""),
}
return requests.post(
"http://supervisor/auth", headers=headers, json=data, timeout=30
)
async def post_ha_addon_login(self) -> None:
loop = asyncio.get_running_loop()
try:
req = await loop.run_in_executor(None, self._make_supervisor_auth_request)
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.warning("Error during Hass.io auth request: %s", err)
self.set_status(500)
self.render_login_page(error="Internal server error")
return
if req.status_code == 200:
self._set_authenticated()
self.redirect("/")
return
self.set_status(401)
self.render_login_page(error="Invalid username or password")
def _set_authenticated(self) -> None:
"""Set the authenticated cookie."""
self.set_secure_cookie(AUTH_COOKIE_NAME, COOKIE_AUTHENTICATED_YES)
def post_native_login(self) -> None:
username = self.get_argument("username", "")
password = self.get_argument("password", "")
if settings.check_password(username, password):
self._set_authenticated()
self.redirect("./")
return
error_str = (
"Invalid username or password" if settings.username else "Invalid password"
)
self.set_status(401)
self.render_login_page(error=error_str)
async def post(self):
if settings.using_ha_addon_auth:
await self.post_ha_addon_login()
else:
self.post_native_login()
class LogoutHandler(BaseHandler):
@authenticated
def get(self) -> None:
self.clear_cookie(AUTH_COOKIE_NAME)
self.redirect("./login")
class SecretKeysRequestHandler(BaseHandler):
@authenticated
def get(self) -> None:
filename = None
for secret_filename in const.SECRETS_FILES:
relative_filename = settings.rel_path(secret_filename)
if relative_filename.is_file():
filename = relative_filename
break
if filename is None:
self.send_error(404)
return
secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False))
self.set_header("content-type", "application/json")
self.write(json.dumps(secret_keys))
class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader):
def ignore_unknown(self, node: Node) -> str:
return f"{node.tag} {node.value}"
def construct_yaml_binary(self, node: Node) -> str:
return super().construct_yaml_binary(node).decode("ascii")
SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown)
SafeLoaderIgnoreUnknown.add_constructor(
"tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary
)
class JsonConfigRequestHandler(BaseHandler):
@authenticated
@bind_config
async def get(self, configuration: str | None = None) -> None:
filename = settings.rel_path(configuration)
if not filename.is_file():
self.send_error(404)
return
args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"]
rc, stdout, stderr = await async_run_system_command(args)
if rc != 0:
self.set_status(422)
self.write(stderr)
return
data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown)
self.set_header("content-type", "application/json")
self.write(json.dumps(data))
self.finish()
def get_base_frontend_path() -> Path:
if ENV_DEV not in os.environ:
import esphome_dashboard
return esphome_dashboard.where()
static_path = os.environ[ENV_DEV]
if not static_path.endswith("/"):
static_path += "/"
# This path can be relative, so resolve against the root or else templates don't work
path = Path.cwd() / static_path / "esphome_dashboard"
return path.resolve()
def get_static_path(*args: Iterable[str]) -> Path:
return get_base_frontend_path() / "static" / Path(*args)
@functools.cache
def get_static_file_url(name: str) -> str:
base = f"./static/{name}"
if ENV_DEV in os.environ:
return base
# Module imports can't deduplicate if stuff added to url
if name == "js/esphome/index.js":
import esphome_dashboard
return base.replace("index.js", esphome_dashboard.entrypoint())
path = get_static_path(name)
hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8]
return f"{base}?hash={hash_}"
def make_app(debug: bool | None = None) -> tornado.web.Application:
if debug is None:
debug = get_bool_env(ENV_DEV)
def log_function(handler: tornado.web.RequestHandler) -> None:
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()
# pylint: disable=protected-access
log_method(
"%d %s %.2fms",
handler.get_status(),
handler._request_summary(),
request_time,
)
class StaticFileHandler(tornado.web.StaticFileHandler):
def get_cache_time(
self, path: str, modified: datetime.datetime | None, mime_type: str
) -> int:
"""Override to customize cache control behavior."""
if debug:
return 0
# Assets that are hashed have ?hash= in the URL, all javascript
# filenames hashed so we can cache them for a long time
if "hash" in self.request.arguments or "/javascript" in mime_type:
return self.CACHE_MAX_AGE
return super().get_cache_time(path, modified, mime_type)
app_settings = {
"debug": debug,
"cookie_secret": settings.cookie_secret,
"log_function": log_function,
"websocket_ping_interval": 30.0,
"template_path": get_base_frontend_path(),
"xsrf_cookies": settings.using_password,
}
rel = settings.relative_url
return tornado.web.Application(
[
(f"{rel}", MainRequestHandler),
(f"{rel}login", LoginHandler),
(f"{rel}logout", LogoutHandler),
(f"{rel}logs", EsphomeLogsHandler),
(f"{rel}upload", EsphomeUploadHandler),
(f"{rel}run", EsphomeRunHandler),
(f"{rel}compile", EsphomeCompileHandler),
(f"{rel}validate", EsphomeValidateHandler),
(f"{rel}clean-mqtt", EsphomeCleanMqttHandler),
(f"{rel}clean-all", EsphomeCleanAllHandler),
(f"{rel}clean", EsphomeCleanHandler),
(f"{rel}vscode", EsphomeVscodeHandler),
(f"{rel}ace", EsphomeAceEditorHandler),
(f"{rel}update-all", EsphomeUpdateAllHandler),
(f"{rel}info", InfoRequestHandler),
(f"{rel}edit", EditRequestHandler),
(f"{rel}downloads", DownloadListRequestHandler),
(f"{rel}download.bin", DownloadBinaryRequestHandler),
(f"{rel}serial-ports", SerialPortRequestHandler),
(f"{rel}ping", PingRequestHandler),
(f"{rel}delete", ArchiveRequestHandler),
(f"{rel}undo-delete", UnArchiveRequestHandler),
(f"{rel}archive", ArchiveRequestHandler),
(f"{rel}unarchive", UnArchiveRequestHandler),
(f"{rel}wizard", WizardRequestHandler),
(f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}),
(f"{rel}devices", ListDevicesHandler),
(f"{rel}events", DashboardEventsWebSocket),
(f"{rel}import", ImportRequestHandler),
(f"{rel}secret_keys", SecretKeysRequestHandler),
(f"{rel}json-config", JsonConfigRequestHandler),
(f"{rel}rename", EsphomeRenameHandler),
(f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler),
(f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler),
(f"{rel}version", EsphomeVersionHandler),
(f"{rel}ignore-device", IgnoreDeviceRequestHandler),
],
**app_settings,
)
def start_web_server(
app: tornado.web.Application,
socket: str | None,
address: str | None,
port: int | None,
config_dir: str,
) -> None:
"""Start the web server listener."""
trash_path = trash_storage_path()
if trash_path.is_dir() and trash_path.exists():
_LOGGER.info("Renaming 'trash' folder to 'archive'")
archive_path = archive_storage_path()
shutil.move(trash_path, archive_path)
if socket is None:
_LOGGER.info(
"Starting dashboard web server on http://%s:%s and configuration dir %s...",
address,
port,
config_dir,
)
app.listen(port, address)
return
_LOGGER.info(
"Starting dashboard web server on unix socket %s and configuration dir %s...",
socket,
config_dir,
)
server = tornado.httpserver.HTTPServer(app)
socket = tornado.netutil.bind_unix_socket(socket, mode=0o666)
server.add_socket(socket)

View File

@@ -124,14 +124,8 @@ def slugify(value: str) -> str:
def friendly_name_slugify(value: str) -> str:
"""Convert a friendly name to a slug with dashes instead of underscores.
Used by:
- esphome.dashboard.web_server (legacy dashboard)
- device-builder (esphome/device-builder) — slugifies friendly names
into the YAML filename / device name during adoption + wizard flows.
Lives here rather than in ``esphome.dashboard.util.text`` so it
survives the legacy dashboard's eventual removal.
The dashboard module re-exports this name as a back-compat shim.
Used by device-builder (esphome/device-builder), which slugifies friendly
names into the YAML filename / device name during adoption + wizard flows.
Coordinate with the device-builder team before changing the
slugification rules — the mapping must stay stable so existing
on-disk filenames keep matching across releases.

View File

@@ -71,14 +71,10 @@ def _to_path_if_not_none(value: str | None) -> Path | None:
class StorageJSON:
"""Persisted device metadata sidecar.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) — reads/writes the same
JSON file as the legacy dashboard so a single config_dir can be
shared between the two during the transition. The schema
(``storage_version``, field names, types) must stay backwards
compatible — coordinate with the device-builder team before
adding required fields or changing semantics of existing ones.
Used by device-builder (esphome/device-builder), which reads/writes this
JSON file. The schema (``storage_version``, field names, types) must stay
backwards compatible — coordinate with the device-builder team before
adding required fields or changing semantics of existing ones.
"""
def __init__(

View File

@@ -62,14 +62,12 @@ TXT_RECORD_VERSION = b"version"
class DiscoveredImport:
"""An importable device discovered via mDNS ``_esphomelib._tcp.local.``.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) — surfaces these as
"discovered devices" on the new dashboard's adoption flow.
Used by device-builder (esphome/device-builder), which surfaces these as
"discovered devices" on its adoption flow.
Fields are populated from TXT records on the broadcast service
info (see :class:`DashboardImportDiscovery`). Coordinate before
adding/removing fields — both consumers persist them.
adding/removing fields — the consumer persists them.
"""
friendly_name: str | None
@@ -87,11 +85,9 @@ class DashboardBrowser(AsyncServiceBrowser):
class DashboardImportDiscovery:
"""Track importable devices announcing on ``_esphomelib._tcp.local.``.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) — wired up alongside
the dashboard's own ``ServiceBrowser`` to populate the
"Discovered devices" panel and the adoption flow.
Used by device-builder (esphome/device-builder), which wires it up
alongside its own ``ServiceBrowser`` to populate the
"Discovered devices" panel and the adoption flow.
The class maintains ``import_state: dict[str, DiscoveredImport]``
keyed by the mDNS service name. ``on_update`` is invoked with
@@ -262,11 +258,9 @@ async def async_resolve_hosts(
class AsyncEsphomeZeroconf(AsyncZeroconf):
"""ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) — drives both the live
mDNS browser and the per-sweep ``async_resolve_host`` fallback
for non-API devices that don't broadcast esphomelib.
Used by device-builder (esphome/device-builder), which drives both the live
mDNS browser and the per-sweep ``async_resolve_host`` fallback
for non-API devices that don't broadcast esphomelib.
Coordinate before adding required constructor args or changing
the ``async_resolve_host`` signature — device-builder calls it

View File

@@ -3,15 +3,12 @@ voluptuous==0.16.0
PyYAML==6.0.3
paho-mqtt==1.6.1
colorama==0.4.6
icmplib==3.0.4
tornado==6.5.7
tzlocal==5.4.3 # from time
tzdata>=2026.2 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.3.1
zeroconf==0.149.16
puremagic==1.30

View File

@@ -259,14 +259,7 @@ def lint_executable_bit(fname: Path) -> str | None:
return None
@lint_content_find_check(
"\t",
only_first=True,
exclude=[
"esphome/dashboard/static/ace.js",
"esphome/dashboard/static/ext-searchbox.js",
],
)
@lint_content_find_check("\t", only_first=True)
def lint_tabs(fname, line, col, content):
return "File contains tab character. Please convert tabs to spaces."

View File

@@ -1,6 +0,0 @@
import pathlib
def get_fixture_path(filename: str) -> pathlib.Path:
"""Get path of fixture."""
return pathlib.Path(__file__).parent.joinpath("fixtures", filename)

View File

@@ -1,43 +0,0 @@
"""Common fixtures for dashboard tests."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
import pytest_asyncio
from esphome.dashboard.core import ESPHomeDashboard
from esphome.dashboard.entries import DashboardEntries
@pytest.fixture
def mock_settings(tmp_path: Path) -> MagicMock:
"""Create mock dashboard settings."""
settings = MagicMock()
settings.config_dir = str(tmp_path)
settings.absolute_config_dir = tmp_path
return settings
@pytest.fixture
def mock_dashboard(mock_settings: MagicMock) -> Mock:
"""Create a mock dashboard."""
dashboard = Mock(spec=ESPHomeDashboard)
dashboard.settings = mock_settings
dashboard.entries = Mock()
dashboard.entries.async_all.return_value = []
dashboard.stop_event = Mock()
dashboard.stop_event.is_set.return_value = True
dashboard.ping_request = Mock()
dashboard.ignored_devices = set()
dashboard.bus = Mock()
dashboard.bus.async_fire = Mock()
return dashboard
@pytest_asyncio.fixture
async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries:
"""Create a DashboardEntries instance for testing."""
return DashboardEntries(mock_dashboard)

View File

@@ -1,47 +0,0 @@
substitutions:
name: picoproxy
friendly_name: Pico Proxy
esphome:
name: ${name}
friendly_name: ${friendly_name}
project:
name: esphome.bluetooth-proxy
version: "1.0"
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
api:
logger:
ota:
improv_serial:
dashboard_import:
package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main
button:
- platform: factory_reset
id: resetf
- platform: safe_mode
name: Safe Mode Boot
entity_category: diagnostic
sensor:
- platform: template
id: pm11
name: "pm 1.0µm"
lambda: return 1.0;
- platform: template
id: pm251
name: "pm 2.5µm"
lambda: return 2.5;
- platform: template
id: pm101
name: "pm 10µm"
lambda: return 10;

View File

@@ -1,199 +0,0 @@
"""Unit tests for esphome.dashboard.dns module."""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
from icmplib import NameLookupError
import pytest
from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper
@pytest.fixture
def dns_cache_fixture() -> DNSCache:
"""Create a DNSCache instance."""
return DNSCache()
def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when hostname is not in cache."""
now = time.monotonic()
result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now)
assert result is None
def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when cache entry is expired."""
now = time.monotonic()
# Add entry that's already expired
dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result is None
# Expired entry should still be in cache (not removed by get_cached_addresses)
assert "example.com" in dns_cache_fixture._cache
def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with valid cache entry."""
now = time.monotonic()
# Add entry that expires in 60 seconds
dns_cache_fixture._cache["example.com"] = (
now + 60,
["192.168.1.10", "192.168.1.11"],
)
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == ["192.168.1.10", "192.168.1.11"]
# Entry should still be in cache
assert "example.com" in dns_cache_fixture._cache
def test_get_cached_addresses_hostname_normalization(
dns_cache_fixture: DNSCache,
) -> None:
"""Test get_cached_addresses normalizes hostname."""
now = time.monotonic()
# Add entry with lowercase hostname
dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"])
# Test with various forms
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [
"192.168.1.10"
]
assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [
"192.168.1.10"
]
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [
"192.168.1.10"
]
def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with IPv6 addresses."""
now = time.monotonic()
dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == ["2001:db8::1", "fe80::1"]
def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with empty address list."""
now = time.monotonic()
dns_cache_fixture._cache["example.com"] = (now + 60, [])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == []
def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when cache contains an exception."""
now = time.monotonic()
# Store an exception (from failed resolution)
dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed"))
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result is None # Should return None for exceptions
def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
"""Test that get_cached_addresses never calls async_resolve."""
now = time.monotonic()
with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve:
# Test non-cached
result = dns_cache_fixture.get_cached_addresses("uncached.com", now)
assert result is None
mock_resolve.assert_not_called()
# Test expired
dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("expired.com", now)
assert result is None
mock_resolve.assert_not_called()
# Test valid
dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
assert result == ["192.168.1.10"]
mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_async_resolve_wrapper_ip_address() -> None:
"""Test _async_resolve_wrapper returns IP address directly."""
result = await _async_resolve_wrapper("192.168.1.10")
assert result == ["192.168.1.10"]
result = await _async_resolve_wrapper("2001:db8::1")
assert result == ["2001:db8::1"]
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_success() -> None:
"""Test _async_resolve_wrapper falls back to bare hostname for .local."""
mock_resolve = AsyncMock()
# First call (device.local) fails, second call (device) succeeds
mock_resolve.side_effect = [
NameLookupError("device.local"),
["192.168.1.50"],
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
assert mock_resolve.call_count == 2
mock_resolve.assert_any_call("device.local")
mock_resolve.assert_any_call("device")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_both_fail() -> None:
"""Test _async_resolve_wrapper returns exception when both fail."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.local")
mock_resolve.side_effect = [
original_exception,
NameLookupError("device"),
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
# Should return the original exception, not the fallback exception
assert result is original_exception
assert mock_resolve.call_count == 2
@pytest.mark.asyncio
async def test_async_resolve_wrapper_non_local_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback for non-.local hostnames."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.example.com")
mock_resolve.side_effect = original_exception
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.example.com")
assert result is original_exception
# Should only try the original hostname, no fallback
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.example.com")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_success_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback when .local succeeds."""
mock_resolve = AsyncMock(return_value=["192.168.1.50"])
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
# Should only try once since it succeeded
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.local")

View File

@@ -1,240 +0,0 @@
"""Unit tests for esphome.dashboard.status.mdns module."""
from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
import pytest_asyncio
from zeroconf import AddressResolver, IPVersion
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.status.mdns import MDNSStatus
from esphome.zeroconf import DiscoveredImport
@pytest_asyncio.fixture
async def mdns_status(mock_dashboard: Mock) -> MDNSStatus:
"""Create an MDNSStatus instance in async context."""
# We're in an async context so get_running_loop will work
return MDNSStatus(mock_dashboard)
@pytest.mark.asyncio
async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when no zeroconf instance is available."""
mdns_status.aiozc = None
result = mdns_status.get_cached_addresses("device.local")
assert result is None
@pytest.mark.asyncio
async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when address is not in cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = False
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result is None
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
@pytest.mark.asyncio
async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when address is found in cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == ["192.168.1.10", "fe80::1"]
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All)
@pytest.mark.asyncio
async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with hostname having trailing dot."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local.")
assert result == ["192.168.1.10"]
# Should normalize to device.local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with uppercase hostname."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("DEVICE.LOCAL")
assert result == ["192.168.1.10"]
# Should normalize to device.local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with simple hostname (no domain)."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device")
assert result == ["192.168.1.10"]
# Should append .local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses returning only IPv6 addresses."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == ["fe80::1", "2001:db8::1"]
@pytest.mark.asyncio
async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses returning empty list from cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = []
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == []
@pytest.mark.asyncio
async def test_async_setup_success(mock_dashboard: Mock) -> None:
"""Test successful async_setup."""
mdns_status = MDNSStatus(mock_dashboard)
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
mock_zc.return_value = Mock()
result = mdns_status.async_setup()
assert result is True
assert mdns_status.aiozc is not None
@pytest.mark.asyncio
async def test_async_setup_failure(mock_dashboard: Mock) -> None:
"""Test async_setup with OSError."""
mdns_status = MDNSStatus(mock_dashboard)
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
mock_zc.side_effect = OSError("Network error")
result = mdns_status.async_setup()
assert result is False
assert mdns_status.aiozc is None
@pytest.mark.asyncio
async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is added."""
# Create a DiscoveredImport object
discovered = DiscoveredImport(
device_name="test_device",
friendly_name="Test Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="wifi",
)
# Call _on_import_update with a device
mdns_status._on_import_update("test_device", discovered)
# Should fire IMPORTABLE_DEVICE_ADDED event
mock_dashboard = mdns_status.dashboard
mock_dashboard.bus.async_fire.assert_called_once()
call_args = mock_dashboard.bus.async_fire.call_args
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
assert "device" in call_args[0][1]
device_data = call_args[0][1]["device"]
assert device_data["name"] == "test_device"
assert device_data["friendly_name"] == "Test Device"
assert device_data["project_name"] == "test_project"
assert device_data["ignored"] is False
@pytest.mark.asyncio
async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is ignored."""
# Add device to ignored list
mdns_status.dashboard.ignored_devices.add("ignored_device")
# Create a DiscoveredImport object for ignored device
discovered = DiscoveredImport(
device_name="ignored_device",
friendly_name="Ignored Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="ethernet",
)
# Call _on_import_update with an ignored device
mdns_status._on_import_update("ignored_device", discovered)
# Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True
mock_dashboard = mdns_status.dashboard
mock_dashboard.bus.async_fire.assert_called_once()
call_args = mock_dashboard.bus.async_fire.call_args
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
device_data = call_args[0][1]["device"]
assert device_data["name"] == "ignored_device"
assert device_data["ignored"] is True
@pytest.mark.asyncio
async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is removed."""
# Call _on_import_update with None (device removed)
mdns_status._on_import_update("removed_device", None)
# Should fire IMPORTABLE_DEVICE_REMOVED event
mdns_status.dashboard.bus.async_fire.assert_called_once_with(
DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"}
)

View File

@@ -1,288 +0,0 @@
"""Tests for dashboard entries Path-related functionality."""
from __future__ import annotations
import os
from pathlib import Path
import tempfile
from unittest.mock import Mock
import pytest
from esphome.core import CORE
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.entries import DashboardEntries, DashboardEntry
def create_cache_key() -> tuple[int, int, float, int]:
"""Helper to create a valid DashboardCacheKeyType."""
return (0, 0, 0.0, 0)
@pytest.fixture(autouse=True)
def setup_core():
"""Set up CORE for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
CORE.config_path = Path(tmpdir) / "test.yaml"
yield
CORE.reset()
def test_dashboard_entry_path_initialization() -> None:
"""Test DashboardEntry initializes with path correctly."""
test_path = Path("/test/config/device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert entry.cache_key == cache_key
def test_dashboard_entry_path_with_absolute_path() -> None:
"""Test DashboardEntry handles absolute paths."""
# Use a truly absolute path for the platform
test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml"
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert entry.path.is_absolute()
def test_dashboard_entry_path_with_relative_path() -> None:
"""Test DashboardEntry handles relative paths."""
test_path = Path("configs/device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert not entry.path.is_absolute()
@pytest.mark.asyncio
async def test_dashboard_entries_get_by_path(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test getting entry by path."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Verify the entry was loaded
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
assert entry.path == test_file
# Also verify get() works with Path
result = dashboard_entries.get(test_file)
assert result == entry
@pytest.mark.asyncio
async def test_dashboard_entries_get_nonexistent_path(
dashboard_entries: DashboardEntries,
) -> None:
"""Test getting non-existent entry returns None."""
result = dashboard_entries.get("/nonexistent/path.yaml")
assert result is None
@pytest.mark.asyncio
async def test_dashboard_entries_path_normalization(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test that paths are handled consistently."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
@pytest.mark.asyncio
async def test_dashboard_entries_path_with_spaces(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test handling paths with spaces."""
# Create a test file with spaces in name
test_file = tmp_path / "my device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
assert result.path == test_file
@pytest.mark.asyncio
async def test_dashboard_entries_path_with_special_chars(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test handling paths with special characters."""
# Create a test file with special characters
test_file = tmp_path / "device-01_test.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
def test_dashboard_entries_windows_path() -> None:
"""Test handling Windows-style paths."""
test_path = Path(r"C:\Users\test\esphome\device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
@pytest.mark.asyncio
async def test_dashboard_entries_path_to_cache_key_mapping(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test internal entries storage with paths and cache keys."""
# Create test files
file1 = tmp_path / "device1.yaml"
file2 = tmp_path / "device2.yaml"
file1.write_text("test config 1")
file2.write_text("test config 2")
# Update entries to load the files
await dashboard_entries.async_update_entries()
# Get entries and verify they have different cache keys
entry1 = dashboard_entries.get(file1)
entry2 = dashboard_entries.get(file2)
assert entry1 is not None
assert entry2 is not None
assert entry1.cache_key != entry2.cache_key
def test_dashboard_entry_path_property() -> None:
"""Test that path property returns expected value."""
test_path = Path("/test/config/device.yaml")
entry = DashboardEntry(test_path, create_cache_key())
assert entry.path == test_path
assert isinstance(entry.path, Path)
@pytest.mark.asyncio
async def test_dashboard_entries_all_returns_entries_with_paths(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test that all() returns entries with their paths intact."""
# Create test files
files = [
tmp_path / "device1.yaml",
tmp_path / "device2.yaml",
tmp_path / "device3.yaml",
]
for file in files:
file.write_text("test config")
# Update entries to load the files
await dashboard_entries.async_update_entries()
all_entries = dashboard_entries.async_all()
assert len(all_entries) == len(files)
retrieved_paths = [entry.path for entry in all_entries]
assert set(retrieved_paths) == set(files)
@pytest.mark.asyncio
async def test_async_update_entries_removed_path(
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
) -> None:
"""Test that removed files trigger ENTRY_REMOVED event."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# First update to add the entry
await dashboard_entries.async_update_entries()
# Verify entry was added
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
# Delete the file
test_file.unlink()
# Second update to detect removal
await dashboard_entries.async_update_entries()
# Verify entry was removed
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 0
# Verify ENTRY_REMOVED event was fired
mock_dashboard.bus.async_fire.assert_any_call(
DashboardEvent.ENTRY_REMOVED, {"entry": entry}
)
@pytest.mark.asyncio
async def test_async_update_entries_updated_path(
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
) -> None:
"""Test that modified files trigger ENTRY_UPDATED event."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# First update to add the entry
await dashboard_entries.async_update_entries()
# Verify entry was added
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
original_cache_key = entry.cache_key
# Modify the file to change its mtime
test_file.write_text("updated config")
# Explicitly change the mtime to ensure it's different
stat = test_file.stat()
os.utime(test_file, (stat.st_atime, stat.st_mtime + 1))
# Second update to detect modification
await dashboard_entries.async_update_entries()
# Verify entry is still there with updated cache key
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
updated_entry = all_entries[0]
assert updated_entry == entry # Same entry object
assert updated_entry.cache_key != original_cache_key # But cache key updated
# Verify ENTRY_UPDATED event was fired
mock_dashboard.bus.async_fire.assert_any_call(
DashboardEvent.ENTRY_UPDATED, {"entry": entry}
)

View File

@@ -1,287 +0,0 @@
"""Tests for DashboardSettings (path resolution and authentication)."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
import tempfile
import pytest
from esphome.core import CORE
from esphome.dashboard.settings import DashboardSettings
from esphome.dashboard.util.password import password_hash
@pytest.fixture
def dashboard_settings(tmp_path: Path) -> DashboardSettings:
"""Create DashboardSettings instance with temp directory."""
settings = DashboardSettings()
# Resolve symlinks to ensure paths match
resolved_dir = tmp_path.resolve()
settings.config_dir = resolved_dir
settings.absolute_config_dir = resolved_dir
return settings
def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with simple relative path."""
result = dashboard_settings.rel_path("config.yaml")
expected = dashboard_settings.config_dir / "config.yaml"
assert result == expected
def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with multiple path components."""
result = dashboard_settings.rel_path("subfolder", "device", "config.yaml")
expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml"
assert result == expected
def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path prevents directory traversal."""
# This should raise ValueError as it tries to go outside config_dir
with pytest.raises(ValueError):
dashboard_settings.rel_path("..", "outside.yaml")
def test_rel_path_absolute_path_within_config(
dashboard_settings: DashboardSettings,
) -> None:
"""Test rel_path with absolute path that's within config dir."""
internal_path = dashboard_settings.absolute_config_dir / "internal.yaml"
internal_path.touch()
result = dashboard_settings.rel_path("internal.yaml")
expected = dashboard_settings.config_dir / "internal.yaml"
assert result == expected
def test_rel_path_absolute_path_outside_config(
dashboard_settings: DashboardSettings,
) -> None:
"""Test rel_path with absolute path outside config dir raises error."""
outside_path = "/tmp/outside/config.yaml"
with pytest.raises(ValueError):
dashboard_settings.rel_path(outside_path)
def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with no arguments returns config_dir."""
result = dashboard_settings.rel_path()
assert result == dashboard_settings.config_dir
def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path works with Path objects as arguments."""
path_obj = Path("subfolder") / "config.yaml"
result = dashboard_settings.rel_path(path_obj)
expected = dashboard_settings.config_dir / "subfolder" / "config.yaml"
assert result == expected
def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path normalizes path separators."""
# os.path.join normalizes slashes on Windows but preserves them on Unix
# Test that providing components separately gives same result
result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
assert result1 == result2
# Also test that the result is as expected
expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml"
assert result1 == expected
def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles paths with spaces."""
result = dashboard_settings.rel_path("my folder", "my config.yaml")
expected = dashboard_settings.config_dir / "my folder" / "my config.yaml"
assert result == expected
def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles paths with special characters."""
result = dashboard_settings.rel_path("device-01_test", "config.yaml")
expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml"
assert result == expected
def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None:
"""Test that config_dir can be accessed and used with Path operations."""
config_path = dashboard_settings.config_dir
assert config_path.exists()
assert config_path.is_dir()
assert config_path.is_absolute()
def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None:
"""Test absolute_config_dir is a Path object."""
assert isinstance(dashboard_settings.absolute_config_dir, Path)
assert dashboard_settings.absolute_config_dir.exists()
assert dashboard_settings.absolute_config_dir.is_dir()
assert dashboard_settings.absolute_config_dir.is_absolute()
def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with symlink that points inside config dir."""
target = dashboard_settings.absolute_config_dir / "target.yaml"
target.touch()
symlink = dashboard_settings.absolute_config_dir / "link.yaml"
symlink.symlink_to(target)
result = dashboard_settings.rel_path("link.yaml")
expected = dashboard_settings.config_dir / "link.yaml"
assert result == expected
def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with symlink that points outside config dir."""
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp:
symlink = dashboard_settings.absolute_config_dir / "external_link.yaml"
symlink.symlink_to(tmp.name)
with pytest.raises(ValueError):
dashboard_settings.rel_path("external_link.yaml")
def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles None arguments gracefully."""
result = dashboard_settings.rel_path("None")
expected = dashboard_settings.config_dir / "None"
assert result == expected
def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles numeric arguments."""
result = dashboard_settings.rel_path("123", "456.789")
expected = dashboard_settings.config_dir / "123" / "456.789"
assert result == expected
def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
"""Test that CORE.config_path.parent resolves to config_dir after parse_args.
This is a regression test for issue #11280 where binary download failed
when using packages with secrets after the Path migration in 2025.10.0.
The issue was that after switching from os.path to Path:
- Before: os.path.dirname("/config/.") → "/config"
- After: Path("/config/.").parent → Path("/") (normalized first!)
The fix uses a sentinel file so .parent returns the correct directory:
- Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config")
"""
# Create test directory structure with secrets and packages
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create secrets.yaml with obviously fake test values
secrets_file = config_dir / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TEST-DUMMY-SSID\n"
"wifi_password: not-a-real-password-just-for-testing\n"
)
# Create package file that uses secrets
package_file = config_dir / "common.yaml"
package_file.write_text(
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
)
# Create main device config that includes the package
device_config = config_dir / "test-device.yaml"
device_config.write_text(
"esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n"
)
# Set up dashboard settings with our test config directory
settings = DashboardSettings()
args = Namespace(
configuration=str(config_dir),
password=None,
username=None,
ha_addon=False,
verbose=False,
)
settings.parse_args(args)
# Verify that CORE.config_path.parent correctly points to the config directory
# This is critical for secret resolution in yaml_util.py which does:
# main_config_dir = CORE.config_path.parent
# main_secret_yml = main_config_dir / "secrets.yaml"
assert CORE.config_path.parent == config_dir.resolve()
assert (CORE.config_path.parent / "secrets.yaml").exists()
assert (CORE.config_path.parent / "common.yaml").exists()
# Verify that CORE.config_path itself uses the sentinel file
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist
@pytest.fixture
def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings:
"""Create DashboardSettings with auth configured, based on dashboard_settings."""
dashboard_settings.username = "admin"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("correctpassword")
return dashboard_settings
def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None:
"""Test check_password returns True for correct username and password."""
assert auth_settings.check_password("admin", "correctpassword") is True
def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong password."""
assert auth_settings.check_password("admin", "wrongpassword") is False
def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong username."""
assert auth_settings.check_password("notadmin", "correctpassword") is False
def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False when both are wrong."""
assert auth_settings.check_password("notadmin", "wrongpassword") is False
def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None:
"""Test check_password returns True when auth is not configured."""
assert dashboard_settings.check_password("anyone", "anything") is True
def test_check_password_non_ascii_username(
dashboard_settings: DashboardSettings,
) -> None:
"""Test check_password handles non-ASCII usernames without TypeError."""
dashboard_settings.username = "\u00e9l\u00e8ve"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("pass")
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False
assert dashboard_settings.check_password("other", "pass") is False
def test_check_password_ha_addon_no_password(
dashboard_settings: DashboardSettings,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_password doesn't crash in HA add-on mode without a password.
In HA add-on mode, using_ha_addon_auth can be True while using_password
is False, leaving password_hash as b"". This must not raise TypeError
in hmac.compare_digest.
"""
monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False)
dashboard_settings.on_ha_addon = True
dashboard_settings.using_password = False
# password_hash stays as default b""
assert dashboard_settings.check_password("anyone", "anything") is False

View File

@@ -1,1889 +0,0 @@
from __future__ import annotations
from argparse import Namespace
import asyncio
import base64
from collections.abc import Generator
from contextlib import asynccontextmanager
import gzip
import json
import os
from pathlib import Path
import sys
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
import pytest_asyncio
from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.testing import bind_unused_port
from tornado.websocket import WebSocketClientConnection, websocket_connect
from esphome import yaml_util
from esphome.core import CORE
from esphome.dashboard import web_server
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.core import DASHBOARD
from esphome.dashboard.entries import (
DashboardEntry,
EntryStateSource,
bool_to_entry_state,
)
from esphome.dashboard.models import build_importable_device_dict
from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket
from esphome.zeroconf import DiscoveredImport
from .common import get_fixture_path
def get_build_path(base_path: Path, device_name: str) -> Path:
"""Get the build directory path for a device.
This is a test helper that constructs the standard ESPHome build directory
structure. Note: This helper does NOT perform path traversal sanitization
because it's only used in tests where we control the inputs. The actual
web_server.py code handles sanitization in DownloadBinaryRequestHandler.get()
via file_name.replace("..", "").lstrip("/").
Args:
base_path: The base temporary path (typically tmp_path from pytest)
device_name: The name of the device (should not contain path separators
in production use, but tests may use it for specific scenarios)
Returns:
Path to the build directory (.esphome/build/device_name)
"""
return base_path / ".esphome" / "build" / device_name
class DashboardTestHelper:
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
self.io_loop = io_loop
self.client = client
self.port = port
async def fetch(self, path: str, **kwargs) -> HTTPResponse:
"""Get a response for the given path."""
if path.lower().startswith(("http://", "https://")):
url = path
else:
url = f"http://127.0.0.1:{self.port}{path}"
future = self.client.fetch(url, raise_error=True, **kwargs)
return await future
@pytest.fixture
def mock_async_run_system_command() -> Generator[MagicMock]:
"""Fixture to mock async_run_system_command."""
with patch("esphome.dashboard.web_server.async_run_system_command") as mock:
yield mock
@pytest.fixture
def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]:
"""Fixture to mock trash_storage_path."""
trash_dir = tmp_path / "trash"
with patch(
"esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir
) as mock:
yield mock
@pytest.fixture
def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]:
"""Fixture to mock archive_storage_path."""
archive_dir = tmp_path / "archive"
with patch(
"esphome.dashboard.web_server.archive_storage_path",
return_value=archive_dir,
) as mock:
yield mock
@pytest.fixture
def mock_dashboard_settings() -> Generator[MagicMock]:
"""Fixture to mock dashboard settings."""
with patch("esphome.dashboard.web_server.settings") as mock_settings:
# Set default auth settings to avoid authentication issues
mock_settings.using_auth = False
mock_settings.on_ha_addon = False
yield mock_settings
@pytest.fixture
def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]:
"""Fixture to mock ext_storage_path."""
with patch("esphome.dashboard.web_server.ext_storage_path") as mock:
mock.return_value = str(tmp_path / "storage.json")
yield mock
@pytest.fixture
def mock_storage_json() -> Generator[MagicMock]:
"""Fixture to mock StorageJSON."""
with patch("esphome.dashboard.web_server.StorageJSON") as mock:
yield mock
@pytest.fixture
def mock_idedata() -> Generator[MagicMock]:
"""Fixture to mock platformio toolchain.IDEData."""
with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock:
yield mock
@pytest_asyncio.fixture()
async def dashboard() -> DashboardTestHelper:
sock, port = bind_unused_port()
args = Mock(
ha_addon=True,
configuration=get_fixture_path("conf"),
port=port,
)
DASHBOARD.settings.parse_args(args)
app = web_server.make_app()
http_server = HTTPServer(app)
http_server.add_sockets([sock])
await DASHBOARD.async_setup()
os.environ["DISABLE_HA_AUTHENTICATION"] = "1"
assert DASHBOARD.settings.using_password is False
assert DASHBOARD.settings.on_ha_addon is True
assert DASHBOARD.settings.using_auth is False
task = asyncio.create_task(DASHBOARD.async_run())
# Wait for initial device loading to complete
await DASHBOARD.entries.async_request_update_entries()
client = AsyncHTTPClient()
io_loop = IOLoop(make_current=False)
yield DashboardTestHelper(io_loop, client, port)
task.cancel()
sock.close()
client.close()
io_loop.close()
@asynccontextmanager
async def websocket_connection(dashboard: DashboardTestHelper):
"""Async context manager for WebSocket connections."""
url = f"ws://127.0.0.1:{dashboard.port}/events"
ws = await websocket_connect(url)
try:
yield ws
finally:
if ws:
ws.close()
@pytest_asyncio.fixture
async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection:
"""Create a WebSocket connection for testing."""
url = f"ws://127.0.0.1:{dashboard.port}/events"
ws = await websocket_connect(url)
# Read and discard initial state message
await ws.read_message()
yield ws
if ws:
ws.close()
@pytest.mark.asyncio
async def test_main_page(dashboard: DashboardTestHelper) -> None:
response = await dashboard.fetch("/")
assert response.code == 200
@pytest.mark.asyncio
async def test_devices_page(dashboard: DashboardTestHelper) -> None:
response = await dashboard.fetch("/devices")
assert response.code == 200
assert response.headers["content-type"] == "application/json"
json_data = json.loads(response.body.decode())
configured_devices = json_data["configured"]
assert len(configured_devices) != 0
first_device = configured_devices[0]
assert first_device["name"] == "pico"
assert first_device["configuration"] == "pico.yaml"
@pytest.mark.asyncio
async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None:
"""Test the WizardRequestHandler.post method with invalid inputs."""
# Test with missing name (should fail with 422)
body_no_name = json.dumps(
{
"name": "", # Empty name
"platform": "ESP32",
"board": "esp32dev",
}
)
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/wizard",
method="POST",
body=body_no_name,
headers={"Content-Type": "application/json"},
)
assert exc_info.value.code == 422
# Test with invalid wizard type (should fail with 422)
body_invalid_type = json.dumps(
{
"name": "test_device",
"type": "invalid_type",
"platform": "ESP32",
"board": "esp32dev",
}
)
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/wizard",
method="POST",
body=body_invalid_type,
headers={"Content-Type": "application/json"},
)
assert exc_info.value.code == 422
@pytest.mark.asyncio
async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None:
"""Test the WizardRequestHandler.post when config already exists."""
# Try to create a wizard for existing pico.yaml (should conflict)
body = json.dumps(
{
"name": "pico", # This already exists in fixtures
"platform": "ESP32",
"board": "esp32dev",
}
)
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/wizard",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
assert exc_info.value.code == 409
@pytest.mark.asyncio
async def test_download_binary_handler_not_found(
dashboard: DashboardTestHelper,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with non-existent config."""
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/download.bin?configuration=nonexistent.yaml",
method="GET",
)
assert exc_info.value.code == 404
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_no_file_param(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get without file parameter."""
# Mock storage to exist, but still should fail without file param
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin")
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/download.bin?configuration=pico.yaml",
method="GET",
)
assert exc_info.value.code == 400
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_with_file(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with existing binary file."""
# Create a fake binary file
build_dir = tmp_path / ".esphome" / "build" / "test"
build_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"fake firmware content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin",
method="GET",
)
assert response.code == 200
assert response.body == b"fake firmware content"
assert response.headers["Content-Type"] == "application/octet-stream"
assert "attachment" in response.headers["Content-Disposition"]
assert "test_device-firmware.bin" in response.headers["Content-Disposition"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_compressed(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with compression."""
# Create a fake binary file
build_dir = tmp_path / ".esphome" / "build" / "test"
build_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
original_content = b"fake firmware content for compression test"
firmware_file.write_bytes(original_content)
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1",
method="GET",
)
assert response.code == 200
# Decompress and verify content
decompressed = gzip.decompress(response.body)
assert decompressed == original_content
assert response.headers["Content-Type"] == "application/octet-stream"
assert "firmware.bin.gz" in response.headers["Content-Disposition"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_custom_download_name(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with custom download name."""
# Create a fake binary file
build_dir = tmp_path / ".esphome" / "build" / "test"
build_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin",
method="GET",
)
assert response.code == 200
assert "custom_name.bin" in response.headers["Content-Disposition"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_idedata_fallback(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_async_run_system_command: MagicMock,
mock_storage_json: MagicMock,
mock_idedata: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images."""
# Create build directory but no bootloader file initially
build_dir = tmp_path / ".esphome" / "build" / "test"
build_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"firmware")
# Create bootloader file that idedata will find
bootloader_file = tmp_path / "bootloader.bin"
bootloader_file.write_bytes(b"bootloader content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Mock idedata response
mock_image = Mock()
mock_image.path = bootloader_file
mock_idedata_instance = Mock()
mock_idedata_instance.extra_flash_images = [mock_image]
mock_idedata.return_value = mock_idedata_instance
# Mock async_run_system_command to return idedata JSON
mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "")
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=bootloader.bin",
method="GET",
)
assert response.code == 200
assert response.body == b"bootloader content"
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_subdirectory_file(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case).
This is a regression test for issue #11343 where the Path migration broke
downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'.
The issue was that with_name() doesn't accept path separators:
- Before: path = storage_json.firmware_bin_path.with_name(file_name)
ValueError: Invalid name 'zephyr/zephyr.uf2'
- After: path = storage_json.firmware_bin_path.parent.joinpath(file_name)
Works correctly with subdirectory paths
"""
# Create a fake nRF52 build structure with firmware in subdirectory
build_dir = get_build_path(tmp_path, "nrf52-device")
zephyr_dir = build_dir / "zephyr"
zephyr_dir.mkdir(parents=True)
# Create the main firmware binary (would be in build root)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"main firmware")
# Create the UF2 file in zephyr subdirectory (nRF52 specific)
uf2_file = zephyr_dir / "zephyr.uf2"
uf2_file.write_bytes(b"nRF52 UF2 firmware content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "nrf52-device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Request the UF2 file with subdirectory path
response = await dashboard.fetch(
"/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2",
method="GET",
)
assert response.code == 200
assert response.body == b"nRF52 UF2 firmware content"
assert response.headers["Content-Type"] == "application/octet-stream"
assert "attachment" in response.headers["Content-Disposition"]
# Download name should be device-name + full file path
assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_subdirectory_file_url_encoded(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path.
Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly
decoded and handled, and that custom download names work with subdirectories.
"""
# Create a fake build structure with firmware in subdirectory
build_dir = get_build_path(tmp_path, "test")
zephyr_dir = build_dir / "zephyr"
zephyr_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"content")
uf2_file = zephyr_dir / "zephyr.uf2"
uf2_file.write_bytes(b"content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Request with URL-encoded path and custom download name
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin",
method="GET",
)
assert response.code == 200
assert "custom_name.bin" in response.headers["Content-Disposition"]
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize(
("attack_path", "expected_code"),
[
pytest.param("../../../secrets.yaml", 403, id="basic_traversal"),
pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"),
pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"),
pytest.param("/etc/passwd", 403, id="absolute_path"),
pytest.param("//etc/passwd", 403, id="double_slash_absolute"),
pytest.param(
"....//secrets.yaml",
# On Windows, Path.resolve() treats "..." and "...." as parent
# traversal (like ".."), so the path escapes base_dir -> 403.
# On Unix, "...." is a literal directory name that stays inside
# base_dir but doesn't exist -> 404.
403 if sys.platform == "win32" else 404,
id="multiple_dots",
),
],
)
async def test_download_binary_handler_path_traversal_protection(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
attack_path: str,
expected_code: int,
) -> None:
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
Verifies that attempts to escape the build directory via '..' are rejected
using resolve()/relative_to() validation. Tests multiple attack vectors.
Real traversals that escape the base directory get 403. Paths like '....'
that resolve inside the base directory but don't exist get 404.
"""
# Create build structure
build_dir = get_build_path(tmp_path, "test")
build_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"firmware content")
# Create a sensitive file outside the build directory that should NOT be accessible
sensitive_file = tmp_path / "secrets.yaml"
sensitive_file.write_bytes(b"secret: my_secret_password")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
# Mock async_run_system_command so paths that pass validation but don't exist
# return 404 deterministically without spawning a real subprocess.
with (
patch(
"esphome.dashboard.web_server.async_run_system_command",
new_callable=AsyncMock,
return_value=(2, "", ""),
),
pytest.raises(HTTPClientError) as exc_info,
):
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={attack_path}",
method="GET",
)
assert exc_info.value.code == expected_code
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_no_firmware_bin_path(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
) -> None:
"""Test that download returns 404 when firmware_bin_path is None.
This covers configs created by StorageJSON.from_wizard() where no
firmware has been compiled yet.
"""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = None
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=firmware.bin",
method="GET",
)
assert exc_info.value.code == 404
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"])
async def test_download_binary_handler_empty_file_name(
dashboard: DashboardTestHelper,
mock_storage_json: MagicMock,
file_value: str,
) -> None:
"""Test that download returns 400 for empty or whitespace-only file names."""
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = Path("/fake/firmware.bin")
mock_storage_json.load.return_value = mock_storage
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
f"/download.bin?configuration=test.yaml&file={file_value}",
method="GET",
)
assert exc_info.value.code == 400
@pytest.mark.asyncio
@pytest.mark.usefixtures("mock_ext_storage_path")
async def test_download_binary_handler_multiple_subdirectory_levels(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_storage_json: MagicMock,
) -> None:
"""Test downloading files from multiple subdirectory levels.
Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'.
"""
# Create nested directory structure
build_dir = get_build_path(tmp_path, "test")
nested_dir = build_dir / "build" / "output"
nested_dir.mkdir(parents=True)
firmware_file = build_dir / "firmware.bin"
firmware_file.write_bytes(b"main")
nested_file = nested_dir / "firmware.bin"
nested_file.write_bytes(b"nested firmware content")
# Mock storage JSON
mock_storage = Mock()
mock_storage.name = "test_device"
mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/download.bin?configuration=test.yaml&file=build/output/firmware.bin",
method="GET",
)
assert response.code == 200
assert response.body == b"nested firmware content"
@pytest.mark.asyncio
async def test_edit_request_handler_post_invalid_file(
dashboard: DashboardTestHelper,
) -> None:
"""Test the EditRequestHandler.post with non-yaml file."""
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/edit?configuration=test.txt",
method="POST",
body=b"content",
)
assert exc_info.value.code == 404
@pytest.mark.asyncio
async def test_edit_request_handler_post_existing(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_dashboard_settings: MagicMock,
) -> None:
"""Test the EditRequestHandler.post with existing yaml file."""
# Create a temporary yaml file to edit (don't modify fixtures)
test_file = tmp_path / "test_edit.yaml"
test_file.write_text("esphome:\n name: original\n")
# Configure the mock settings
mock_dashboard_settings.rel_path.return_value = test_file
mock_dashboard_settings.absolute_config_dir = test_file.parent
new_content = "esphome:\n name: modified\n"
response = await dashboard.fetch(
"/edit?configuration=test_edit.yaml",
method="POST",
body=new_content.encode(),
)
assert response.code == 200
# Verify the file was actually modified
assert test_file.read_text() == new_content
@pytest.mark.asyncio
async def test_unarchive_request_handler(
dashboard: DashboardTestHelper,
mock_archive_storage_path: MagicMock,
mock_dashboard_settings: MagicMock,
tmp_path: Path,
) -> None:
"""Test the UnArchiveRequestHandler.post method."""
# Set up an archived file
archive_dir = mock_archive_storage_path.return_value
archive_dir.mkdir(parents=True, exist_ok=True)
archived_file = archive_dir / "archived.yaml"
archived_file.write_text("test content")
# Set up the destination path where the file should be moved
config_dir = tmp_path / "config"
config_dir.mkdir(parents=True, exist_ok=True)
destination_file = config_dir / "archived.yaml"
mock_dashboard_settings.rel_path.return_value = destination_file
response = await dashboard.fetch(
"/unarchive?configuration=archived.yaml",
method="POST",
body=b"",
)
assert response.code == 200
# Verify the file was actually moved from archive to config
assert not archived_file.exists() # File should be gone from archive
assert destination_file.exists() # File should now be in config
assert destination_file.read_text() == "test content" # Content preserved
@pytest.mark.asyncio
async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None:
"""Test the SecretKeysRequestHandler.get when no secrets file exists."""
# By default, there's no secrets file in the test fixtures
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch("/secret_keys", method="GET")
assert exc_info.value.code == 404
@pytest.mark.asyncio
async def test_secret_keys_handler_with_file(
dashboard: DashboardTestHelper,
tmp_path: Path,
mock_dashboard_settings: MagicMock,
) -> None:
"""Test the SecretKeysRequestHandler.get when secrets file exists."""
# Create a secrets file in temp directory
secrets_file = tmp_path / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n"
)
# Configure mock to return our temp secrets file
# Since the file actually exists, os.path.isfile will return True naturally
mock_dashboard_settings.rel_path.return_value = secrets_file
response = await dashboard.fetch("/secret_keys", method="GET")
assert response.code == 200
data = json.loads(response.body.decode())
assert "wifi_ssid" in data
assert "wifi_password" in data
assert "api_key" in data
@pytest.mark.asyncio
async def test_json_config_handler(
dashboard: DashboardTestHelper,
mock_async_run_system_command: MagicMock,
) -> None:
"""Test the JsonConfigRequestHandler.get method."""
# This will actually run the esphome config command on pico.yaml
mock_output = json.dumps(
{
"esphome": {"name": "pico"},
"esp32": {"board": "esp32dev"},
}
)
mock_async_run_system_command.return_value = (0, mock_output, "")
response = await dashboard.fetch(
"/json-config?configuration=pico.yaml", method="GET"
)
assert response.code == 200
data = json.loads(response.body.decode())
assert data["esphome"]["name"] == "pico"
@pytest.mark.asyncio
async def test_json_config_handler_invalid_config(
dashboard: DashboardTestHelper,
mock_async_run_system_command: MagicMock,
) -> None:
"""Test the JsonConfigRequestHandler.get with invalid config."""
# Simulate esphome config command failure
mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration")
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET")
assert exc_info.value.code == 422
@pytest.mark.asyncio
async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None:
"""Test the JsonConfigRequestHandler.get with non-existent file."""
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch(
"/json-config?configuration=nonexistent.yaml", method="GET"
)
assert exc_info.value.code == 404
def test_start_web_server_with_address_port(
tmp_path: Path,
mock_trash_storage_path: MagicMock,
mock_archive_storage_path: MagicMock,
) -> None:
"""Test the start_web_server function with address and port."""
app = Mock()
trash_dir = mock_trash_storage_path.return_value
archive_dir = mock_archive_storage_path.return_value
# Create trash dir to test migration
trash_dir.mkdir()
(trash_dir / "old.yaml").write_text("old")
web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config"))
# The function calls app.listen directly for non-socket mode
app.listen.assert_called_once_with(6052, "127.0.0.1")
# Verify trash was moved to archive
assert not trash_dir.exists()
assert archive_dir.exists()
assert (archive_dir / "old.yaml").exists()
@pytest.mark.asyncio
async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None:
"""Test EditRequestHandler.get method."""
# Test getting a valid yaml file
response = await dashboard.fetch("/edit?configuration=pico.yaml")
assert response.code == 200
assert response.headers["content-type"] == "application/yaml"
content = response.body.decode()
assert "esphome:" in content # Verify it's a valid ESPHome config
# Test getting a non-existent file
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch("/edit?configuration=nonexistent.yaml")
assert exc_info.value.code == 404
# Test getting a non-yaml file
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch("/edit?configuration=test.txt")
assert exc_info.value.code == 404
# Test path traversal attempt
with pytest.raises(HTTPClientError) as exc_info:
await dashboard.fetch("/edit?configuration=../../../etc/passwd")
assert exc_info.value.code == 404
@pytest.mark.asyncio
async def test_archive_request_handler_post(
dashboard: DashboardTestHelper,
mock_archive_storage_path: MagicMock,
mock_ext_storage_path: MagicMock,
tmp_path: Path,
) -> None:
"""Test ArchiveRequestHandler.post method without storage_json."""
# Set up temp directories
config_dir = Path(get_fixture_path("conf"))
archive_dir = tmp_path / "archive"
# Create a test configuration file
test_config = config_dir / "test_archive.yaml"
test_config.write_text("esphome:\n name: test_archive\n")
# Archive the configuration
response = await dashboard.fetch(
"/archive",
method="POST",
body="configuration=test_archive.yaml",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert response.code == 200
# Verify file was moved to archive
assert not test_config.exists()
assert (archive_dir / "test_archive.yaml").exists()
assert (
archive_dir / "test_archive.yaml"
).read_text() == "esphome:\n name: test_archive\n"
@pytest.mark.asyncio
async def test_archive_handler_with_build_folder(
dashboard: DashboardTestHelper,
mock_archive_storage_path: MagicMock,
mock_ext_storage_path: MagicMock,
mock_dashboard_settings: MagicMock,
mock_storage_json: MagicMock,
tmp_path: Path,
) -> None:
"""Test ArchiveRequestHandler.post with storage_json and build folder."""
config_dir = tmp_path / "config"
config_dir.mkdir()
archive_dir = tmp_path / "archive"
archive_dir.mkdir()
build_dir = tmp_path / "build"
build_dir.mkdir()
configuration = "test_device.yaml"
test_config = config_dir / configuration
test_config.write_text("esphome:\n name: test_device\n")
build_folder = build_dir / "test_device"
build_folder.mkdir()
(build_folder / "firmware.bin").write_text("binary content")
(build_folder / ".pioenvs").mkdir()
mock_dashboard_settings.config_dir = str(config_dir)
mock_dashboard_settings.rel_path.return_value = test_config
mock_archive_storage_path.return_value = archive_dir
mock_storage = MagicMock()
mock_storage.name = "test_device"
mock_storage.build_path = build_folder
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/archive",
method="POST",
body=f"configuration={configuration}",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert response.code == 200
assert not test_config.exists()
assert (archive_dir / configuration).exists()
assert not build_folder.exists()
assert not (archive_dir / "test_device").exists()
@pytest.mark.asyncio
async def test_archive_handler_no_build_folder(
dashboard: DashboardTestHelper,
mock_archive_storage_path: MagicMock,
mock_ext_storage_path: MagicMock,
mock_dashboard_settings: MagicMock,
mock_storage_json: MagicMock,
tmp_path: Path,
) -> None:
"""Test ArchiveRequestHandler.post with storage_json but no build folder."""
config_dir = tmp_path / "config"
config_dir.mkdir()
archive_dir = tmp_path / "archive"
archive_dir.mkdir()
configuration = "test_device.yaml"
test_config = config_dir / configuration
test_config.write_text("esphome:\n name: test_device\n")
mock_dashboard_settings.config_dir = str(config_dir)
mock_dashboard_settings.rel_path.return_value = test_config
mock_archive_storage_path.return_value = archive_dir
mock_storage = MagicMock()
mock_storage.name = "test_device"
mock_storage.build_path = None
mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch(
"/archive",
method="POST",
body=f"configuration={configuration}",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert response.code == 200
assert not test_config.exists()
assert (archive_dir / configuration).exists()
assert not (archive_dir / "test_device").exists()
@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows")
@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path")
def test_start_web_server_with_unix_socket(tmp_path: Path) -> None:
"""Test the start_web_server function with unix socket."""
app = Mock()
socket_path = tmp_path / "test.sock"
# Don't create trash_dir - it doesn't exist, so no migration needed
with (
patch("tornado.httpserver.HTTPServer") as mock_server_class,
patch("tornado.netutil.bind_unix_socket") as mock_bind,
):
server = Mock()
mock_server_class.return_value = server
mock_bind.return_value = Mock()
web_server.start_web_server(
app, str(socket_path), None, None, str(tmp_path / "config")
)
mock_server_class.assert_called_once_with(app)
mock_bind.assert_called_once_with(str(socket_path), mode=0o666)
server.add_socket.assert_called_once()
def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None:
"""Test with no entry returns empty list."""
result = web_server.build_cache_arguments(None, mock_dashboard, 0.0)
assert result == []
def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None:
"""Test with entry but no address or name."""
entry = Mock(spec=web_server.DashboardEntry)
entry.address = None
entry.name = None
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
assert result == []
def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None:
"""Test with .local address that has cached mDNS results."""
entry = Mock(spec=web_server.DashboardEntry)
entry.address = "device.local"
entry.name = None
mock_dashboard.mdns_status = Mock()
mock_dashboard.mdns_status.get_cached_addresses.return_value = [
"192.168.1.10",
"fe80::1",
]
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
assert result == [
"--mdns-address-cache",
"device.local=192.168.1.10,fe80::1",
]
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
"device.local"
)
def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None:
"""Test with non-.local address that has cached DNS results."""
entry = Mock(spec=web_server.DashboardEntry)
entry.address = "example.com"
entry.name = None
mock_dashboard.dns_cache = Mock()
mock_dashboard.dns_cache.get_cached_addresses.return_value = [
"93.184.216.34",
"2606:2800:220:1:248:1893:25c8:1946",
]
now = 100.0
result = web_server.build_cache_arguments(entry, mock_dashboard, now)
# IPv6 addresses are sorted before IPv4
assert result == [
"--dns-address-cache",
"example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34",
]
mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with(
"example.com", now
)
def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None:
"""Test with name but no address - should check mDNS with .local suffix."""
entry = Mock(spec=web_server.DashboardEntry)
entry.name = "my-device"
entry.address = None
mock_dashboard.mdns_status = Mock()
mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"]
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
assert result == [
"--mdns-address-cache",
"my-device.local=192.168.1.20",
]
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
"my-device.local"
)
@pytest.mark.asyncio
async def test_websocket_connection_initial_state(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket connection and initial state."""
async with websocket_connection(dashboard) as ws:
# Should receive initial state with configured and importable devices
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
assert "devices" in data["data"]
assert "configured" in data["data"]["devices"]
assert "importable" in data["data"]["devices"]
# Check configured devices
configured = data["data"]["devices"]["configured"]
assert len(configured) > 0
assert configured[0]["name"] == "pico" # From test fixtures
@pytest.mark.asyncio
async def test_websocket_ping_pong(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket ping/pong mechanism."""
# Send ping
await websocket_client.write_message(json.dumps({"event": "ping"}))
# Should receive pong
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "pong"
@pytest.mark.asyncio
async def test_websocket_invalid_json(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket handling of invalid JSON."""
# Send invalid JSON
await websocket_client.write_message("not valid json {]")
# Send a valid ping to verify connection is still alive
await websocket_client.write_message(json.dumps({"event": "ping"}))
# Should receive pong, confirming the connection wasn't closed by invalid JSON
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "pong"
@pytest.mark.asyncio
async def test_websocket_authentication_required(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket authentication when auth is required."""
with patch(
"esphome.dashboard.web_server.is_authenticated"
) as mock_is_authenticated:
mock_is_authenticated.return_value = False
# Try to connect - should be rejected with 401
url = f"ws://127.0.0.1:{dashboard.port}/events"
with pytest.raises(HTTPClientError) as exc_info:
await websocket_connect(url)
# Should get HTTP 401 Unauthorized
assert exc_info.value.code == 401
@pytest.mark.asyncio
async def test_websocket_authentication_not_required(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket connection when no auth is required."""
with patch(
"esphome.dashboard.web_server.is_authenticated"
) as mock_is_authenticated:
mock_is_authenticated.return_value = True
# Should be able to connect successfully
async with websocket_connection(dashboard) as ws:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
@pytest.mark.asyncio
async def test_websocket_entry_state_changed(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket entry state changed event."""
# Simulate entry state change
entry = DASHBOARD.entries.async_all()[0]
state = bool_to_entry_state(True, EntryStateSource.MDNS)
DASHBOARD.bus.async_fire(
DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
)
# Should receive state change event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "entry_state_changed"
assert data["data"]["filename"] == entry.filename
assert data["data"]["name"] == entry.name
assert data["data"]["state"] is True
@pytest.mark.asyncio
async def test_websocket_entry_added(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket entry added event."""
# Create a mock entry
mock_entry = Mock(spec=DashboardEntry)
mock_entry.filename = "test.yaml"
mock_entry.name = "test_device"
mock_entry.to_dict.return_value = {
"name": "test_device",
"filename": "test.yaml",
"configuration": "test.yaml",
}
# Simulate entry added
DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry})
# Should receive entry added event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "entry_added"
assert data["data"]["device"]["name"] == "test_device"
assert data["data"]["device"]["filename"] == "test.yaml"
@pytest.mark.asyncio
async def test_websocket_entry_removed(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket entry removed event."""
# Create a mock entry
mock_entry = Mock(spec=DashboardEntry)
mock_entry.filename = "removed.yaml"
mock_entry.name = "removed_device"
mock_entry.to_dict.return_value = {
"name": "removed_device",
"filename": "removed.yaml",
"configuration": "removed.yaml",
}
# Simulate entry removed
DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry})
# Should receive entry removed event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "entry_removed"
assert data["data"]["device"]["name"] == "removed_device"
assert data["data"]["device"]["filename"] == "removed.yaml"
@pytest.mark.asyncio
async def test_websocket_importable_device_added(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket importable device added event with real DiscoveredImport."""
# Create a real DiscoveredImport object
discovered = DiscoveredImport(
device_name="new_import_device",
friendly_name="New Import Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="wifi",
)
# Directly fire the event as the mDNS system would
device_dict = build_importable_device_dict(DASHBOARD, discovered)
DASHBOARD.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict}
)
# Should receive importable device added event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "importable_device_added"
assert data["data"]["device"]["name"] == "new_import_device"
assert data["data"]["device"]["friendly_name"] == "New Import Device"
assert data["data"]["device"]["project_name"] == "test_project"
assert data["data"]["device"]["network"] == "wifi"
assert data["data"]["device"]["ignored"] is False
@pytest.mark.asyncio
async def test_websocket_importable_device_added_ignored(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket importable device added event for ignored device."""
# Add device to ignored list
DASHBOARD.ignored_devices.add("ignored_device")
# Create a real DiscoveredImport object
discovered = DiscoveredImport(
device_name="ignored_device",
friendly_name="Ignored Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="ethernet",
)
# Directly fire the event as the mDNS system would
device_dict = build_importable_device_dict(DASHBOARD, discovered)
DASHBOARD.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict}
)
# Should receive importable device added event with ignored=True
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "importable_device_added"
assert data["data"]["device"]["name"] == "ignored_device"
assert data["data"]["device"]["friendly_name"] == "Ignored Device"
assert data["data"]["device"]["network"] == "ethernet"
assert data["data"]["device"]["ignored"] is True
@pytest.mark.asyncio
async def test_websocket_importable_device_removed(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket importable device removed event."""
# Simulate importable device removed
DASHBOARD.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_REMOVED,
{"name": "removed_import_device"},
)
# Should receive importable device removed event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "importable_device_removed"
assert data["data"]["name"] == "removed_import_device"
@pytest.mark.asyncio
async def test_websocket_importable_device_already_configured(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test that importable device event is not sent if device is already configured."""
# Get an existing configured device name
existing_entry = DASHBOARD.entries.async_all()[0]
# Simulate importable device added with same name as configured device
DASHBOARD.bus.async_fire(
DashboardEvent.IMPORTABLE_DEVICE_ADDED,
{
"device": {
"name": existing_entry.name,
"friendly_name": "Should Not Be Sent",
"package_import_url": "https://example.com/package",
"project_name": "test_project",
"project_version": "1.0.0",
"network": "wifi",
}
},
)
# Send a ping to ensure connection is still alive
await websocket_client.write_message(json.dumps({"event": "ping"}))
# Should only receive pong, not the importable device event
msg = await websocket_client.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "pong"
@pytest.mark.asyncio
async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None:
"""Test multiple WebSocket connections."""
async with (
websocket_connection(dashboard) as ws1,
websocket_connection(dashboard) as ws2,
):
# Both should receive initial state
msg1 = await ws1.read_message()
assert msg1 is not None
data1 = json.loads(msg1)
assert data1["event"] == "initial_state"
msg2 = await ws2.read_message()
assert msg2 is not None
data2 = json.loads(msg2)
assert data2["event"] == "initial_state"
# Fire an event - both should receive it
entry = DASHBOARD.entries.async_all()[0]
state = bool_to_entry_state(False, EntryStateSource.MDNS)
DASHBOARD.bus.async_fire(
DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
)
msg1 = await ws1.read_message()
assert msg1 is not None
data1 = json.loads(msg1)
assert data1["event"] == "entry_state_changed"
msg2 = await ws2.read_message()
assert msg2 is not None
data2 = json.loads(msg2)
assert data2["event"] == "entry_state_changed"
@pytest.mark.asyncio
async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None:
"""Test DashboardSubscriber lifecycle."""
subscriber = DashboardSubscriber()
# Initially no subscribers
assert len(subscriber._subscribers) == 0
assert subscriber._event_loop_task is None
# Add a subscriber
mock_websocket = Mock()
unsubscribe = subscriber.subscribe(mock_websocket)
# Should have started the event loop task
assert len(subscriber._subscribers) == 1
assert subscriber._event_loop_task is not None
# Unsubscribe
unsubscribe()
# Should have stopped the task
assert len(subscriber._subscribers) == 0
@pytest.mark.asyncio
async def test_dashboard_subscriber_entries_update_interval(
dashboard: DashboardTestHelper,
) -> None:
"""Test DashboardSubscriber entries update interval."""
# Patch the constants to make the test run faster
with (
patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01),
patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2),
patch("esphome.dashboard.web_server.settings") as mock_settings,
patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard,
):
mock_settings.status_use_mqtt = False
# Mock dashboard dependencies
mock_dashboard.ping_request = Mock()
mock_dashboard.ping_request.set = Mock()
mock_dashboard.entries = Mock()
mock_dashboard.entries.async_request_update_entries = Mock()
subscriber = DashboardSubscriber()
mock_websocket = Mock()
# Subscribe to start the event loop
unsubscribe = subscriber.subscribe(mock_websocket)
# Wait for a few iterations to ensure entries update is called
await asyncio.sleep(0.05) # Should be enough for 2+ iterations
# Unsubscribe to stop the task
unsubscribe()
# Verify entries update was called
assert mock_dashboard.entries.async_request_update_entries.call_count >= 1
# Verify ping request was set multiple times
assert mock_dashboard.ping_request.set.call_count >= 2
@pytest.mark.asyncio
async def test_websocket_refresh_command(
dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection
) -> None:
"""Test WebSocket refresh command triggers dashboard update."""
with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber:
# Signal an asyncio.Event when request_refresh is invoked so the
# test can deterministically wait for the server-side handler to run
# instead of relying on a fixed sleep (flaky on Windows CI under load).
called = asyncio.Event()
mock_subscriber.request_refresh = Mock(side_effect=called.set)
# Send refresh command
await websocket_client.write_message(json.dumps({"event": "refresh"}))
# Wait for the server to process the message and invoke request_refresh
async with asyncio.timeout(5):
await called.wait()
# Verify request_refresh was called
mock_subscriber.request_refresh.assert_called_once()
@pytest.mark.asyncio
async def test_dashboard_subscriber_refresh_event(
dashboard: DashboardTestHelper,
) -> None:
"""Test DashboardSubscriber refresh event triggers immediate update."""
# Patch the constants to make the test run faster
with (
patch(
"esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0
), # Long timeout
patch(
"esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100
), # Won't reach naturally
patch("esphome.dashboard.web_server.settings") as mock_settings,
patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard,
):
mock_settings.status_use_mqtt = False
# Mock dashboard dependencies
mock_dashboard.ping_request = Mock()
mock_dashboard.ping_request.set = Mock()
mock_dashboard.entries = Mock()
mock_dashboard.entries.async_request_update_entries = AsyncMock()
subscriber = DashboardSubscriber()
mock_websocket = Mock()
# Subscribe to start the event loop
unsubscribe = subscriber.subscribe(mock_websocket)
# Wait a bit to ensure loop is running
await asyncio.sleep(0.01)
# Verify entries update hasn't been called yet (iterations not reached)
assert mock_dashboard.entries.async_request_update_entries.call_count == 0
# Request refresh
subscriber.request_refresh()
# Wait for the refresh to be processed
await asyncio.sleep(0.01)
# Now entries update should have been called
assert mock_dashboard.entries.async_request_update_entries.call_count == 1
# Unsubscribe to stop the task
unsubscribe()
# Give it a moment to clean up
await asyncio.sleep(0.01)
@pytest.mark.asyncio
async def test_dashboard_yaml_loading_with_packages_and_secrets(
tmp_path: Path,
) -> None:
"""Test dashboard YAML loading with packages referencing secrets.
This is a regression test for issue #11280 where binary download failed
when using packages with secrets after the Path migration in 2025.10.0.
This test verifies that CORE.config_path initialization in the dashboard
allows yaml_util.load_yaml() to correctly resolve secrets from packages.
"""
# Create test directory structure with secrets and packages
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create secrets.yaml with obviously fake test values
secrets_file = config_dir / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TEST-DUMMY-SSID\n"
"wifi_password: not-a-real-password-just-for-testing\n"
)
# Create package file that uses secrets
package_file = config_dir / "common.yaml"
package_file.write_text(
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
)
# Create main device config that includes the package
device_config = config_dir / "test-download-secrets.yaml"
device_config.write_text(
"esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n"
"packages:\n common: !include common.yaml\n"
)
# Initialize DASHBOARD settings with our test config directory
# This is what sets CORE.config_path - the critical code path for the bug
args = Namespace(
configuration=str(config_dir),
password=None,
username=None,
ha_addon=False,
verbose=False,
)
DASHBOARD.settings.parse_args(args)
# With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml"
# so CORE.config_path.parent would be config_dir
# Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir
# so CORE.config_path.parent would be tmp_path (the parent of config_dir)
# The fix ensures CORE.config_path.parent points to config_dir
assert CORE.config_path.parent == config_dir.resolve(), (
f"CORE.config_path.parent should point to config_dir. "
f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. "
f"CORE.config_path is {CORE.config_path}"
)
# Now load the YAML with packages that reference secrets
# This is where the bug would manifest - yaml_util.load_yaml would fail
# to find secrets.yaml because CORE.config_path.parent pointed to the wrong place
config = yaml_util.load_yaml(device_config)
# If we get here, secret resolution worked!
assert "esphome" in config
assert config["esphome"]["name"] == "test-download-secrets"
@pytest.mark.asyncio
async def test_websocket_check_origin_default_same_origin(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set."""
# Ensure ESPHOME_TRUSTED_DOMAINS is not set
env = os.environ.copy()
env.pop("ESPHOME_TRUSTED_DOMAINS", None)
with patch.dict(os.environ, env, clear=True):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Same origin should work (default Tornado behavior)
request = HTTPRequest(
url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"}
)
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_trusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from trusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"})
ws = await websocket_connect(request)
try:
# Should receive initial state
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
@pytest.mark.asyncio
async def test_websocket_check_origin_untrusted_domain(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket rejects connections from untrusted domains."""
with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"})
with pytest.raises(HTTPClientError) as exc_info:
await websocket_connect(request)
# Should get HTTP 403 Forbidden due to origin check failure
assert exc_info.value.code == 403
@pytest.mark.asyncio
async def test_websocket_check_origin_multiple_trusted_domains(
dashboard: DashboardTestHelper,
) -> None:
"""Test WebSocket accepts connections from multiple trusted domains."""
with patch.dict(
os.environ,
{"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"},
):
from tornado.httpclient import HTTPRequest
url = f"ws://127.0.0.1:{dashboard.port}/events"
# Test second domain in list (with space after comma)
request = HTTPRequest(url, headers={"Origin": "https://second.example.com"})
ws = await websocket_connect(request)
try:
msg = await ws.read_message()
assert msg is not None
data = json.loads(msg)
assert data["event"] == "initial_state"
finally:
ws.close()
def test_proc_on_exit_calls_close() -> None:
"""Test _proc_on_exit sends exit event and closes the WebSocket."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = False
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_called_once_with({"event": "exit", "code": 0})
handler.close.assert_called_once()
def test_proc_on_exit_skips_when_already_closed() -> None:
"""Test _proc_on_exit does nothing when WebSocket is already closed."""
handler = Mock(spec=EsphomeCommandWebSocket)
handler._is_closed = True
EsphomeCommandWebSocket._proc_on_exit(handler, 0)
handler.write_message.assert_not_called()
handler.close.assert_not_called()
@pytest.mark.asyncio
async def test_esphome_logs_handler_appends_no_states_when_set() -> None:
"""Test --no-states is appended when no_states is truthy in the message."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
json_message = {
"configuration": "device.yaml",
"port": "OTA",
"no_states": True,
}
cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message)
assert cmd == [
"esphome",
"logs",
"device.yaml",
"--device",
"OTA",
"--no-states",
]
handler.build_device_command.assert_awaited_once_with(["logs"], json_message)
@pytest.mark.asyncio
async def test_esphome_logs_handler_omits_no_states_when_missing() -> None:
"""Test --no-states is not added when no_states is absent from the message."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
cmd = await web_server.EsphomeLogsHandler.build_command(
handler, {"configuration": "device.yaml", "port": "OTA"}
)
assert "--no-states" not in cmd
assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"]
@pytest.mark.asyncio
async def test_esphome_logs_handler_omits_no_states_when_false() -> None:
"""Test --no-states is not added when no_states is explicitly False."""
handler = Mock(spec=web_server.EsphomeLogsHandler)
handler.build_device_command = AsyncMock(
return_value=["esphome", "logs", "device.yaml", "--device", "OTA"]
)
cmd = await web_server.EsphomeLogsHandler.build_command(
handler,
{"configuration": "device.yaml", "port": "OTA", "no_states": False},
)
assert "--no-states" not in cmd
def _make_auth_handler(auth_header: str | None = None) -> Mock:
"""Create a mock handler with the given Authorization header."""
handler = Mock()
handler.request = Mock()
if auth_header is not None:
handler.request.headers = {"Authorization": auth_header}
else:
handler.request.headers = {}
handler.get_secure_cookie = Mock(return_value=None)
return handler
@pytest.fixture
def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock:
"""Fixture to configure mock dashboard settings with auth enabled."""
mock_dashboard_settings.using_auth = True
mock_dashboard_settings.on_ha_addon = False
return mock_dashboard_settings
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_malformed_base64() -> None:
"""Test that invalid base64 in Authorization header returns False."""
handler = _make_auth_handler("Basic !!!not-valid-base64!!!")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_bad_base64_padding() -> None:
"""Test that incorrect base64 padding (binascii.Error) returns False."""
handler = _make_auth_handler("Basic abc")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_invalid_utf8() -> None:
"""Test that base64 decoding to invalid UTF-8 returns False."""
# \xff\xfe is invalid UTF-8
bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii")
handler = _make_auth_handler(f"Basic {bad_payload}")
assert web_server.is_authenticated(handler) is False
@pytest.mark.usefixtures("mock_auth_settings")
def test_is_authenticated_no_colon() -> None:
"""Test that base64 payload without ':' separator returns False."""
no_colon = base64.b64encode(b"nocolonhere").decode("ascii")
handler = _make_auth_handler(f"Basic {no_colon}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_valid_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth credentials are checked."""
creds = base64.b64encode(b"admin:secret").decode("ascii")
mock_auth_settings.check_password.return_value = True
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is True
mock_auth_settings.check_password.assert_called_once_with("admin", "secret")
def test_is_authenticated_wrong_credentials(
mock_auth_settings: MagicMock,
) -> None:
"""Test that valid Basic auth with wrong credentials returns False."""
creds = base64.b64encode(b"admin:wrong").decode("ascii")
mock_auth_settings.check_password.return_value = False
handler = _make_auth_handler(f"Basic {creds}")
assert web_server.is_authenticated(handler) is False
def test_is_authenticated_no_auth_configured(
mock_dashboard_settings: MagicMock,
) -> None:
"""Test that requests pass when auth is not configured."""
mock_dashboard_settings.using_auth = False
mock_dashboard_settings.on_ha_addon = False
handler = _make_auth_handler()
assert web_server.is_authenticated(handler) is True

View File

@@ -1,219 +0,0 @@
"""Tests for dashboard web_server Path-related functionality."""
from __future__ import annotations
import gzip
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
from esphome.dashboard import web_server
def test_get_base_frontend_path_production() -> None:
"""Test get_base_frontend_path in production mode."""
mock_module = MagicMock()
mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard")
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
):
result = web_server.get_base_frontend_path()
assert result == Path("/usr/local/lib/esphome_dashboard")
mock_module.where.assert_called_once()
def test_get_base_frontend_path_dev_mode() -> None:
"""Test get_base_frontend_path in development mode."""
test_path = "/home/user/esphome/dashboard"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None:
"""Test get_base_frontend_path in dev mode with trailing slash."""
test_path = "/home/user/esphome/dashboard/"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
expected = (Path.cwd() / test_path / "esphome_dashboard").resolve()
assert result == expected
def test_get_base_frontend_path_dev_mode_relative_path() -> None:
"""Test get_base_frontend_path with relative dev path."""
test_path = "./dashboard"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
assert result.is_absolute()
def test_get_static_path_single_component() -> None:
"""Test get_static_path with single path component."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("file.js")
assert result == Path("/base/frontend") / "static" / "file.js"
def test_get_static_path_multiple_components() -> None:
"""Test get_static_path with multiple path components."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js", "esphome", "index.js")
assert (
result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js"
)
def test_get_static_path_empty_args() -> None:
"""Test get_static_path with no arguments."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path()
assert result == Path("/base/frontend") / "static"
def test_get_static_path_with_pathlib_path() -> None:
"""Test get_static_path with Path objects."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
path_obj = Path("js") / "app.js"
result = web_server.get_static_path(str(path_obj))
assert result == Path("/base/frontend") / "static" / "js" / "app.js"
def test_get_static_file_url_production() -> None:
"""Test get_static_file_url in production mode."""
web_server.get_static_file_url.cache_clear()
mock_module = MagicMock()
mock_path = MagicMock(spec=Path)
mock_path.read_bytes.return_value = b"test content"
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
patch("esphome.dashboard.web_server.get_static_path") as mock_get_path,
):
mock_get_path.return_value = mock_path
result = web_server.get_static_file_url("js/app.js")
assert result.startswith("./static/js/app.js?hash=")
def test_get_static_file_url_dev_mode() -> None:
"""Test get_static_file_url in development mode."""
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}):
web_server.get_static_file_url.cache_clear()
result = web_server.get_static_file_url("js/app.js")
assert result == "./static/js/app.js"
def test_get_static_file_url_index_js_special_case() -> None:
"""Test get_static_file_url replaces index.js with entrypoint."""
web_server.get_static_file_url.cache_clear()
mock_module = MagicMock()
mock_module.entrypoint.return_value = "main.js"
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
):
result = web_server.get_static_file_url("js/esphome/index.js")
assert result == "./static/js/esphome/main.js"
def test_load_file_path(tmp_path: Path) -> None:
"""Test loading a file."""
test_file = tmp_path / "test.txt"
test_file.write_bytes(b"test content")
with test_file.open("rb") as f:
content = f.read()
assert content == b"test content"
def test_load_file_compressed_path(tmp_path: Path) -> None:
"""Test loading a compressed file."""
test_file = tmp_path / "test.txt.gz"
with gzip.open(test_file, "wb") as gz:
gz.write(b"compressed content")
with gzip.open(test_file, "rb") as gz:
content = gz.read()
assert content == b"compressed content"
def test_path_normalization_in_static_path() -> None:
"""Test that paths are normalized correctly."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
# Test with separate components
result1 = web_server.get_static_path("js", "app.js")
result2 = web_server.get_static_path("js", "app.js")
assert result1 == result2
assert result1 == Path("/base/frontend") / "static" / "js" / "app.js"
def test_windows_path_handling() -> None:
"""Test handling of Windows-style paths."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path(r"C:\Program Files\esphome\frontend")
result = web_server.get_static_path("js", "app.js")
# Path should handle this correctly on the platform
expected = (
Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js"
)
assert result == expected
def test_path_with_special_characters() -> None:
"""Test paths with special characters."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js-modules", "app_v1.0.js")
assert (
result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js"
)
def test_path_with_spaces() -> None:
"""Test paths with spaces."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/my frontend")
result = web_server.get_static_path("my js", "my app.js")
assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js"

View File

@@ -562,7 +562,7 @@ def test_determine_integration_tests(
with patch.object(
determine_jobs,
"changed_files",
return_value=["esphome/dashboard/web_server.py"],
return_value=["esphome/analyze_memory/helpers.py"],
):
run_all, test_files = determine_jobs.determine_integration_tests()
assert run_all is False
@@ -914,7 +914,6 @@ def test_should_run_core_ci_with_branch() -> None:
# picks them up because esphome's pyproject sets
# include-package-data = true.
(["esphome/idf_component.yml"], True),
(["esphome/dashboard/templates/index.html"], True),
(["esphome/components/api/api_pb2_service.json"], True),
# Mixed: any triggering file is enough
(["docs/README.md", "esphome/config.py"], True),

View File

@@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected):
assert helpers.friendly_name_slugify(value) == expected
def test_friendly_name_slugify_back_compat_shim():
"""``esphome.dashboard.util.text`` keeps re-exporting for back-compat.
The function moved to ``esphome.helpers`` so the new
device-builder dashboard backend can import it without depending
on the legacy dashboard package, but downstream code that still
imports from the old path keeps working until the dashboard
module is removed.
"""
from esphome.dashboard.util.text import (
friendly_name_slugify as legacy_friendly_name_slugify,
)
assert legacy_friendly_name_slugify is helpers.friendly_name_slugify
@pytest.mark.parametrize(
"host",
(

View File

@@ -33,6 +33,7 @@ from esphome.__main__ import (
command_clean_all,
command_config,
command_config_hash,
command_dashboard,
command_idedata,
command_rename,
command_run,
@@ -3740,6 +3741,45 @@ def test_command_wizard(tmp_path: Path) -> None:
mock_wizard.assert_called_once_with(config_file)
def test_command_dashboard_errors_with_device_builder_redirect() -> None:
"""The removed dashboard command points users to ESPHome Device Builder."""
args = MockArgs()
with pytest.raises(EsphomeError, match="esphome-device-builder"):
command_dashboard(args)
@pytest.mark.parametrize(
"argv",
[
["esphome", "dashboard"],
["esphome", "dashboard", "/config"],
# Legacy flags must be accepted so old invocations reach the redirect
# instead of failing on argparse "unrecognized arguments".
["esphome", "dashboard", "--port", "6052", "/config"],
["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"],
[
"esphome",
"dashboard",
"--address",
"0.0.0.0",
"--socket",
"/x",
"--ha-addon",
],
],
)
def test_run_esphome_dashboard_redirects_to_device_builder(
argv: list[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`esphome dashboard` still parses but fails with the redirect message."""
result = run_esphome(argv)
assert result == 1
assert "esphome-device-builder" in caplog.text
def test_command_config_hash(
tmp_path: Path,
capfd: CaptureFixture[str],