mirror of
https://github.com/Threnklyn/esphome-dev.git
synced 2026-05-18 20:23:27 +02:00
149d814fab
* Centralize dashboard entries into DashboardEntries class * preen * preen * preen * preen * preen
263 lines
8.8 KiB
Python
263 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
|
|
from esphome import const, util
|
|
from esphome.storage_json import StorageJSON, ext_storage_path
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
DashboardCacheKeyType = tuple[int, int, float, int]
|
|
|
|
|
|
class DashboardEntries:
|
|
"""Represents all dashboard entries."""
|
|
|
|
__slots__ = ("_loop", "_config_dir", "_entries", "_loaded_entries", "_update_lock")
|
|
|
|
def __init__(self, config_dir: str) -> None:
|
|
"""Initialize the DashboardEntries."""
|
|
self._loop = asyncio.get_running_loop()
|
|
self._config_dir = config_dir
|
|
# Entries are stored as
|
|
# {
|
|
# "path/to/file.yaml": DashboardEntry,
|
|
# ...
|
|
# }
|
|
self._entries: dict[str, DashboardEntry] = {}
|
|
self._loaded_entries = False
|
|
self._update_lock = asyncio.Lock()
|
|
|
|
def get(self, path: str) -> DashboardEntry | None:
|
|
"""Get an entry by path."""
|
|
return self._entries.get(path)
|
|
|
|
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())
|
|
|
|
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
|
|
)
|
|
added: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
|
updated: dict[DashboardEntry, DashboardCacheKeyType] = {}
|
|
removed: set[DashboardEntry] = {
|
|
entry
|
|
for filename, entry in self._entries.items()
|
|
if filename not in path_to_cache_key
|
|
}
|
|
entries = self._entries
|
|
for path, cache_key in path_to_cache_key.items():
|
|
if entry := self._entries.get(path):
|
|
if entry.cache_key != cache_key:
|
|
updated[entry] = cache_key
|
|
else:
|
|
entry = DashboardEntry(path, cache_key)
|
|
added[entry] = cache_key
|
|
|
|
if added or updated:
|
|
await self._loop.run_in_executor(
|
|
None, self._load_entries, {**added, **updated}
|
|
)
|
|
|
|
for entry in added:
|
|
_LOGGER.debug("Added dashboard entry %s", entry.path)
|
|
entries[entry.path] = entry
|
|
|
|
if entry in removed:
|
|
_LOGGER.debug("Removed dashboard entry %s", entry.path)
|
|
entries.pop(entry.path)
|
|
|
|
for entry in updated:
|
|
_LOGGER.debug("Updated dashboard entry %s", entry.path)
|
|
# In the future we can fire events when entries are added/removed/updated
|
|
|
|
def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]:
|
|
"""Return a dict of path to cache key."""
|
|
path_to_cache_key: dict[str, 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 = os.stat(ext_storage_path(os.path.basename(file)))
|
|
except OSError:
|
|
try:
|
|
# Fallback to the yaml file if the storage
|
|
# file does not exist or could not be generated
|
|
stat = os.stat(file)
|
|
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
|
|
|
|
|
|
class DashboardEntry:
|
|
"""Represents a single dashboard entry.
|
|
|
|
This class is thread-safe and read-only.
|
|
"""
|
|
|
|
__slots__ = ("path", "filename", "_storage_path", "cache_key", "storage")
|
|
|
|
def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None:
|
|
"""Initialize the DashboardEntry."""
|
|
self.path = path
|
|
self.filename = os.path.basename(path)
|
|
self._storage_path = ext_storage_path(self.filename)
|
|
self.cache_key = cache_key
|
|
self.storage: StorageJSON | None = None
|
|
|
|
def __repr__(self):
|
|
"""Return the representation of this entry."""
|
|
return (
|
|
f"DashboardEntry({self.path} "
|
|
f"address={self.address} "
|
|
f"web_port={self.web_port} "
|
|
f"name={self.name} "
|
|
f"no_mdns={self.no_mdns})"
|
|
)
|
|
|
|
def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None:
|
|
"""Load this entry from disk."""
|
|
self.storage = StorageJSON.load(self._storage_path)
|
|
#
|
|
# 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) -> list[str]:
|
|
if self.storage is None:
|
|
return []
|
|
return self.storage.loaded_integrations
|