mirror of
https://github.com/esphome/esphome.git
synced 2025-02-28 07:48:15 +00:00
dashboard: Implement automatic ping fallback (#8263)
This commit is contained in:
parent
63a7234767
commit
3048f303c5
@ -23,10 +23,6 @@ if bashio::config.true 'streamer_mode'; then
|
|||||||
export ESPHOME_STREAMER_MODE=true
|
export ESPHOME_STREAMER_MODE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if bashio::config.true 'status_use_ping'; then
|
|
||||||
export ESPHOME_DASHBOARD_USE_PING=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if bashio::config.has_value 'relative_url'; then
|
if bashio::config.has_value 'relative_url'; then
|
||||||
export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url')
|
export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url')
|
||||||
fi
|
fi
|
||||||
|
@ -9,7 +9,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from esphome.storage_json import ignored_devices_storage_path
|
from esphome.storage_json import ignored_devices_storage_path
|
||||||
|
|
||||||
@ -17,15 +17,15 @@ from ..zeroconf import DiscoveredImport
|
|||||||
from .dns import DNSCache
|
from .dns import DNSCache
|
||||||
from .entries import DashboardEntries
|
from .entries import DashboardEntries
|
||||||
from .settings import DashboardSettings
|
from .settings import DashboardSettings
|
||||||
|
from .status.mdns import MDNSStatus
|
||||||
if TYPE_CHECKING:
|
from .status.ping import PingStatus
|
||||||
from .status.mdns import MDNSStatus
|
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json"
|
IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json"
|
||||||
|
|
||||||
|
MDNS_BOOTSTRAP_TIME = 7.5
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event:
|
class Event:
|
||||||
@ -81,6 +81,7 @@ class ESPHomeDashboard:
|
|||||||
"dns_cache",
|
"dns_cache",
|
||||||
"_background_tasks",
|
"_background_tasks",
|
||||||
"ignored_devices",
|
"ignored_devices",
|
||||||
|
"_ping_status_task",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@ -97,6 +98,7 @@ class ESPHomeDashboard:
|
|||||||
self.dns_cache = DNSCache()
|
self.dns_cache = DNSCache()
|
||||||
self._background_tasks: set[asyncio.Task] = set()
|
self._background_tasks: set[asyncio.Task] = set()
|
||||||
self.ignored_devices: set[str] = set()
|
self.ignored_devices: set[str] = set()
|
||||||
|
self._ping_status_task: asyncio.Task | None = None
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Setup the dashboard."""
|
"""Setup the dashboard."""
|
||||||
@ -121,41 +123,48 @@ class ESPHomeDashboard:
|
|||||||
{"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle
|
{"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:
|
async def async_run(self) -> None:
|
||||||
"""Run the dashboard."""
|
"""Run the dashboard."""
|
||||||
settings = self.settings
|
settings = self.settings
|
||||||
mdns_task: asyncio.Task | None = None
|
mdns_task: asyncio.Task | None = None
|
||||||
ping_status_task: asyncio.Task | None = None
|
|
||||||
await self.entries.async_update_entries()
|
await self.entries.async_update_entries()
|
||||||
|
|
||||||
if settings.status_use_ping:
|
mdns_status = MDNSStatus(self)
|
||||||
from .status.ping import PingStatus
|
ping_status = PingStatus(self)
|
||||||
|
start_ping_timer: asyncio.TimerHandle | None = None
|
||||||
|
|
||||||
ping_status = PingStatus()
|
self.mdns_status = mdns_status
|
||||||
ping_status_task = asyncio.create_task(ping_status.async_run())
|
if mdns_status.async_setup():
|
||||||
else:
|
|
||||||
from .status.mdns import MDNSStatus
|
|
||||||
|
|
||||||
mdns_status = MDNSStatus()
|
|
||||||
await mdns_status.async_refresh_hosts()
|
|
||||||
self.mdns_status = mdns_status
|
|
||||||
mdns_task = asyncio.create_task(mdns_status.async_run())
|
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:
|
if settings.status_use_mqtt:
|
||||||
from .status.mqtt import MqttStatusThread
|
from .status.mqtt import MqttStatusThread
|
||||||
|
|
||||||
status_thread_mqtt = MqttStatusThread()
|
status_thread_mqtt = MqttStatusThread(self)
|
||||||
status_thread_mqtt.start()
|
status_thread_mqtt.start()
|
||||||
|
|
||||||
shutdown_event = asyncio.Event()
|
|
||||||
try:
|
try:
|
||||||
await shutdown_event.wait()
|
await asyncio.Event().wait()
|
||||||
finally:
|
finally:
|
||||||
_LOGGER.info("Shutting down...")
|
_LOGGER.info("Shutting down...")
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
self.ping_request.set()
|
self.ping_request.set()
|
||||||
if ping_status_task:
|
if start_ping_timer:
|
||||||
ping_status_task.cancel()
|
start_ping_timer.cancel()
|
||||||
|
if self._ping_status_task:
|
||||||
|
self._ping_status_task.cancel()
|
||||||
|
self._ping_status_task = None
|
||||||
if mdns_task:
|
if mdns_task:
|
||||||
mdns_task.cancel()
|
mdns_task.cancel()
|
||||||
if settings.status_use_mqtt:
|
if settings.status_use_mqtt:
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
from ipaddress import ip_address
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from icmplib import NameLookupError, async_resolve
|
from icmplib import NameLookupError, async_resolve
|
||||||
@ -10,11 +12,15 @@ if sys.version_info >= (3, 11):
|
|||||||
else:
|
else:
|
||||||
from async_timeout import timeout as async_timeout
|
from async_timeout import timeout as async_timeout
|
||||||
|
|
||||||
|
RESOLVE_TIMEOUT = 3.0
|
||||||
|
|
||||||
|
|
||||||
async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception:
|
async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception:
|
||||||
"""Wrap the icmplib async_resolve function."""
|
"""Wrap the icmplib async_resolve function."""
|
||||||
|
with suppress(ValueError):
|
||||||
|
return [str(ip_address(hostname))]
|
||||||
try:
|
try:
|
||||||
async with async_timeout(2):
|
async with async_timeout(RESOLVE_TIMEOUT):
|
||||||
return await async_resolve(hostname)
|
return await async_resolve(hostname)
|
||||||
except (asyncio.TimeoutError, NameLookupError, UnicodeError) as ex:
|
except (asyncio.TimeoutError, NameLookupError, UnicodeError) as ex:
|
||||||
return ex
|
return ex
|
||||||
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@ -27,37 +29,53 @@ _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
|
@dataclass(frozen=True)
|
||||||
# it may be expanded to include more states
|
class EntryState:
|
||||||
|
"""Represents the state of an entry."""
|
||||||
|
|
||||||
|
reachable: ReachableState
|
||||||
|
source: EntryStateSource
|
||||||
|
|
||||||
|
|
||||||
class EntryState(StrEnum):
|
class EntryStateSource(StrEnum):
|
||||||
ONLINE = "online"
|
MDNS = "mdns"
|
||||||
OFFLINE = "offline"
|
PING = "ping"
|
||||||
|
MQTT = "mqtt"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
_BOOL_TO_ENTRY_STATE = {
|
class ReachableState(StrEnum):
|
||||||
True: EntryState.ONLINE,
|
ONLINE = "online"
|
||||||
False: EntryState.OFFLINE,
|
OFFLINE = "offline"
|
||||||
None: EntryState.UNKNOWN,
|
DNS_FAILURE = "dns_failure"
|
||||||
}
|
UNKNOWN = "unknown"
|
||||||
_ENTRY_STATE_TO_BOOL = {
|
|
||||||
EntryState.ONLINE: True,
|
|
||||||
EntryState.OFFLINE: False,
|
|
||||||
EntryState.UNKNOWN: None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def bool_to_entry_state(value: bool) -> EntryState:
|
_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."""
|
"""Convert a bool to an entry state."""
|
||||||
return _BOOL_TO_ENTRY_STATE[value]
|
return EntryState(_BOOL_TO_REACHABLE_STATE[value], source)
|
||||||
|
|
||||||
|
|
||||||
def entry_state_to_bool(value: EntryState) -> bool | None:
|
def entry_state_to_bool(value: EntryState) -> bool | None:
|
||||||
"""Convert an entry state to a bool."""
|
"""Convert an entry state to a bool."""
|
||||||
return _ENTRY_STATE_TO_BOOL[value]
|
return _REACHABLE_STATE_TO_BOOL[value.reachable]
|
||||||
|
|
||||||
|
|
||||||
class DashboardEntries:
|
class DashboardEntries:
|
||||||
@ -119,6 +137,55 @@ class DashboardEntries:
|
|||||||
"""Set the state for an entry."""
|
"""Set the state for an entry."""
|
||||||
self.async_set_state(entry, state)
|
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:
|
def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None:
|
||||||
"""Set the state for an entry."""
|
"""Set the state for an entry."""
|
||||||
if entry.state == state:
|
if entry.state == state:
|
||||||
@ -269,7 +336,7 @@ class DashboardEntry:
|
|||||||
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.state = UNKNOWN_STATE
|
||||||
self._to_dict: dict[str, Any] | None = None
|
self._to_dict: dict[str, Any] | None = None
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
@ -54,10 +54,6 @@ class DashboardSettings:
|
|||||||
def relative_url(self) -> str:
|
def relative_url(self) -> str:
|
||||||
return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/"
|
return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/"
|
||||||
|
|
||||||
@property
|
|
||||||
def status_use_ping(self):
|
|
||||||
return get_bool_env("ESPHOME_DASHBOARD_USE_PING")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_use_mqtt(self) -> bool:
|
def status_use_mqtt(self) -> bool:
|
||||||
return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT")
|
return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT")
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import typing
|
||||||
|
|
||||||
from esphome.zeroconf import (
|
from esphome.zeroconf import (
|
||||||
ESPHOME_SERVICE_TYPE,
|
ESPHOME_SERVICE_TYPE,
|
||||||
@ -11,20 +13,36 @@ from esphome.zeroconf import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..const import SENTINEL
|
from ..const import SENTINEL
|
||||||
from ..core import DASHBOARD
|
from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state
|
||||||
from ..entries import DashboardEntry, bool_to_entry_state
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from ..core import ESPHomeDashboard
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MDNSStatus:
|
class MDNSStatus:
|
||||||
"""Class that updates the mdns status."""
|
"""Class that updates the mdns status."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
||||||
"""Initialize the MDNSStatus class."""
|
"""Initialize the MDNSStatus class."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
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] = {}
|
||||||
self._loop = asyncio.get_running_loop()
|
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:
|
async def async_resolve_host(self, host_name: str) -> list[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."""
|
||||||
@ -32,9 +50,9 @@ class MDNSStatus:
|
|||||||
return await aiozc.async_resolve_host(host_name)
|
return await aiozc.async_resolve_host(host_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def async_refresh_hosts(self):
|
async def async_refresh_hosts(self) -> None:
|
||||||
"""Refresh the hosts to track."""
|
"""Refresh the hosts to track."""
|
||||||
dashboard = DASHBOARD
|
dashboard = self.dashboard
|
||||||
host_mdns_state = self.host_mdns_state
|
host_mdns_state = self.host_mdns_state
|
||||||
entries = dashboard.entries
|
entries = dashboard.entries
|
||||||
poll_names: dict[str, set[DashboardEntry]] = {}
|
poll_names: dict[str, set[DashboardEntry]] = {}
|
||||||
@ -49,7 +67,7 @@ class MDNSStatus:
|
|||||||
# the device won't respond to a request to ._esphomelib._tcp.local.
|
# the device won't respond to a request to ._esphomelib._tcp.local.
|
||||||
poll_names.setdefault(entry.name, set()).add(entry)
|
poll_names.setdefault(entry.name, set()).add(entry)
|
||||||
elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL:
|
elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL:
|
||||||
entries.async_set_state(entry, bool_to_entry_state(online))
|
self._async_set_state(entry, online)
|
||||||
if poll_names and self.aiozc:
|
if poll_names and self.aiozc:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*(self.aiozc.async_resolve_host(name) for name in poll_names)
|
*(self.aiozc.async_resolve_host(name) for name in poll_names)
|
||||||
@ -58,13 +76,25 @@ class MDNSStatus:
|
|||||||
result = bool(address_list)
|
result = bool(address_list)
|
||||||
host_mdns_state[name] = result
|
host_mdns_state[name] = result
|
||||||
for entry in poll_names[name]:
|
for entry in poll_names[name]:
|
||||||
entries.async_set_state(entry, bool_to_entry_state(result))
|
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:
|
async def async_run(self) -> None:
|
||||||
dashboard = DASHBOARD
|
"""Run the mdns status."""
|
||||||
|
dashboard = self.dashboard
|
||||||
entries = dashboard.entries
|
entries = dashboard.entries
|
||||||
aiozc = AsyncEsphomeZeroconf()
|
|
||||||
self.aiozc = aiozc
|
|
||||||
host_mdns_state = self.host_mdns_state
|
host_mdns_state = self.host_mdns_state
|
||||||
|
|
||||||
def on_update(dat: dict[str, bool | None]) -> None:
|
def on_update(dat: dict[str, bool | None]) -> None:
|
||||||
@ -73,15 +103,14 @@ class MDNSStatus:
|
|||||||
host_mdns_state[name] = result
|
host_mdns_state[name] = result
|
||||||
if matching_entries := entries.get_by_name(name):
|
if matching_entries := entries.get_by_name(name):
|
||||||
for entry in matching_entries:
|
for entry in matching_entries:
|
||||||
if not entry.no_mdns:
|
self._async_set_state(entry, result)
|
||||||
entries.async_set_state(entry, bool_to_entry_state(result))
|
|
||||||
|
|
||||||
stat = DashboardStatus(on_update)
|
stat = DashboardStatus(on_update)
|
||||||
imports = DashboardImportDiscovery()
|
imports = DashboardImportDiscovery()
|
||||||
dashboard.import_result = imports.import_state
|
dashboard.import_result = imports.import_state
|
||||||
|
|
||||||
browser = DashboardBrowser(
|
browser = DashboardBrowser(
|
||||||
aiozc.zeroconf,
|
self.aiozc.zeroconf,
|
||||||
ESPHOME_SERVICE_TYPE,
|
ESPHOME_SERVICE_TYPE,
|
||||||
[stat.browser_callback, imports.browser_callback],
|
[stat.browser_callback, imports.browser_callback],
|
||||||
)
|
)
|
||||||
@ -93,5 +122,5 @@ class MDNSStatus:
|
|||||||
ping_request.clear()
|
ping_request.clear()
|
||||||
|
|
||||||
await browser.async_cancel()
|
await browser.async_cancel()
|
||||||
await aiozc.async_close()
|
await self.aiozc.async_close()
|
||||||
self.aiozc = None
|
self.aiozc = None
|
||||||
|
@ -4,19 +4,27 @@ import binascii
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import typing
|
||||||
|
|
||||||
from esphome import mqtt
|
from esphome import mqtt
|
||||||
|
|
||||||
from ..core import DASHBOARD
|
from ..entries import EntryStateSource, bool_to_entry_state
|
||||||
from ..entries import EntryState
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from ..core import ESPHomeDashboard
|
||||||
|
|
||||||
|
|
||||||
class MqttStatusThread(threading.Thread):
|
class MqttStatusThread(threading.Thread):
|
||||||
"""Status thread to get the status of the devices via MQTT."""
|
"""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:
|
def run(self) -> None:
|
||||||
"""Run the status thread."""
|
"""Run the status thread."""
|
||||||
dashboard = DASHBOARD
|
dashboard = self.dashboard
|
||||||
entries = dashboard.entries
|
entries = dashboard.entries
|
||||||
current_entries = entries.all()
|
current_entries = entries.all()
|
||||||
|
|
||||||
@ -31,10 +39,13 @@ class MqttStatusThread(threading.Thread):
|
|||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
if "name" not in data:
|
if "name" not in data:
|
||||||
return
|
return
|
||||||
for entry in current_entries:
|
if matching_entries := entries.get_by_name(data["name"]):
|
||||||
if entry.name == data["name"]:
|
for entry in matching_entries:
|
||||||
entries.set_state(entry, EntryState.ONLINE)
|
# Only override state if we don't have a state from another source
|
||||||
return
|
# 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):
|
def on_connect(client, userdata, flags, return_code):
|
||||||
client.publish("esphome/discover", None, retain=False)
|
client.publish("esphome/discover", None, retain=False)
|
||||||
@ -56,8 +67,10 @@ class MqttStatusThread(threading.Thread):
|
|||||||
current_entries = 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 current_entries:
|
for entry in current_entries:
|
||||||
if entry.no_mdns:
|
# Only override state if we don't have a state from another source
|
||||||
entries.set_state(entry, EntryState.OFFLINE)
|
entries.set_state_if_source(
|
||||||
|
entry, bool_to_entry_state(False, EntryStateSource.MQTT)
|
||||||
|
)
|
||||||
|
|
||||||
client.publish("esphome/discover", None, retain=False)
|
client.publish("esphome/discover", None, retain=False)
|
||||||
dashboard.mqtt_ping_request.wait()
|
dashboard.mqtt_ping_request.wait()
|
||||||
|
@ -3,29 +3,44 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import typing
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from icmplib import Host, SocketPermissionError, async_ping
|
from icmplib import Host, SocketPermissionError, async_ping
|
||||||
|
|
||||||
from ..const import MAX_EXECUTOR_WORKERS
|
from ..const import MAX_EXECUTOR_WORKERS
|
||||||
from ..core import DASHBOARD
|
from ..entries import (
|
||||||
from ..entries import DashboardEntry, EntryState, bool_to_entry_state
|
DashboardEntry,
|
||||||
|
EntryState,
|
||||||
|
EntryStateSource,
|
||||||
|
ReachableState,
|
||||||
|
bool_to_entry_state,
|
||||||
|
)
|
||||||
from ..util.itertools import chunked
|
from ..util.itertools import chunked
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from ..core import ESPHomeDashboard
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2)
|
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:
|
class PingStatus:
|
||||||
def __init__(self) -> None:
|
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
||||||
"""Initialize the PingStatus class."""
|
"""Initialize the PingStatus class."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self.dashboard = dashboard
|
||||||
|
|
||||||
async def async_run(self) -> None:
|
async def async_run(self) -> None:
|
||||||
"""Run the ping status."""
|
"""Run the ping status."""
|
||||||
dashboard = DASHBOARD
|
dashboard = self.dashboard
|
||||||
entries = dashboard.entries
|
entries = dashboard.entries
|
||||||
privileged = await _can_use_icmp_lib_with_privilege()
|
privileged = await _can_use_icmp_lib_with_privilege()
|
||||||
if privileged is None:
|
if privileged is None:
|
||||||
@ -36,10 +51,24 @@ class PingStatus:
|
|||||||
# 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_request.clear()
|
dashboard.ping_request.clear()
|
||||||
|
iteration_start = time.monotonic()
|
||||||
current_entries = dashboard.entries.async_all()
|
current_entries = dashboard.entries.async_all()
|
||||||
to_ping: list[DashboardEntry] = [
|
to_ping: list[DashboardEntry] = []
|
||||||
entry for entry in current_entries if entry.address is not None
|
|
||||||
]
|
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
|
# Resolve DNS for all entries
|
||||||
entries_with_addresses: dict[DashboardEntry, list[str]] = {}
|
entries_with_addresses: dict[DashboardEntry, list[str]] = {}
|
||||||
@ -56,7 +85,10 @@ class PingStatus:
|
|||||||
|
|
||||||
for entry, result in zip(ping_group, dns_results):
|
for entry, result in zip(ping_group, dns_results):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
entries.async_set_state(entry, EntryState.UNKNOWN)
|
# 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
|
continue
|
||||||
if isinstance(result, BaseException):
|
if isinstance(result, BaseException):
|
||||||
raise result
|
raise result
|
||||||
@ -82,8 +114,20 @@ class PingStatus:
|
|||||||
else:
|
else:
|
||||||
host: Host = result
|
host: Host = result
|
||||||
ping_result = host.is_alive
|
ping_result = host.is_alive
|
||||||
entry, _ = entry_addresses
|
entry: DashboardEntry = entry_addresses[0]
|
||||||
entries.async_set_state(entry, bool_to_entry_state(ping_result))
|
# 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:
|
async def _can_use_icmp_lib_with_privilege() -> None | bool:
|
||||||
|
@ -45,7 +45,7 @@ from esphome.yaml_util import FastestAvailableSafeLoader
|
|||||||
|
|
||||||
from .const import DASHBOARD_COMMAND
|
from .const import DASHBOARD_COMMAND
|
||||||
from .core import DASHBOARD
|
from .core import DASHBOARD
|
||||||
from .entries import EntryState, entry_state_to_bool
|
from .entries import UNKNOWN_STATE, entry_state_to_bool
|
||||||
from .util.file import write_file
|
from .util.file import write_file
|
||||||
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
|
||||||
@ -381,7 +381,7 @@ class EsphomeRenameHandler(EsphomeCommandWebSocket):
|
|||||||
# Remove the old ping result from the cache
|
# Remove the old ping result from the cache
|
||||||
entries = DASHBOARD.entries
|
entries = DASHBOARD.entries
|
||||||
if entry := entries.get(self.old_name):
|
if entry := entries.get(self.old_name):
|
||||||
entries.async_set_state(entry, EntryState.UNKNOWN)
|
entries.async_set_state(entry, UNKNOWN_STATE)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
|
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
|
||||||
|
@ -5,7 +5,13 @@ from dataclasses import dataclass
|
|||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from zeroconf import IPVersion, ServiceInfo, ServiceStateChange, Zeroconf
|
from zeroconf import (
|
||||||
|
AddressResolver,
|
||||||
|
IPVersion,
|
||||||
|
ServiceInfo,
|
||||||
|
ServiceStateChange,
|
||||||
|
Zeroconf,
|
||||||
|
)
|
||||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||||
|
|
||||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||||
@ -16,15 +22,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_BACKGROUND_TASKS: set[asyncio.Task] = set()
|
_BACKGROUND_TASKS: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
class HostResolver(ServiceInfo):
|
|
||||||
"""Resolve a host name to an IP address."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _is_complete(self) -> bool:
|
|
||||||
"""The ServiceInfo has all expected properties."""
|
|
||||||
return bool(self._ipv4_addresses)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardStatus:
|
class DashboardStatus:
|
||||||
def __init__(self, on_update: Callable[[dict[str, bool | None], []]]) -> None:
|
def __init__(self, on_update: Callable[[dict[str, bool | None], []]]) -> None:
|
||||||
"""Initialize the dashboard status."""
|
"""Initialize the dashboard status."""
|
||||||
@ -166,19 +163,10 @@ class DashboardImportDiscovery:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_host_resolver(host: str) -> HostResolver:
|
|
||||||
"""Create a new HostResolver for the given host name."""
|
|
||||||
name = host.partition(".")[0]
|
|
||||||
info = HostResolver(
|
|
||||||
ESPHOME_SERVICE_TYPE, f"{name}.{ESPHOME_SERVICE_TYPE}", server=f"{name}.local."
|
|
||||||
)
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
class EsphomeZeroconf(Zeroconf):
|
class EsphomeZeroconf(Zeroconf):
|
||||||
def resolve_host(self, host: str, timeout: float = 3.0) -> list[str] | None:
|
def resolve_host(self, host: str, timeout: float = 3.0) -> list[str] | None:
|
||||||
"""Resolve a host name to an IP address."""
|
"""Resolve a host name to an IP address."""
|
||||||
info = _make_host_resolver(host)
|
info = AddressResolver(f'{host.partition(".")[0]}.local.')
|
||||||
if (
|
if (
|
||||||
info.load_from_cache(self)
|
info.load_from_cache(self)
|
||||||
or (timeout and info.request(self, timeout * 1000))
|
or (timeout and info.request(self, timeout * 1000))
|
||||||
@ -192,7 +180,7 @@ class AsyncEsphomeZeroconf(AsyncZeroconf):
|
|||||||
self, host: str, timeout: float = 3.0
|
self, host: str, timeout: float = 3.0
|
||||||
) -> list[str] | None:
|
) -> list[str] | None:
|
||||||
"""Resolve a host name to an IP address."""
|
"""Resolve a host name to an IP address."""
|
||||||
info = _make_host_resolver(host)
|
info = AddressResolver(f'{host.partition(".")[0]}.local.')
|
||||||
if (
|
if (
|
||||||
info.load_from_cache(self.zeroconf)
|
info.load_from_cache(self.zeroconf)
|
||||||
or (timeout and await info.async_request(self.zeroconf, timeout * 1000))
|
or (timeout and await info.async_request(self.zeroconf, timeout * 1000))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user