esphome 2025.2.2__py3-none-any.whl → 2025.3.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- esphome/__main__.py +9 -1
- esphome/components/api/api_connection.cpp +426 -70
- esphome/components/api/api_connection.h +117 -25
- esphome/components/api/api_pb2.cpp +33 -0
- esphome/components/api/api_pb2.h +4 -0
- esphome/components/api/api_server.cpp +2 -2
- esphome/components/api/list_entities.cpp +76 -22
- esphome/components/api/list_entities.h +1 -0
- esphome/components/api/subscribe_state.h +2 -0
- esphome/components/audio/__init__.py +1 -1
- esphome/components/audio/audio_decoder.cpp +43 -11
- esphome/components/audio/audio_reader.cpp +2 -2
- esphome/components/audio/audio_resampler.cpp +4 -2
- esphome/components/audio/audio_transfer_buffer.cpp +19 -9
- esphome/components/audio/audio_transfer_buffer.h +7 -2
- esphome/components/bluetooth_proxy/bluetooth_proxy.h +8 -0
- esphome/components/bmp085/bmp085.cpp +1 -1
- esphome/components/chsc6x/__init__.py +2 -0
- esphome/components/chsc6x/chsc6x_touchscreen.cpp +47 -0
- esphome/components/chsc6x/chsc6x_touchscreen.h +34 -0
- esphome/components/chsc6x/touchscreen.py +33 -0
- esphome/components/climate/__init__.py +0 -1
- esphome/components/cst816/binary_sensor/__init__.py +2 -25
- esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +3 -14
- esphome/components/cst816/touchscreen/cst816_touchscreen.h +0 -4
- esphome/components/esp32_ble_beacon/__init__.py +3 -1
- esphome/components/esp8266/gpio.py +1 -2
- esphome/components/font/__init__.py +185 -185
- esphome/components/font/font.cpp +4 -4
- esphome/components/font/font.h +1 -0
- esphome/components/haier/climate.py +11 -10
- esphome/components/hbridge/switch/hbridge_switch.cpp +2 -2
- esphome/components/heatpumpir/climate.py +2 -1
- esphome/components/heatpumpir/heatpumpir.cpp +1 -0
- esphome/components/heatpumpir/heatpumpir.h +1 -0
- esphome/components/i2c/__init__.py +6 -6
- esphome/components/i2c/i2c_bus_esp_idf.cpp +6 -2
- esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +1 -1
- esphome/components/ili9xxx/display.py +1 -0
- esphome/components/ili9xxx/ili9xxx_display.h +5 -0
- esphome/components/ili9xxx/ili9xxx_init.h +59 -0
- esphome/components/ld2450/__init__.py +51 -0
- esphome/components/ld2450/binary_sensor.py +47 -0
- esphome/components/ld2450/button/__init__.py +45 -0
- esphome/components/ld2450/button/reset_button.cpp +9 -0
- esphome/components/ld2450/button/reset_button.h +18 -0
- esphome/components/ld2450/button/restart_button.cpp +9 -0
- esphome/components/ld2450/button/restart_button.h +18 -0
- esphome/components/ld2450/ld2450.cpp +876 -0
- esphome/components/ld2450/ld2450.h +234 -0
- esphome/components/ld2450/number/__init__.py +121 -0
- esphome/components/ld2450/number/presence_timeout_number.cpp +12 -0
- esphome/components/ld2450/number/presence_timeout_number.h +18 -0
- esphome/components/ld2450/number/zone_coordinate_number.cpp +14 -0
- esphome/components/ld2450/number/zone_coordinate_number.h +19 -0
- esphome/components/ld2450/select/__init__.py +56 -0
- esphome/components/ld2450/select/baud_rate_select.cpp +12 -0
- esphome/components/ld2450/select/baud_rate_select.h +18 -0
- esphome/components/ld2450/select/zone_type_select.cpp +12 -0
- esphome/components/ld2450/select/zone_type_select.h +18 -0
- esphome/components/ld2450/sensor.py +156 -0
- esphome/components/ld2450/switch/__init__.py +45 -0
- esphome/components/ld2450/switch/bluetooth_switch.cpp +12 -0
- esphome/components/ld2450/switch/bluetooth_switch.h +18 -0
- esphome/components/ld2450/switch/multi_target_switch.cpp +12 -0
- esphome/components/ld2450/switch/multi_target_switch.h +18 -0
- esphome/components/ld2450/text_sensor.py +62 -0
- esphome/components/lvgl/defines.py +0 -2
- esphome/components/lvgl/font.cpp +1 -1
- esphome/components/lvgl/lvgl_esphome.cpp +27 -19
- esphome/components/lvgl/widgets/img.py +1 -3
- esphome/components/mcp2515/mcp2515.cpp +1 -0
- esphome/components/mdns/__init__.py +1 -1
- esphome/components/mixer/speaker/mixer_speaker.cpp +6 -1
- esphome/components/mixer/speaker/mixer_speaker.h +2 -0
- esphome/components/mlx90393/sensor.py +53 -33
- esphome/components/mlx90393/sensor_mlx90393.cpp +4 -0
- esphome/components/mlx90393/sensor_mlx90393.h +8 -3
- esphome/components/mqtt/__init__.py +2 -2
- esphome/components/msa3xx/__init__.py +189 -0
- esphome/components/msa3xx/binary_sensor.py +40 -0
- esphome/components/msa3xx/msa3xx.cpp +417 -0
- esphome/components/msa3xx/msa3xx.h +311 -0
- esphome/components/msa3xx/sensor.py +42 -0
- esphome/components/msa3xx/text_sensor.py +38 -0
- esphome/components/nfc/binary_sensor/__init__.py +4 -4
- esphome/components/opentherm/binary_sensor/__init__.py +4 -4
- esphome/components/opentherm/generate.py +6 -6
- esphome/components/opentherm/sensor/__init__.py +5 -6
- esphome/components/packages/__init__.py +35 -11
- esphome/components/pn532/binary_sensor.py +4 -4
- esphome/components/rc522/binary_sensor.py +4 -4
- esphome/components/resampler/speaker/resampler_speaker.h +2 -0
- esphome/components/socket/bsd_sockets_impl.cpp +1 -0
- esphome/components/socket/lwip_sockets_impl.cpp +1 -0
- esphome/components/socket/socket.h +3 -1
- esphome/components/speaker/speaker.h +2 -2
- esphome/components/ssd1306_base/__init__.py +7 -7
- esphome/components/thermostat/climate.py +1 -1
- esphome/components/tmp1075/tmp1075.cpp +7 -11
- esphome/components/tmp1075/tmp1075.h +1 -2
- esphome/components/tormatic/__init__.py +1 -0
- esphome/components/tormatic/cover.py +47 -0
- esphome/components/tormatic/tormatic_cover.cpp +355 -0
- esphome/components/tormatic/tormatic_cover.h +60 -0
- esphome/components/tormatic/tormatic_protocol.h +211 -0
- esphome/components/touchscreen/binary_sensor/__init__.py +3 -0
- esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp +7 -1
- esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +3 -1
- esphome/components/touchscreen/touchscreen.cpp +3 -4
- esphome/components/udp/udp_component.h +4 -1
- esphome/components/web_server/list_entities.cpp +70 -66
- esphome/components/web_server/list_entities.h +43 -22
- esphome/components/web_server/web_server.cpp +345 -68
- esphome/components/web_server/web_server.h +138 -6
- esphome/components/web_server_base/__init__.py +1 -1
- esphome/components/web_server_idf/__init__.py +2 -0
- esphome/components/web_server_idf/web_server_idf.cpp +177 -30
- esphome/components/web_server_idf/web_server_idf.h +53 -4
- esphome/config_validation.py +23 -125
- esphome/const.py +5 -1
- esphome/core/config.py +12 -4
- esphome/core/defines.h +1 -1
- esphome/core/helpers.h +24 -3
- esphome/core/time.cpp +1 -0
- esphome/cpp_generator.py +3 -3
- esphome/dashboard/core.py +30 -21
- esphome/dashboard/dns.py +7 -1
- esphome/dashboard/entries.py +83 -16
- esphome/dashboard/settings.py +0 -4
- esphome/dashboard/status/mdns.py +43 -14
- esphome/dashboard/status/mqtt.py +22 -9
- esphome/dashboard/status/ping.py +54 -10
- esphome/dashboard/web_server.py +56 -24
- esphome/storage_json.py +4 -0
- esphome/wizard.py +13 -17
- esphome/writer.py +1 -3
- esphome/yaml_util.py +36 -33
- esphome/zeroconf.py +9 -21
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/METADATA +7 -7
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/RECORD +145 -105
- esphome/components/cst816/binary_sensor/cst816_button.h +0 -27
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/LICENSE +0 -0
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/WHEEL +0 -0
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/entry_points.txt +0 -0
- {esphome-2025.2.2.dist-info → esphome-2025.3.0b2.dist-info}/top_level.txt +0 -0
esphome/dashboard/entries.py
CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import asyncio
|
4
4
|
from collections import defaultdict
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from functools import lru_cache
|
5
7
|
import logging
|
6
8
|
import os
|
7
9
|
from typing import TYPE_CHECKING, Any
|
@@ -27,37 +29,53 @@ _LOGGER = logging.getLogger(__name__)
|
|
27
29
|
|
28
30
|
DashboardCacheKeyType = tuple[int, int, float, int]
|
29
31
|
|
30
|
-
# Currently EntryState is a simple
|
31
|
-
# online/offline/unknown enum, but in the future
|
32
|
-
# it may be expanded to include more states
|
33
32
|
|
33
|
+
@dataclass(frozen=True)
|
34
|
+
class EntryState:
|
35
|
+
"""Represents the state of an entry."""
|
34
36
|
|
35
|
-
|
37
|
+
reachable: ReachableState
|
38
|
+
source: EntryStateSource
|
39
|
+
|
40
|
+
|
41
|
+
class EntryStateSource(StrEnum):
|
42
|
+
MDNS = "mdns"
|
43
|
+
PING = "ping"
|
44
|
+
MQTT = "mqtt"
|
45
|
+
UNKNOWN = "unknown"
|
46
|
+
|
47
|
+
|
48
|
+
class ReachableState(StrEnum):
|
36
49
|
ONLINE = "online"
|
37
50
|
OFFLINE = "offline"
|
51
|
+
DNS_FAILURE = "dns_failure"
|
38
52
|
UNKNOWN = "unknown"
|
39
53
|
|
40
54
|
|
41
|
-
|
42
|
-
True:
|
43
|
-
False:
|
44
|
-
None:
|
55
|
+
_BOOL_TO_REACHABLE_STATE = {
|
56
|
+
True: ReachableState.ONLINE,
|
57
|
+
False: ReachableState.OFFLINE,
|
58
|
+
None: ReachableState.UNKNOWN,
|
45
59
|
}
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
60
|
+
_REACHABLE_STATE_TO_BOOL = {
|
61
|
+
ReachableState.ONLINE: True,
|
62
|
+
ReachableState.OFFLINE: False,
|
63
|
+
ReachableState.DNS_FAILURE: False,
|
64
|
+
ReachableState.UNKNOWN: None,
|
50
65
|
}
|
51
66
|
|
67
|
+
UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN)
|
52
68
|
|
53
|
-
|
69
|
+
|
70
|
+
@lru_cache # creating frozen dataclass instances is expensive, so we cache them
|
71
|
+
def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState:
|
54
72
|
"""Convert a bool to an entry state."""
|
55
|
-
return
|
73
|
+
return EntryState(_BOOL_TO_REACHABLE_STATE[value], source)
|
56
74
|
|
57
75
|
|
58
76
|
def entry_state_to_bool(value: EntryState) -> bool | None:
|
59
77
|
"""Convert an entry state to a bool."""
|
60
|
-
return
|
78
|
+
return _REACHABLE_STATE_TO_BOOL[value.reachable]
|
61
79
|
|
62
80
|
|
63
81
|
class DashboardEntries:
|
@@ -119,6 +137,55 @@ class DashboardEntries:
|
|
119
137
|
"""Set the state for an entry."""
|
120
138
|
self.async_set_state(entry, state)
|
121
139
|
|
140
|
+
def set_state_if_online_or_source(
|
141
|
+
self, entry: DashboardEntry, state: EntryState
|
142
|
+
) -> None:
|
143
|
+
"""Set the state for an entry if its online or provided by the source or unknown."""
|
144
|
+
asyncio.run_coroutine_threadsafe(
|
145
|
+
self._async_set_state_if_online_or_source(entry, state), self._loop
|
146
|
+
).result()
|
147
|
+
|
148
|
+
async def _async_set_state_if_online_or_source(
|
149
|
+
self, entry: DashboardEntry, state: EntryState
|
150
|
+
) -> None:
|
151
|
+
"""Set the state for an entry if its online or provided by the source or unknown."""
|
152
|
+
self.async_set_state_if_online_or_source(entry, state)
|
153
|
+
|
154
|
+
def async_set_state_if_online_or_source(
|
155
|
+
self, entry: DashboardEntry, state: EntryState
|
156
|
+
) -> None:
|
157
|
+
"""Set the state for an entry if its online or provided by the source or unknown."""
|
158
|
+
if (
|
159
|
+
state.reachable is ReachableState.ONLINE
|
160
|
+
and entry.state.reachable is not ReachableState.ONLINE
|
161
|
+
) or entry.state.source in (
|
162
|
+
EntryStateSource.UNKNOWN,
|
163
|
+
state.source,
|
164
|
+
):
|
165
|
+
self.async_set_state(entry, state)
|
166
|
+
|
167
|
+
def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None:
|
168
|
+
"""Set the state for an entry if provided by the source or unknown."""
|
169
|
+
asyncio.run_coroutine_threadsafe(
|
170
|
+
self._async_set_state_if_source(entry, state), self._loop
|
171
|
+
).result()
|
172
|
+
|
173
|
+
async def _async_set_state_if_source(
|
174
|
+
self, entry: DashboardEntry, state: EntryState
|
175
|
+
) -> None:
|
176
|
+
"""Set the state for an entry if rovided by the source or unknown."""
|
177
|
+
self.async_set_state_if_source(entry, state)
|
178
|
+
|
179
|
+
def async_set_state_if_source(
|
180
|
+
self, entry: DashboardEntry, state: EntryState
|
181
|
+
) -> None:
|
182
|
+
"""Set the state for an entry if provided by the source or unknown."""
|
183
|
+
if entry.state.source in (
|
184
|
+
EntryStateSource.UNKNOWN,
|
185
|
+
state.source,
|
186
|
+
):
|
187
|
+
self.async_set_state(entry, state)
|
188
|
+
|
122
189
|
def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None:
|
123
190
|
"""Set the state for an entry."""
|
124
191
|
if entry.state == state:
|
@@ -269,7 +336,7 @@ class DashboardEntry:
|
|
269
336
|
self._storage_path = ext_storage_path(self.filename)
|
270
337
|
self.cache_key = cache_key
|
271
338
|
self.storage: StorageJSON | None = None
|
272
|
-
self.state =
|
339
|
+
self.state = UNKNOWN_STATE
|
273
340
|
self._to_dict: dict[str, Any] | None = None
|
274
341
|
|
275
342
|
def __repr__(self) -> str:
|
esphome/dashboard/settings.py
CHANGED
@@ -54,10 +54,6 @@ class DashboardSettings:
|
|
54
54
|
def relative_url(self) -> str:
|
55
55
|
return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/"
|
56
56
|
|
57
|
-
@property
|
58
|
-
def status_use_ping(self):
|
59
|
-
return get_bool_env("ESPHOME_DASHBOARD_USE_PING")
|
60
|
-
|
61
57
|
@property
|
62
58
|
def status_use_mqtt(self) -> bool:
|
63
59
|
return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT")
|
esphome/dashboard/status/mdns.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import asyncio
|
4
|
+
import logging
|
5
|
+
import typing
|
4
6
|
|
5
7
|
from esphome.zeroconf import (
|
6
8
|
ESPHOME_SERVICE_TYPE,
|
@@ -11,20 +13,36 @@ from esphome.zeroconf import (
|
|
11
13
|
)
|
12
14
|
|
13
15
|
from ..const import SENTINEL
|
14
|
-
from ..
|
15
|
-
|
16
|
+
from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state
|
17
|
+
|
18
|
+
if typing.TYPE_CHECKING:
|
19
|
+
from ..core import ESPHomeDashboard
|
20
|
+
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
16
22
|
|
17
23
|
|
18
24
|
class MDNSStatus:
|
19
25
|
"""Class that updates the mdns status."""
|
20
26
|
|
21
|
-
def __init__(self) -> None:
|
27
|
+
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
22
28
|
"""Initialize the MDNSStatus class."""
|
23
29
|
super().__init__()
|
24
30
|
self.aiozc: AsyncEsphomeZeroconf | None = None
|
25
31
|
# This is the current mdns state for each host (True, False, None)
|
26
32
|
self.host_mdns_state: dict[str, bool | None] = {}
|
27
33
|
self._loop = asyncio.get_running_loop()
|
34
|
+
self.dashboard = dashboard
|
35
|
+
|
36
|
+
def async_setup(self) -> bool:
|
37
|
+
"""Set up the MDNSStatus class."""
|
38
|
+
try:
|
39
|
+
self.aiozc = AsyncEsphomeZeroconf()
|
40
|
+
except OSError as e:
|
41
|
+
_LOGGER.warning(
|
42
|
+
"Failed to initialize zeroconf, will fallback to ping: %s", e
|
43
|
+
)
|
44
|
+
return False
|
45
|
+
return True
|
28
46
|
|
29
47
|
async def async_resolve_host(self, host_name: str) -> list[str] | None:
|
30
48
|
"""Resolve a host name to an address in a thread-safe manner."""
|
@@ -32,9 +50,9 @@ class MDNSStatus:
|
|
32
50
|
return await aiozc.async_resolve_host(host_name)
|
33
51
|
return None
|
34
52
|
|
35
|
-
async def async_refresh_hosts(self):
|
53
|
+
async def async_refresh_hosts(self) -> None:
|
36
54
|
"""Refresh the hosts to track."""
|
37
|
-
dashboard =
|
55
|
+
dashboard = self.dashboard
|
38
56
|
host_mdns_state = self.host_mdns_state
|
39
57
|
entries = dashboard.entries
|
40
58
|
poll_names: dict[str, set[DashboardEntry]] = {}
|
@@ -49,7 +67,7 @@ class MDNSStatus:
|
|
49
67
|
# the device won't respond to a request to ._esphomelib._tcp.local.
|
50
68
|
poll_names.setdefault(entry.name, set()).add(entry)
|
51
69
|
elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL:
|
52
|
-
|
70
|
+
self._async_set_state(entry, online)
|
53
71
|
if poll_names and self.aiozc:
|
54
72
|
results = await asyncio.gather(
|
55
73
|
*(self.aiozc.async_resolve_host(name) for name in poll_names)
|
@@ -58,13 +76,25 @@ class MDNSStatus:
|
|
58
76
|
result = bool(address_list)
|
59
77
|
host_mdns_state[name] = result
|
60
78
|
for entry in poll_names[name]:
|
61
|
-
|
79
|
+
self._async_set_state(entry, result)
|
80
|
+
|
81
|
+
def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None:
|
82
|
+
"""Set the state of an entry."""
|
83
|
+
state = bool_to_entry_state(result, EntryStateSource.MDNS)
|
84
|
+
if result:
|
85
|
+
# If we can reach it via mDNS, we always set it online
|
86
|
+
# since its the fastest source if its working
|
87
|
+
self.dashboard.entries.async_set_state(entry, state)
|
88
|
+
else:
|
89
|
+
# However if we can't reach it via mDNS
|
90
|
+
# we only set it to offline if the state is unknown
|
91
|
+
# or from mDNS
|
92
|
+
self.dashboard.entries.async_set_state_if_source(entry, state)
|
62
93
|
|
63
94
|
async def async_run(self) -> None:
|
64
|
-
|
95
|
+
"""Run the mdns status."""
|
96
|
+
dashboard = self.dashboard
|
65
97
|
entries = dashboard.entries
|
66
|
-
aiozc = AsyncEsphomeZeroconf()
|
67
|
-
self.aiozc = aiozc
|
68
98
|
host_mdns_state = self.host_mdns_state
|
69
99
|
|
70
100
|
def on_update(dat: dict[str, bool | None]) -> None:
|
@@ -73,15 +103,14 @@ class MDNSStatus:
|
|
73
103
|
host_mdns_state[name] = result
|
74
104
|
if matching_entries := entries.get_by_name(name):
|
75
105
|
for entry in matching_entries:
|
76
|
-
|
77
|
-
entries.async_set_state(entry, bool_to_entry_state(result))
|
106
|
+
self._async_set_state(entry, result)
|
78
107
|
|
79
108
|
stat = DashboardStatus(on_update)
|
80
109
|
imports = DashboardImportDiscovery()
|
81
110
|
dashboard.import_result = imports.import_state
|
82
111
|
|
83
112
|
browser = DashboardBrowser(
|
84
|
-
aiozc.zeroconf,
|
113
|
+
self.aiozc.zeroconf,
|
85
114
|
ESPHOME_SERVICE_TYPE,
|
86
115
|
[stat.browser_callback, imports.browser_callback],
|
87
116
|
)
|
@@ -93,5 +122,5 @@ class MDNSStatus:
|
|
93
122
|
ping_request.clear()
|
94
123
|
|
95
124
|
await browser.async_cancel()
|
96
|
-
await aiozc.async_close()
|
125
|
+
await self.aiozc.async_close()
|
97
126
|
self.aiozc = None
|
esphome/dashboard/status/mqtt.py
CHANGED
@@ -4,19 +4,27 @@ import binascii
|
|
4
4
|
import json
|
5
5
|
import os
|
6
6
|
import threading
|
7
|
+
import typing
|
7
8
|
|
8
9
|
from esphome import mqtt
|
9
10
|
|
10
|
-
from ..
|
11
|
-
|
11
|
+
from ..entries import EntryStateSource, bool_to_entry_state
|
12
|
+
|
13
|
+
if typing.TYPE_CHECKING:
|
14
|
+
from ..core import ESPHomeDashboard
|
12
15
|
|
13
16
|
|
14
17
|
class MqttStatusThread(threading.Thread):
|
15
18
|
"""Status thread to get the status of the devices via MQTT."""
|
16
19
|
|
20
|
+
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
21
|
+
"""Initialize the status thread."""
|
22
|
+
super().__init__()
|
23
|
+
self.dashboard = dashboard
|
24
|
+
|
17
25
|
def run(self) -> None:
|
18
26
|
"""Run the status thread."""
|
19
|
-
dashboard =
|
27
|
+
dashboard = self.dashboard
|
20
28
|
entries = dashboard.entries
|
21
29
|
current_entries = entries.all()
|
22
30
|
|
@@ -31,10 +39,13 @@ class MqttStatusThread(threading.Thread):
|
|
31
39
|
data = json.loads(payload)
|
32
40
|
if "name" not in data:
|
33
41
|
return
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
42
|
+
if matching_entries := entries.get_by_name(data["name"]):
|
43
|
+
for entry in matching_entries:
|
44
|
+
# Only override state if we don't have a state from another source
|
45
|
+
# or we have a state from MQTT and the device is reachable
|
46
|
+
entries.set_state_if_online_or_source(
|
47
|
+
entry, bool_to_entry_state(True, EntryStateSource.MQTT)
|
48
|
+
)
|
38
49
|
|
39
50
|
def on_connect(client, userdata, flags, return_code):
|
40
51
|
client.publish("esphome/discover", None, retain=False)
|
@@ -56,8 +67,10 @@ class MqttStatusThread(threading.Thread):
|
|
56
67
|
current_entries = entries.all()
|
57
68
|
# will be set to true on on_message
|
58
69
|
for entry in current_entries:
|
59
|
-
if
|
60
|
-
|
70
|
+
# Only override state if we don't have a state from another source
|
71
|
+
entries.set_state_if_source(
|
72
|
+
entry, bool_to_entry_state(False, EntryStateSource.MQTT)
|
73
|
+
)
|
61
74
|
|
62
75
|
client.publish("esphome/discover", None, retain=False)
|
63
76
|
dashboard.mqtt_ping_request.wait()
|
esphome/dashboard/status/ping.py
CHANGED
@@ -3,29 +3,44 @@ from __future__ import annotations
|
|
3
3
|
import asyncio
|
4
4
|
import logging
|
5
5
|
import time
|
6
|
+
import typing
|
6
7
|
from typing import cast
|
7
8
|
|
8
9
|
from icmplib import Host, SocketPermissionError, async_ping
|
9
10
|
|
10
11
|
from ..const import MAX_EXECUTOR_WORKERS
|
11
|
-
from ..
|
12
|
-
|
12
|
+
from ..entries import (
|
13
|
+
DashboardEntry,
|
14
|
+
EntryState,
|
15
|
+
EntryStateSource,
|
16
|
+
ReachableState,
|
17
|
+
bool_to_entry_state,
|
18
|
+
)
|
13
19
|
from ..util.itertools import chunked
|
14
20
|
|
21
|
+
if typing.TYPE_CHECKING:
|
22
|
+
from ..core import ESPHomeDashboard
|
23
|
+
|
24
|
+
|
15
25
|
_LOGGER = logging.getLogger(__name__)
|
16
26
|
|
17
27
|
GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2)
|
18
28
|
|
29
|
+
DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING)
|
30
|
+
|
31
|
+
MIN_PING_INTERVAL = 5 # ensure we don't ping too often
|
32
|
+
|
19
33
|
|
20
34
|
class PingStatus:
|
21
|
-
def __init__(self) -> None:
|
35
|
+
def __init__(self, dashboard: ESPHomeDashboard) -> None:
|
22
36
|
"""Initialize the PingStatus class."""
|
23
37
|
super().__init__()
|
24
38
|
self._loop = asyncio.get_running_loop()
|
39
|
+
self.dashboard = dashboard
|
25
40
|
|
26
41
|
async def async_run(self) -> None:
|
27
42
|
"""Run the ping status."""
|
28
|
-
dashboard =
|
43
|
+
dashboard = self.dashboard
|
29
44
|
entries = dashboard.entries
|
30
45
|
privileged = await _can_use_icmp_lib_with_privilege()
|
31
46
|
if privileged is None:
|
@@ -36,10 +51,24 @@ class PingStatus:
|
|
36
51
|
# Only ping if the dashboard is open
|
37
52
|
await dashboard.ping_request.wait()
|
38
53
|
dashboard.ping_request.clear()
|
54
|
+
iteration_start = time.monotonic()
|
39
55
|
current_entries = dashboard.entries.async_all()
|
40
|
-
to_ping: list[DashboardEntry] = [
|
41
|
-
|
42
|
-
|
56
|
+
to_ping: list[DashboardEntry] = []
|
57
|
+
|
58
|
+
for entry in current_entries:
|
59
|
+
if entry.address is None:
|
60
|
+
# No address or we already have a state from another source
|
61
|
+
# so no need to ping
|
62
|
+
continue
|
63
|
+
if (
|
64
|
+
entry.state.reachable is ReachableState.ONLINE
|
65
|
+
and entry.state.source
|
66
|
+
not in (EntryStateSource.PING, EntryStateSource.UNKNOWN)
|
67
|
+
):
|
68
|
+
# If we already have a state from another source and
|
69
|
+
# it's online, we don't need to ping
|
70
|
+
continue
|
71
|
+
to_ping.append(entry)
|
43
72
|
|
44
73
|
# Resolve DNS for all entries
|
45
74
|
entries_with_addresses: dict[DashboardEntry, list[str]] = {}
|
@@ -56,7 +85,10 @@ class PingStatus:
|
|
56
85
|
|
57
86
|
for entry, result in zip(ping_group, dns_results):
|
58
87
|
if isinstance(result, Exception):
|
59
|
-
|
88
|
+
# Only update state if its unknown or from ping
|
89
|
+
# so we don't mark it as offline if we have a state
|
90
|
+
# from mDNS or MQTT
|
91
|
+
entries.async_set_state_if_source(entry, DNS_FAILURE_STATE)
|
60
92
|
continue
|
61
93
|
if isinstance(result, BaseException):
|
62
94
|
raise result
|
@@ -82,8 +114,20 @@ class PingStatus:
|
|
82
114
|
else:
|
83
115
|
host: Host = result
|
84
116
|
ping_result = host.is_alive
|
85
|
-
entry
|
86
|
-
|
117
|
+
entry: DashboardEntry = entry_addresses[0]
|
118
|
+
# If we can reach it via ping, we always set it
|
119
|
+
# online, however if we can't reach it via ping
|
120
|
+
# we only set it to offline if the state is unknown
|
121
|
+
# or from ping
|
122
|
+
entries.async_set_state_if_online_or_source(
|
123
|
+
entry,
|
124
|
+
bool_to_entry_state(ping_result, EntryStateSource.PING),
|
125
|
+
)
|
126
|
+
|
127
|
+
if not dashboard.stop_event.is_set():
|
128
|
+
iteration_duration = time.monotonic() - iteration_start
|
129
|
+
if iteration_duration < MIN_PING_INTERVAL:
|
130
|
+
await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration)
|
87
131
|
|
88
132
|
|
89
133
|
async def _can_use_icmp_lib_with_privilege() -> None | bool:
|
esphome/dashboard/web_server.py
CHANGED
@@ -33,18 +33,24 @@ import tornado.process
|
|
33
33
|
import tornado.queues
|
34
34
|
import tornado.web
|
35
35
|
import tornado.websocket
|
36
|
+
import voluptuous as vol
|
36
37
|
import yaml
|
37
38
|
from yaml.nodes import Node
|
38
39
|
|
39
40
|
from esphome import const, platformio_api, yaml_util
|
40
41
|
from esphome.helpers import get_bool_env, mkdir_p
|
41
|
-
from esphome.storage_json import
|
42
|
+
from esphome.storage_json import (
|
43
|
+
StorageJSON,
|
44
|
+
archive_storage_path,
|
45
|
+
ext_storage_path,
|
46
|
+
trash_storage_path,
|
47
|
+
)
|
42
48
|
from esphome.util import get_serial_ports, shlex_quote
|
43
49
|
from esphome.yaml_util import FastestAvailableSafeLoader
|
44
50
|
|
45
51
|
from .const import DASHBOARD_COMMAND
|
46
52
|
from .core import DASHBOARD
|
47
|
-
from .entries import
|
53
|
+
from .entries import UNKNOWN_STATE, entry_state_to_bool
|
48
54
|
from .util.file import write_file
|
49
55
|
from .util.subprocess import async_run_system_command
|
50
56
|
from .util.text import friendly_name_slugify
|
@@ -52,7 +58,6 @@ from .util.text import friendly_name_slugify
|
|
52
58
|
if TYPE_CHECKING:
|
53
59
|
from requests import Response
|
54
60
|
|
55
|
-
|
56
61
|
_LOGGER = logging.getLogger(__name__)
|
57
62
|
|
58
63
|
ENV_DEV = "ESPHOME_DASHBOARD_DEV"
|
@@ -381,7 +386,7 @@ class EsphomeRenameHandler(EsphomeCommandWebSocket):
|
|
381
386
|
# Remove the old ping result from the cache
|
382
387
|
entries = DASHBOARD.entries
|
383
388
|
if entry := entries.get(self.old_name):
|
384
|
-
entries.async_set_state(entry,
|
389
|
+
entries.async_set_state(entry, UNKNOWN_STATE)
|
385
390
|
|
386
391
|
|
387
392
|
class EsphomeUploadHandler(EsphomePortCommandWebSocket):
|
@@ -592,16 +597,39 @@ class IgnoreDeviceRequestHandler(BaseHandler):
|
|
592
597
|
class DownloadListRequestHandler(BaseHandler):
|
593
598
|
@authenticated
|
594
599
|
@bind_config
|
595
|
-
def get(self, configuration: str | None = None) -> None:
|
600
|
+
async def get(self, configuration: str | None = None) -> None:
|
601
|
+
loop = asyncio.get_running_loop()
|
602
|
+
try:
|
603
|
+
downloads_json = await loop.run_in_executor(None, self._get, configuration)
|
604
|
+
except vol.Invalid:
|
605
|
+
self.send_error(404)
|
606
|
+
return
|
607
|
+
if downloads_json is None:
|
608
|
+
self.send_error(404)
|
609
|
+
return
|
610
|
+
self.set_status(200)
|
611
|
+
self.set_header("content-type", "application/json")
|
612
|
+
self.write(downloads_json)
|
613
|
+
self.finish()
|
614
|
+
|
615
|
+
def _get(self, configuration: str | None = None) -> dict[str, Any] | None:
|
596
616
|
storage_path = ext_storage_path(configuration)
|
597
617
|
storage_json = StorageJSON.load(storage_path)
|
598
618
|
if storage_json is None:
|
599
|
-
|
600
|
-
|
619
|
+
return None
|
620
|
+
|
621
|
+
config = yaml_util.load_yaml(settings.rel_path(configuration))
|
622
|
+
|
623
|
+
if const.CONF_EXTERNAL_COMPONENTS in config:
|
624
|
+
from esphome.components.external_components import (
|
625
|
+
do_external_components_pass,
|
626
|
+
)
|
627
|
+
|
628
|
+
do_external_components_pass(config)
|
601
629
|
|
602
630
|
from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS
|
603
631
|
|
604
|
-
downloads = []
|
632
|
+
downloads: list[dict[str, Any]] = []
|
605
633
|
platform: str = storage_json.target_platform.lower()
|
606
634
|
|
607
635
|
if platform.upper() in ESP32_VARIANTS:
|
@@ -615,12 +643,7 @@ class DownloadListRequestHandler(BaseHandler):
|
|
615
643
|
except AttributeError as exc:
|
616
644
|
raise ValueError(f"Unknown platform {platform}") from exc
|
617
645
|
downloads = get_download_types(storage_json)
|
618
|
-
|
619
|
-
self.set_status(200)
|
620
|
-
self.set_header("content-type", "application/json")
|
621
|
-
self.write(json.dumps(downloads))
|
622
|
-
self.finish()
|
623
|
-
return
|
646
|
+
return json.dumps(downloads)
|
624
647
|
|
625
648
|
|
626
649
|
class DownloadBinaryRequestHandler(BaseHandler):
|
@@ -918,16 +941,16 @@ class EditRequestHandler(BaseHandler):
|
|
918
941
|
self.set_status(200)
|
919
942
|
|
920
943
|
|
921
|
-
class
|
944
|
+
class ArchiveRequestHandler(BaseHandler):
|
922
945
|
@authenticated
|
923
946
|
@bind_config
|
924
947
|
def post(self, configuration: str | None = None) -> None:
|
925
948
|
config_file = settings.rel_path(configuration)
|
926
949
|
storage_path = ext_storage_path(configuration)
|
927
950
|
|
928
|
-
|
929
|
-
mkdir_p(
|
930
|
-
shutil.move(config_file, os.path.join(
|
951
|
+
archive_path = archive_storage_path()
|
952
|
+
mkdir_p(archive_path)
|
953
|
+
shutil.move(config_file, os.path.join(archive_path, configuration))
|
931
954
|
|
932
955
|
storage_json = StorageJSON.load(storage_path)
|
933
956
|
if storage_json is not None:
|
@@ -935,16 +958,16 @@ class DeleteRequestHandler(BaseHandler):
|
|
935
958
|
name = storage_json.name
|
936
959
|
build_folder = os.path.join(settings.config_dir, name)
|
937
960
|
if build_folder is not None:
|
938
|
-
shutil.rmtree(build_folder, os.path.join(
|
961
|
+
shutil.rmtree(build_folder, os.path.join(archive_path, name))
|
939
962
|
|
940
963
|
|
941
|
-
class
|
964
|
+
class UnArchiveRequestHandler(BaseHandler):
|
942
965
|
@authenticated
|
943
966
|
@bind_config
|
944
967
|
def post(self, configuration: str | None = None) -> None:
|
945
968
|
config_file = settings.rel_path(configuration)
|
946
|
-
|
947
|
-
shutil.move(os.path.join(
|
969
|
+
archive_path = archive_storage_path()
|
970
|
+
shutil.move(os.path.join(archive_path, configuration), config_file)
|
948
971
|
|
949
972
|
|
950
973
|
class LoginHandler(BaseHandler):
|
@@ -1185,8 +1208,10 @@ def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application:
|
|
1185
1208
|
(f"{rel}download.bin", DownloadBinaryRequestHandler),
|
1186
1209
|
(f"{rel}serial-ports", SerialPortRequestHandler),
|
1187
1210
|
(f"{rel}ping", PingRequestHandler),
|
1188
|
-
(f"{rel}delete",
|
1189
|
-
(f"{rel}undo-delete",
|
1211
|
+
(f"{rel}delete", ArchiveRequestHandler),
|
1212
|
+
(f"{rel}undo-delete", UnArchiveRequestHandler),
|
1213
|
+
(f"{rel}archive", ArchiveRequestHandler),
|
1214
|
+
(f"{rel}unarchive", UnArchiveRequestHandler),
|
1190
1215
|
(f"{rel}wizard", WizardRequestHandler),
|
1191
1216
|
(f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}),
|
1192
1217
|
(f"{rel}devices", ListDevicesHandler),
|
@@ -1211,6 +1236,13 @@ def start_web_server(
|
|
1211
1236
|
config_dir: str,
|
1212
1237
|
) -> None:
|
1213
1238
|
"""Start the web server listener."""
|
1239
|
+
|
1240
|
+
trash_path = trash_storage_path()
|
1241
|
+
if os.path.exists(trash_path):
|
1242
|
+
_LOGGER.info("Renaming 'trash' folder to 'archive'")
|
1243
|
+
archive_path = archive_storage_path()
|
1244
|
+
shutil.move(trash_path, archive_path)
|
1245
|
+
|
1214
1246
|
if socket is None:
|
1215
1247
|
_LOGGER.info(
|
1216
1248
|
"Starting dashboard web server on http://%s:%s and configuration dir %s...",
|