mirror of
https://github.com/esphome/esphome.git
synced 2025-10-06 20:03:46 +01:00
[dashboard] Replace polling with WebSocket for real-time updates (#10893)
This commit is contained in:
@@ -1,9 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
EVENT_ENTRY_ADDED = "entry_added"
|
||||
EVENT_ENTRY_REMOVED = "entry_removed"
|
||||
EVENT_ENTRY_UPDATED = "entry_updated"
|
||||
EVENT_ENTRY_STATE_CHANGED = "entry_state_changed"
|
||||
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
|
||||
|
||||
|
||||
|
@@ -13,6 +13,7 @@ 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
|
||||
@@ -30,7 +31,7 @@ MDNS_BOOTSTRAP_TIME = 7.5
|
||||
class Event:
|
||||
"""Dashboard Event."""
|
||||
|
||||
event_type: str
|
||||
event_type: DashboardEvent
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
@@ -39,22 +40,24 @@ class EventBus:
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Dashboard event bus."""
|
||||
self._listeners: dict[str, set[Callable[[Event], None]]] = {}
|
||||
self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {}
|
||||
|
||||
def async_add_listener(
|
||||
self, event_type: str, listener: Callable[[Event], None]
|
||||
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: str, listener: Callable[[Event], None]
|
||||
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: str, event_data: dict[str, Any]) -> None:
|
||||
def async_fire(
|
||||
self, event_type: DashboardEvent, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Fire an event."""
|
||||
event = Event(event_type, event_data)
|
||||
|
||||
|
@@ -12,13 +12,7 @@ from esphome import const, util
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
|
||||
from .const import (
|
||||
DASHBOARD_COMMAND,
|
||||
EVENT_ENTRY_ADDED,
|
||||
EVENT_ENTRY_REMOVED,
|
||||
EVENT_ENTRY_STATE_CHANGED,
|
||||
EVENT_ENTRY_UPDATED,
|
||||
)
|
||||
from .const import DASHBOARD_COMMAND, DashboardEvent
|
||||
from .util.subprocess import async_run_system_command
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -102,12 +96,12 @@ class DashboardEntries:
|
||||
# "path/to/file.yaml": DashboardEntry,
|
||||
# ...
|
||||
# }
|
||||
self._entries: dict[str, 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: str) -> DashboardEntry | None:
|
||||
def get(self, path: Path) -> DashboardEntry | None:
|
||||
"""Get an entry by path."""
|
||||
return self._entries.get(path)
|
||||
|
||||
@@ -192,7 +186,7 @@ class DashboardEntries:
|
||||
return
|
||||
entry.state = state
|
||||
self._dashboard.bus.async_fire(
|
||||
EVENT_ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
|
||||
DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
|
||||
)
|
||||
|
||||
async def async_request_update_entries(self) -> None:
|
||||
@@ -260,22 +254,22 @@ class DashboardEntries:
|
||||
for entry in added:
|
||||
entries[entry.path] = entry
|
||||
name_to_entry[entry.name].add(entry)
|
||||
bus.async_fire(EVENT_ENTRY_ADDED, {"entry": 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(EVENT_ENTRY_REMOVED, {"entry": 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(EVENT_ENTRY_UPDATED, {"entry": entry})
|
||||
bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry})
|
||||
|
||||
def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]:
|
||||
def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]:
|
||||
"""Return a dict of path to cache key."""
|
||||
path_to_cache_key: dict[str, DashboardCacheKeyType] = {}
|
||||
path_to_cache_key: dict[Path, DashboardCacheKeyType] = {}
|
||||
#
|
||||
# The cache key is (inode, device, mtime, size)
|
||||
# which allows us to avoid locking since it ensures
|
||||
|
76
esphome/dashboard/models.py
Normal file
76
esphome/dashboard/models.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""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
|
||||
],
|
||||
)
|
@@ -13,10 +13,12 @@ from esphome.zeroconf import (
|
||||
DashboardBrowser,
|
||||
DashboardImportDiscovery,
|
||||
DashboardStatus,
|
||||
DiscoveredImport,
|
||||
)
|
||||
|
||||
from ..const import SENTINEL
|
||||
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
|
||||
@@ -77,6 +79,20 @@ class MDNSStatus:
|
||||
_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
|
||||
@@ -133,7 +149,8 @@ class MDNSStatus:
|
||||
self._async_set_state(entry, result)
|
||||
|
||||
stat = DashboardStatus(on_update)
|
||||
imports = DashboardImportDiscovery()
|
||||
|
||||
imports = DashboardImportDiscovery(self._on_import_update)
|
||||
dashboard.import_result = imports.import_state
|
||||
|
||||
browser = DashboardBrowser(
|
||||
|
@@ -4,8 +4,10 @@ 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
|
||||
@@ -50,9 +52,10 @@ 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
|
||||
from .core import DASHBOARD, ESPHomeDashboard
|
||||
from .const import DASHBOARD_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
|
||||
|
||||
@@ -520,6 +523,243 @@ class EsphomeUpdateAllHandler(EsphomeCommandWebSocket):
|
||||
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(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:
|
||||
@@ -874,28 +1114,7 @@ class ListDevicesHandler(BaseHandler):
|
||||
await dashboard.entries.async_request_update_entries()
|
||||
entries = dashboard.entries.async_all()
|
||||
self.set_header("content-type", "application/json")
|
||||
configured = {entry.name for entry in entries}
|
||||
|
||||
self.write(
|
||||
json.dumps(
|
||||
{
|
||||
"configured": [entry.to_dict() for entry in entries],
|
||||
"importable": [
|
||||
{
|
||||
"name": res.device_name,
|
||||
"friendly_name": res.friendly_name,
|
||||
"package_import_url": res.package_import_url,
|
||||
"project_name": res.project_name,
|
||||
"project_version": res.project_version,
|
||||
"network": res.network,
|
||||
"ignored": res.device_name in dashboard.ignored_devices,
|
||||
}
|
||||
for res in dashboard.import_result.values()
|
||||
if res.device_name not in configured
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
self.write(json.dumps(build_device_list_response(dashboard, entries)))
|
||||
|
||||
|
||||
class MainRequestHandler(BaseHandler):
|
||||
@@ -1351,6 +1570,7 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application:
|
||||
(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),
|
||||
|
@@ -68,8 +68,11 @@ class DashboardBrowser(AsyncServiceBrowser):
|
||||
|
||||
|
||||
class DashboardImportDiscovery:
|
||||
def __init__(self) -> None:
|
||||
def __init__(
|
||||
self, on_update: Callable[[str, DiscoveredImport | None], None] | None = None
|
||||
) -> None:
|
||||
self.import_state: dict[str, DiscoveredImport] = {}
|
||||
self.on_update = on_update
|
||||
|
||||
def browser_callback(
|
||||
self,
|
||||
@@ -85,7 +88,9 @@ class DashboardImportDiscovery:
|
||||
state_change,
|
||||
)
|
||||
if state_change == ServiceStateChange.Removed:
|
||||
self.import_state.pop(name, None)
|
||||
removed = self.import_state.pop(name, None)
|
||||
if removed and self.on_update:
|
||||
self.on_update(name, None)
|
||||
return
|
||||
|
||||
if state_change == ServiceStateChange.Updated and name not in self.import_state:
|
||||
@@ -139,7 +144,7 @@ class DashboardImportDiscovery:
|
||||
if friendly_name is not None:
|
||||
friendly_name = friendly_name.decode()
|
||||
|
||||
self.import_state[name] = DiscoveredImport(
|
||||
discovered = DiscoveredImport(
|
||||
friendly_name=friendly_name,
|
||||
device_name=node_name,
|
||||
package_import_url=import_url,
|
||||
@@ -147,6 +152,10 @@ class DashboardImportDiscovery:
|
||||
project_version=project_version,
|
||||
network=network,
|
||||
)
|
||||
is_new = name not in self.import_state
|
||||
self.import_state[name] = discovered
|
||||
if is_new and self.on_update:
|
||||
self.on_update(name, discovered)
|
||||
|
||||
def update_device_mdns(self, node_name: str, version: str):
|
||||
storage_path = ext_storage_path(node_name + ".yaml")
|
||||
|
Reference in New Issue
Block a user