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:
1
.github/scripts/detect-tags.js
vendored
1
.github/scripts/detect-tags.js
vendored
@@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) {
|
||||
*/
|
||||
function hasDashboardChanges(changedFiles) {
|
||||
return changedFiles.some(file =>
|
||||
file.startsWith('esphome/dashboard/') ||
|
||||
file.startsWith('esphome/components/dashboard_import/')
|
||||
);
|
||||
}
|
||||
|
||||
119
.github/workflows/dashboard-deprecation-comment.yml
vendored
119
.github/workflows/dashboard-deprecation-comment.yml
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)), [])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
(
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user