mirror of
https://github.com/esphome/esphome.git
synced 2024-11-26 00:48:19 +01:00
dashboard: Add support for firing events (#5775)
* dashboard: fire events when entry is updated or state changes * dashboard: fire events when entry is updated or state changes * dashboard: fire events when entry is updated or state changes * tweaks * fixes * remove typing_extensions * rename for asyncio * rename for asyncio * rename for asyncio * preen * lint * lint * move dict converter * lint
This commit is contained in:
parent
288af1f4d2
commit
3c243e663f
8 changed files with 251 additions and 78 deletions
8
esphome/dashboard/const.py
Normal file
8
esphome/dashboard/const.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
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"
|
||||||
|
|
||||||
|
SENTINEL = object()
|
|
@ -3,7 +3,9 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import TYPE_CHECKING
|
from dataclasses import dataclass
|
||||||
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
from ..zeroconf import DiscoveredImport
|
from ..zeroconf import DiscoveredImport
|
||||||
from .entries import DashboardEntries
|
from .entries import DashboardEntries
|
||||||
|
@ -12,16 +14,55 @@ from .settings import DashboardSettings
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .status.mdns import MDNSStatus
|
from .status.mdns import MDNSStatus
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
"""Dashboard Event."""
|
||||||
|
|
||||||
|
event_type: str
|
||||||
|
data: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""Dashboard event bus."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the Dashboard event bus."""
|
||||||
|
self._listeners: dict[str, set[Callable[[Event], None]]] = {}
|
||||||
|
|
||||||
|
def async_add_listener(
|
||||||
|
self, event_type: str, 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]
|
||||||
|
) -> 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:
|
||||||
|
"""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 ESPHomeDashboard:
|
||||||
"""Class that represents the dashboard."""
|
"""Class that represents the dashboard."""
|
||||||
|
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
|
"bus",
|
||||||
"entries",
|
"entries",
|
||||||
"loop",
|
"loop",
|
||||||
"ping_result",
|
|
||||||
"import_result",
|
"import_result",
|
||||||
"stop_event",
|
"stop_event",
|
||||||
"ping_request",
|
"ping_request",
|
||||||
|
@ -32,9 +73,9 @@ class ESPHomeDashboard:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize the ESPHomeDashboard."""
|
"""Initialize the ESPHomeDashboard."""
|
||||||
|
self.bus = EventBus()
|
||||||
self.entries: DashboardEntries | None = None
|
self.entries: DashboardEntries | None = None
|
||||||
self.loop: asyncio.AbstractEventLoop | None = None
|
self.loop: asyncio.AbstractEventLoop | None = None
|
||||||
self.ping_result: dict[str, bool | None] = {}
|
|
||||||
self.import_result: dict[str, DiscoveredImport] = {}
|
self.import_result: dict[str, DiscoveredImport] = {}
|
||||||
self.stop_event = threading.Event()
|
self.stop_event = threading.Event()
|
||||||
self.ping_request: asyncio.Event | None = None
|
self.ping_request: asyncio.Event | None = None
|
||||||
|
@ -46,7 +87,7 @@ class ESPHomeDashboard:
|
||||||
"""Setup the dashboard."""
|
"""Setup the dashboard."""
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.ping_request = asyncio.Event()
|
self.ping_request = asyncio.Event()
|
||||||
self.entries = DashboardEntries(self.settings.config_dir)
|
self.entries = DashboardEntries(self)
|
||||||
|
|
||||||
async def async_run(self) -> None:
|
async def async_run(self) -> None:
|
||||||
"""Run the dashboard."""
|
"""Run the dashboard."""
|
||||||
|
|
|
@ -3,24 +3,78 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from esphome import const, util
|
from esphome import const, util
|
||||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
EVENT_ENTRY_ADDED,
|
||||||
|
EVENT_ENTRY_REMOVED,
|
||||||
|
EVENT_ENTRY_STATE_CHANGED,
|
||||||
|
EVENT_ENTRY_UPDATED,
|
||||||
|
)
|
||||||
|
from .enum import StrEnum
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .core import ESPHomeDashboard
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
DashboardCacheKeyType = tuple[int, int, float, int]
|
DashboardCacheKeyType = tuple[int, int, float, int]
|
||||||
|
|
||||||
|
# Currently EntryState is a simple
|
||||||
|
# online/offline/unknown enum, but in the future
|
||||||
|
# it may be expanded to include more states
|
||||||
|
|
||||||
|
|
||||||
|
class EntryState(StrEnum):
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
_BOOL_TO_ENTRY_STATE = {
|
||||||
|
True: EntryState.ONLINE,
|
||||||
|
False: EntryState.OFFLINE,
|
||||||
|
None: EntryState.UNKNOWN,
|
||||||
|
}
|
||||||
|
_ENTRY_STATE_TO_BOOL = {
|
||||||
|
EntryState.ONLINE: True,
|
||||||
|
EntryState.OFFLINE: False,
|
||||||
|
EntryState.UNKNOWN: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bool_to_entry_state(value: bool) -> EntryState:
|
||||||
|
"""Convert a bool to an entry state."""
|
||||||
|
return _BOOL_TO_ENTRY_STATE[value]
|
||||||
|
|
||||||
|
|
||||||
|
def entry_state_to_bool(value: EntryState) -> bool | None:
|
||||||
|
"""Convert an entry state to a bool."""
|
||||||
|
return _ENTRY_STATE_TO_BOOL[value]
|
||||||
|
|
||||||
|
|
||||||
class DashboardEntries:
|
class DashboardEntries:
|
||||||
"""Represents all dashboard entries."""
|
"""Represents all dashboard entries."""
|
||||||
|
|
||||||
__slots__ = ("_loop", "_config_dir", "_entries", "_loaded_entries", "_update_lock")
|
__slots__ = (
|
||||||
|
"_dashboard",
|
||||||
|
"_loop",
|
||||||
|
"_config_dir",
|
||||||
|
"_entries",
|
||||||
|
"_entry_states",
|
||||||
|
"_loaded_entries",
|
||||||
|
"_update_lock",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, config_dir: str) -> None:
|
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
||||||
"""Initialize the DashboardEntries."""
|
"""Initialize the DashboardEntries."""
|
||||||
|
self._dashboard = dashboard
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
self._config_dir = config_dir
|
self._config_dir = dashboard.settings.config_dir
|
||||||
# Entries are stored as
|
# Entries are stored as
|
||||||
# {
|
# {
|
||||||
# "path/to/file.yaml": DashboardEntry,
|
# "path/to/file.yaml": DashboardEntry,
|
||||||
|
@ -46,6 +100,25 @@ class DashboardEntries:
|
||||||
"""Return all entries."""
|
"""Return all entries."""
|
||||||
return list(self._entries.values())
|
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 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(
|
||||||
|
EVENT_ENTRY_STATE_CHANGED, {"entry": entry, "state": state}
|
||||||
|
)
|
||||||
|
|
||||||
async def async_request_update_entries(self) -> None:
|
async def async_request_update_entries(self) -> None:
|
||||||
"""Request an update of the dashboard entries from disk.
|
"""Request an update of the dashboard entries from disk.
|
||||||
|
|
||||||
|
@ -81,16 +154,17 @@ class DashboardEntries:
|
||||||
path_to_cache_key = await self._loop.run_in_executor(
|
path_to_cache_key = await self._loop.run_in_executor(
|
||||||
None, self._get_path_to_cache_key
|
None, self._get_path_to_cache_key
|
||||||
)
|
)
|
||||||
|
entries = self._entries
|
||||||
added: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
added: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
||||||
updated: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
updated: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
||||||
removed: set[DashboardEntry] = {
|
removed: set[DashboardEntry] = {
|
||||||
entry
|
entry
|
||||||
for filename, entry in self._entries.items()
|
for filename, entry in entries.items()
|
||||||
if filename not in path_to_cache_key
|
if filename not in path_to_cache_key
|
||||||
}
|
}
|
||||||
entries = self._entries
|
|
||||||
for path, cache_key in path_to_cache_key.items():
|
for path, cache_key in path_to_cache_key.items():
|
||||||
if entry := self._entries.get(path):
|
if entry := entries.get(path):
|
||||||
if entry.cache_key != cache_key:
|
if entry.cache_key != cache_key:
|
||||||
updated[entry] = cache_key
|
updated[entry] = cache_key
|
||||||
else:
|
else:
|
||||||
|
@ -102,17 +176,17 @@ class DashboardEntries:
|
||||||
None, self._load_entries, {**added, **updated}
|
None, self._load_entries, {**added, **updated}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bus = self._dashboard.bus
|
||||||
for entry in added:
|
for entry in added:
|
||||||
_LOGGER.debug("Added dashboard entry %s", entry.path)
|
|
||||||
entries[entry.path] = entry
|
entries[entry.path] = entry
|
||||||
|
bus.async_fire(EVENT_ENTRY_ADDED, {"entry": entry})
|
||||||
|
|
||||||
if entry in removed:
|
for entry in removed:
|
||||||
_LOGGER.debug("Removed dashboard entry %s", entry.path)
|
del entries[entry.path]
|
||||||
entries.pop(entry.path)
|
bus.async_fire(EVENT_ENTRY_REMOVED, {"entry": entry})
|
||||||
|
|
||||||
for entry in updated:
|
for entry in updated:
|
||||||
_LOGGER.debug("Updated dashboard entry %s", entry.path)
|
bus.async_fire(EVENT_ENTRY_UPDATED, {"entry": entry})
|
||||||
# In the future we can fire events when entries are added/removed/updated
|
|
||||||
|
|
||||||
def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]:
|
def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]:
|
||||||
"""Return a dict of path to cache key."""
|
"""Return a dict of path to cache key."""
|
||||||
|
@ -152,29 +226,64 @@ class DashboardEntry:
|
||||||
This class is thread-safe and read-only.
|
This class is thread-safe and read-only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("path", "filename", "_storage_path", "cache_key", "storage")
|
__slots__ = (
|
||||||
|
"path",
|
||||||
|
"filename",
|
||||||
|
"_storage_path",
|
||||||
|
"cache_key",
|
||||||
|
"storage",
|
||||||
|
"state",
|
||||||
|
"_to_dict",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None:
|
def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None:
|
||||||
"""Initialize the DashboardEntry."""
|
"""Initialize the DashboardEntry."""
|
||||||
self.path = path
|
self.path = path
|
||||||
self.filename = os.path.basename(path)
|
self.filename: str = os.path.basename(path)
|
||||||
self._storage_path = ext_storage_path(self.filename)
|
self._storage_path = ext_storage_path(self.filename)
|
||||||
self.cache_key = cache_key
|
self.cache_key = cache_key
|
||||||
self.storage: StorageJSON | None = None
|
self.storage: StorageJSON | None = None
|
||||||
|
self.state = EntryState.UNKNOWN
|
||||||
|
self._to_dict: dict[str, Any] | None = None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""Return the representation of this entry."""
|
"""Return the representation of this entry."""
|
||||||
return (
|
return (
|
||||||
f"DashboardEntry({self.path} "
|
f"DashboardEntry(path={self.path} "
|
||||||
f"address={self.address} "
|
f"address={self.address} "
|
||||||
f"web_port={self.web_port} "
|
f"web_port={self.web_port} "
|
||||||
f"name={self.name} "
|
f"name={self.name} "
|
||||||
f"no_mdns={self.no_mdns})"
|
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": self.loaded_integrations,
|
||||||
|
"deployed_version": self.update_old,
|
||||||
|
"current_version": self.update_new,
|
||||||
|
"path": 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:
|
def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None:
|
||||||
"""Load this entry from disk."""
|
"""Load this entry from disk."""
|
||||||
self.storage = StorageJSON.load(self._storage_path)
|
self.storage = StorageJSON.load(self._storage_path)
|
||||||
|
self._to_dict = None
|
||||||
#
|
#
|
||||||
# Currently StorageJSON.load() will return None if the file does not exist
|
# Currently StorageJSON.load() will return None if the file does not exist
|
||||||
#
|
#
|
||||||
|
|
19
esphome/dashboard/enum.py
Normal file
19
esphome/dashboard/enum.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
"""Enum backports from standard lib."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class StrEnum(str, Enum):
|
||||||
|
"""Partial backport of Python 3.11's StrEnum for our basic use cases."""
|
||||||
|
|
||||||
|
def __new__(cls, value: str, *args: Any, **kwargs: Any) -> StrEnum:
|
||||||
|
"""Create a new StrEnum instance."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise TypeError(f"{value!r} is not a string")
|
||||||
|
return super().__new__(cls, value, *args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return self.value."""
|
||||||
|
return str(self.value)
|
|
@ -10,7 +10,9 @@ from esphome.zeroconf import (
|
||||||
DashboardStatus,
|
DashboardStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..const import SENTINEL
|
||||||
from ..core import DASHBOARD
|
from ..core import DASHBOARD
|
||||||
|
from ..entries import bool_to_entry_state
|
||||||
|
|
||||||
|
|
||||||
class MDNSStatus:
|
class MDNSStatus:
|
||||||
|
@ -22,16 +24,16 @@ class MDNSStatus:
|
||||||
self.aiozc: AsyncEsphomeZeroconf | None = None
|
self.aiozc: AsyncEsphomeZeroconf | None = None
|
||||||
# This is the current mdns state for each host (True, False, None)
|
# This is the current mdns state for each host (True, False, None)
|
||||||
self.host_mdns_state: dict[str, bool | None] = {}
|
self.host_mdns_state: dict[str, bool | None] = {}
|
||||||
# This is the hostnames to filenames mapping
|
# This is the hostnames to path mapping
|
||||||
self.host_name_to_filename: dict[str, str] = {}
|
self.host_name_to_path: dict[str, str] = {}
|
||||||
self.filename_to_host_name: dict[str, str] = {}
|
self.path_to_host_name: dict[str, str] = {}
|
||||||
# This is a set of host names to track (i.e no_mdns = false)
|
# This is a set of host names to track (i.e no_mdns = false)
|
||||||
self.host_name_with_mdns_enabled: set[set] = set()
|
self.host_name_with_mdns_enabled: set[set] = set()
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
def filename_to_host_name_thread_safe(self, filename: str) -> str | None:
|
def get_path_to_host_name(self, path: str) -> str | None:
|
||||||
"""Resolve a filename to an address in a thread-safe manner."""
|
"""Resolve a path to an address in a thread-safe manner."""
|
||||||
return self.filename_to_host_name.get(filename)
|
return self.path_to_host_name.get(path)
|
||||||
|
|
||||||
async def async_resolve_host(self, host_name: str) -> str | None:
|
async def async_resolve_host(self, host_name: str) -> str | None:
|
||||||
"""Resolve a host name to an address in a thread-safe manner."""
|
"""Resolve a host name to an address in a thread-safe manner."""
|
||||||
|
@ -42,14 +44,14 @@ class MDNSStatus:
|
||||||
async def async_refresh_hosts(self):
|
async def async_refresh_hosts(self):
|
||||||
"""Refresh the hosts to track."""
|
"""Refresh the hosts to track."""
|
||||||
dashboard = DASHBOARD
|
dashboard = DASHBOARD
|
||||||
entries = dashboard.entries.async_all()
|
current_entries = dashboard.entries.async_all()
|
||||||
host_name_with_mdns_enabled = self.host_name_with_mdns_enabled
|
host_name_with_mdns_enabled = self.host_name_with_mdns_enabled
|
||||||
host_mdns_state = self.host_mdns_state
|
host_mdns_state = self.host_mdns_state
|
||||||
host_name_to_filename = self.host_name_to_filename
|
host_name_to_path = self.host_name_to_path
|
||||||
filename_to_host_name = self.filename_to_host_name
|
path_to_host_name = self.path_to_host_name
|
||||||
ping_result = dashboard.ping_result
|
entries = dashboard.entries
|
||||||
|
|
||||||
for entry in entries:
|
for entry in current_entries:
|
||||||
name = entry.name
|
name = entry.name
|
||||||
# If no_mdns is set, remove it from the set
|
# If no_mdns is set, remove it from the set
|
||||||
if entry.no_mdns:
|
if entry.no_mdns:
|
||||||
|
@ -58,37 +60,37 @@ class MDNSStatus:
|
||||||
|
|
||||||
# We are tracking this host
|
# We are tracking this host
|
||||||
host_name_with_mdns_enabled.add(name)
|
host_name_with_mdns_enabled.add(name)
|
||||||
filename = entry.filename
|
path = entry.path
|
||||||
|
|
||||||
# If we just adopted/imported this host, we likely
|
# If we just adopted/imported this host, we likely
|
||||||
# already have a state for it, so we should make sure
|
# already have a state for it, so we should make sure
|
||||||
# to set it so the dashboard shows it as online
|
# to set it so the dashboard shows it as online
|
||||||
if name in host_mdns_state:
|
if (online := host_mdns_state.get(name, SENTINEL)) != SENTINEL:
|
||||||
ping_result[filename] = host_mdns_state[name]
|
entries.async_set_state(entry, bool_to_entry_state(online))
|
||||||
|
|
||||||
# Make sure the mapping is up to date
|
# Make sure the mapping is up to date
|
||||||
# so when we get an mdns update we can map it back
|
# so when we get an mdns update we can map it back
|
||||||
# to the filename
|
# to the filename
|
||||||
host_name_to_filename[name] = filename
|
host_name_to_path[name] = path
|
||||||
filename_to_host_name[filename] = name
|
path_to_host_name[path] = name
|
||||||
|
|
||||||
async def async_run(self) -> None:
|
async def async_run(self) -> None:
|
||||||
dashboard = DASHBOARD
|
dashboard = DASHBOARD
|
||||||
|
entries = dashboard.entries
|
||||||
aiozc = AsyncEsphomeZeroconf()
|
aiozc = AsyncEsphomeZeroconf()
|
||||||
self.aiozc = aiozc
|
self.aiozc = aiozc
|
||||||
host_mdns_state = self.host_mdns_state
|
host_mdns_state = self.host_mdns_state
|
||||||
host_name_to_filename = self.host_name_to_filename
|
host_name_to_path = self.host_name_to_path
|
||||||
host_name_with_mdns_enabled = self.host_name_with_mdns_enabled
|
host_name_with_mdns_enabled = self.host_name_with_mdns_enabled
|
||||||
ping_result = dashboard.ping_result
|
|
||||||
|
|
||||||
def on_update(dat: dict[str, bool | None]) -> None:
|
def on_update(dat: dict[str, bool | None]) -> None:
|
||||||
"""Update the global PING_RESULT dict."""
|
"""Update the entry state."""
|
||||||
for name, result in dat.items():
|
for name, result in dat.items():
|
||||||
host_mdns_state[name] = result
|
host_mdns_state[name] = result
|
||||||
if name in host_name_with_mdns_enabled:
|
if name not in host_name_with_mdns_enabled:
|
||||||
filename = host_name_to_filename[name]
|
continue
|
||||||
ping_result[filename] = result
|
if entry := entries.get(host_name_to_path[name]):
|
||||||
|
entries.async_set_state(entry, bool_to_entry_state(result))
|
||||||
|
|
||||||
stat = DashboardStatus(on_update)
|
stat = DashboardStatus(on_update)
|
||||||
imports = DashboardImportDiscovery()
|
imports = DashboardImportDiscovery()
|
||||||
|
|
|
@ -8,6 +8,7 @@ import threading
|
||||||
from esphome import mqtt
|
from esphome import mqtt
|
||||||
|
|
||||||
from ..core import DASHBOARD
|
from ..core import DASHBOARD
|
||||||
|
from ..entries import EntryState
|
||||||
|
|
||||||
|
|
||||||
class MqttStatusThread(threading.Thread):
|
class MqttStatusThread(threading.Thread):
|
||||||
|
@ -16,22 +17,23 @@ class MqttStatusThread(threading.Thread):
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Run the status thread."""
|
"""Run the status thread."""
|
||||||
dashboard = DASHBOARD
|
dashboard = DASHBOARD
|
||||||
entries = dashboard.entries.all()
|
entries = dashboard.entries
|
||||||
|
current_entries = entries.all()
|
||||||
|
|
||||||
config = mqtt.config_from_env()
|
config = mqtt.config_from_env()
|
||||||
topic = "esphome/discover/#"
|
topic = "esphome/discover/#"
|
||||||
|
|
||||||
def on_message(client, userdata, msg):
|
def on_message(client, userdata, msg):
|
||||||
nonlocal entries
|
nonlocal current_entries
|
||||||
|
|
||||||
payload = msg.payload.decode(errors="backslashreplace")
|
payload = msg.payload.decode(errors="backslashreplace")
|
||||||
if len(payload) > 0:
|
if len(payload) > 0:
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
if "name" not in data:
|
if "name" not in data:
|
||||||
return
|
return
|
||||||
for entry in entries:
|
for entry in current_entries:
|
||||||
if entry.name == data["name"]:
|
if entry.name == data["name"]:
|
||||||
dashboard.ping_result[entry.filename] = True
|
entries.set_state(entry, EntryState.ONLINE)
|
||||||
return
|
return
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, return_code):
|
def on_connect(client, userdata, flags, return_code):
|
||||||
|
@ -51,12 +53,11 @@ class MqttStatusThread(threading.Thread):
|
||||||
client.loop_start()
|
client.loop_start()
|
||||||
|
|
||||||
while not dashboard.stop_event.wait(2):
|
while not dashboard.stop_event.wait(2):
|
||||||
entries = dashboard.entries.all()
|
current_entries = entries.all()
|
||||||
|
|
||||||
# will be set to true on on_message
|
# will be set to true on on_message
|
||||||
for entry in entries:
|
for entry in current_entries:
|
||||||
if entry.no_mdns:
|
if entry.no_mdns:
|
||||||
dashboard.ping_result[entry.filename] = False
|
entries.set_state(entry, EntryState.OFFLINE)
|
||||||
|
|
||||||
client.publish("esphome/discover", None, retain=False)
|
client.publish("esphome/discover", None, retain=False)
|
||||||
dashboard.mqtt_ping_request.wait()
|
dashboard.mqtt_ping_request.wait()
|
||||||
|
|
|
@ -5,7 +5,7 @@ import os
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from ..core import DASHBOARD
|
from ..core import DASHBOARD
|
||||||
from ..entries import DashboardEntry
|
from ..entries import DashboardEntry, bool_to_entry_state
|
||||||
from ..util.itertools import chunked
|
from ..util.itertools import chunked
|
||||||
from ..util.subprocess import async_system_command_status
|
from ..util.subprocess import async_system_command_status
|
||||||
|
|
||||||
|
@ -26,14 +26,14 @@ class PingStatus:
|
||||||
async def async_run(self) -> None:
|
async def async_run(self) -> None:
|
||||||
"""Run the ping status."""
|
"""Run the ping status."""
|
||||||
dashboard = DASHBOARD
|
dashboard = DASHBOARD
|
||||||
|
entries = dashboard.entries
|
||||||
|
|
||||||
while not dashboard.stop_event.is_set():
|
while not dashboard.stop_event.is_set():
|
||||||
# Only ping if the dashboard is open
|
# Only ping if the dashboard is open
|
||||||
await dashboard.ping_request.wait()
|
await dashboard.ping_request.wait()
|
||||||
dashboard.ping_result.clear()
|
current_entries = dashboard.entries.async_all()
|
||||||
entries = dashboard.entries.async_all()
|
|
||||||
to_ping: list[DashboardEntry] = [
|
to_ping: list[DashboardEntry] = [
|
||||||
entry for entry in entries if entry.address is not None
|
entry for entry in current_entries if entry.address is not None
|
||||||
]
|
]
|
||||||
for ping_group in chunked(to_ping, 16):
|
for ping_group in chunked(to_ping, 16):
|
||||||
ping_group = cast(list[DashboardEntry], ping_group)
|
ping_group = cast(list[DashboardEntry], ping_group)
|
||||||
|
@ -46,4 +46,4 @@ class PingStatus:
|
||||||
result = False
|
result = False
|
||||||
elif isinstance(result, BaseException):
|
elif isinstance(result, BaseException):
|
||||||
raise result
|
raise result
|
||||||
dashboard.ping_result[entry.filename] = result
|
entries.async_set_state(entry, bool_to_entry_state(result))
|
||||||
|
|
|
@ -37,6 +37,7 @@ from esphome.util import get_serial_ports, shlex_quote
|
||||||
from esphome.yaml_util import FastestAvailableSafeLoader
|
from esphome.yaml_util import FastestAvailableSafeLoader
|
||||||
|
|
||||||
from .core import DASHBOARD
|
from .core import DASHBOARD
|
||||||
|
from .entries import EntryState, entry_state_to_bool
|
||||||
from .util.subprocess import async_run_system_command
|
from .util.subprocess import async_run_system_command
|
||||||
from .util.text import friendly_name_slugify
|
from .util.text import friendly_name_slugify
|
||||||
|
|
||||||
|
@ -275,7 +276,7 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
|
||||||
if (
|
if (
|
||||||
port == "OTA"
|
port == "OTA"
|
||||||
and (mdns := dashboard.mdns_status)
|
and (mdns := dashboard.mdns_status)
|
||||||
and (host_name := mdns.filename_to_host_name_thread_safe(configuration))
|
and (host_name := mdns.get_path_to_host_name(config_file))
|
||||||
and (address := await mdns.async_resolve_host(host_name))
|
and (address := await mdns.async_resolve_host(host_name))
|
||||||
):
|
):
|
||||||
port = address
|
port = address
|
||||||
|
@ -315,7 +316,9 @@ class EsphomeRenameHandler(EsphomeCommandWebSocket):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove the old ping result from the cache
|
# Remove the old ping result from the cache
|
||||||
DASHBOARD.ping_result.pop(self.old_name, None)
|
entries = DASHBOARD.entries
|
||||||
|
if entry := entries.get(self.old_name):
|
||||||
|
entries.async_set_state(entry, EntryState.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
|
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
|
||||||
|
@ -609,22 +612,7 @@ class ListDevicesHandler(BaseHandler):
|
||||||
self.write(
|
self.write(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"configured": [
|
"configured": [entry.to_dict() for entry in entries],
|
||||||
{
|
|
||||||
"name": entry.name,
|
|
||||||
"friendly_name": entry.friendly_name,
|
|
||||||
"configuration": entry.filename,
|
|
||||||
"loaded_integrations": entry.loaded_integrations,
|
|
||||||
"deployed_version": entry.update_old,
|
|
||||||
"current_version": entry.update_new,
|
|
||||||
"path": entry.path,
|
|
||||||
"comment": entry.comment,
|
|
||||||
"address": entry.address,
|
|
||||||
"web_port": entry.web_port,
|
|
||||||
"target_platform": entry.target_platform,
|
|
||||||
}
|
|
||||||
for entry in entries
|
|
||||||
],
|
|
||||||
"importable": [
|
"importable": [
|
||||||
{
|
{
|
||||||
"name": res.device_name,
|
"name": res.device_name,
|
||||||
|
@ -728,7 +716,15 @@ class PingRequestHandler(BaseHandler):
|
||||||
if settings.status_use_mqtt:
|
if settings.status_use_mqtt:
|
||||||
dashboard.mqtt_ping_request.set()
|
dashboard.mqtt_ping_request.set()
|
||||||
self.set_header("content-type", "application/json")
|
self.set_header("content-type", "application/json")
|
||||||
self.write(json.dumps(dashboard.ping_result))
|
|
||||||
|
self.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
entry.filename: entry_state_to_bool(entry.state)
|
||||||
|
for entry in dashboard.entries.async_all()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InfoRequestHandler(BaseHandler):
|
class InfoRequestHandler(BaseHandler):
|
||||||
|
@ -785,9 +781,6 @@ class DeleteRequestHandler(BaseHandler):
|
||||||
if build_folder is not None:
|
if build_folder is not None:
|
||||||
shutil.rmtree(build_folder, os.path.join(trash_path, name))
|
shutil.rmtree(build_folder, os.path.join(trash_path, name))
|
||||||
|
|
||||||
# Remove the old ping result from the cache
|
|
||||||
DASHBOARD.ping_result.pop(configuration, None)
|
|
||||||
|
|
||||||
|
|
||||||
class UndoDeleteRequestHandler(BaseHandler):
|
class UndoDeleteRequestHandler(BaseHandler):
|
||||||
@authenticated
|
@authenticated
|
||||||
|
|
Loading…
Reference in a new issue