esphome 2025.2.1__py3-none-any.whl → 2025.3.0__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.
Files changed (152) hide show
  1. esphome/__main__.py +9 -1
  2. esphome/components/api/api_connection.cpp +426 -70
  3. esphome/components/api/api_connection.h +117 -25
  4. esphome/components/api/api_pb2.cpp +33 -0
  5. esphome/components/api/api_pb2.h +4 -0
  6. esphome/components/api/api_server.cpp +2 -2
  7. esphome/components/api/list_entities.cpp +76 -22
  8. esphome/components/api/list_entities.h +1 -0
  9. esphome/components/api/subscribe_state.h +2 -0
  10. esphome/components/audio/__init__.py +1 -1
  11. esphome/components/audio/audio_decoder.cpp +43 -11
  12. esphome/components/audio/audio_reader.cpp +9 -9
  13. esphome/components/audio/audio_reader.h +1 -1
  14. esphome/components/audio/audio_resampler.cpp +4 -2
  15. esphome/components/audio/audio_transfer_buffer.cpp +19 -9
  16. esphome/components/audio/audio_transfer_buffer.h +7 -2
  17. esphome/components/bluetooth_proxy/bluetooth_proxy.h +8 -0
  18. esphome/components/bmp085/bmp085.cpp +1 -1
  19. esphome/components/chsc6x/__init__.py +2 -0
  20. esphome/components/chsc6x/chsc6x_touchscreen.cpp +47 -0
  21. esphome/components/chsc6x/chsc6x_touchscreen.h +34 -0
  22. esphome/components/chsc6x/touchscreen.py +33 -0
  23. esphome/components/climate/__init__.py +0 -1
  24. esphome/components/cst816/binary_sensor/__init__.py +2 -25
  25. esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +3 -14
  26. esphome/components/cst816/touchscreen/cst816_touchscreen.h +0 -4
  27. esphome/components/esp32_ble_beacon/__init__.py +3 -1
  28. esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +2 -2
  29. esphome/components/esp8266/gpio.py +1 -2
  30. esphome/components/font/__init__.py +198 -215
  31. esphome/components/font/font.cpp +4 -4
  32. esphome/components/font/font.h +1 -0
  33. esphome/components/graph/graph.cpp +4 -0
  34. esphome/components/graph/graph.h +4 -0
  35. esphome/components/haier/climate.py +11 -10
  36. esphome/components/hbridge/switch/hbridge_switch.cpp +2 -2
  37. esphome/components/heatpumpir/climate.py +2 -1
  38. esphome/components/heatpumpir/heatpumpir.cpp +1 -0
  39. esphome/components/heatpumpir/heatpumpir.h +1 -0
  40. esphome/components/i2c/__init__.py +6 -6
  41. esphome/components/i2c/i2c_bus_esp_idf.cpp +6 -2
  42. esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +1 -1
  43. esphome/components/ili9xxx/display.py +1 -0
  44. esphome/components/ili9xxx/ili9xxx_display.h +5 -0
  45. esphome/components/ili9xxx/ili9xxx_init.h +59 -0
  46. esphome/components/ld2450/__init__.py +51 -0
  47. esphome/components/ld2450/binary_sensor.py +47 -0
  48. esphome/components/ld2450/button/__init__.py +45 -0
  49. esphome/components/ld2450/button/reset_button.cpp +9 -0
  50. esphome/components/ld2450/button/reset_button.h +18 -0
  51. esphome/components/ld2450/button/restart_button.cpp +9 -0
  52. esphome/components/ld2450/button/restart_button.h +18 -0
  53. esphome/components/ld2450/ld2450.cpp +876 -0
  54. esphome/components/ld2450/ld2450.h +234 -0
  55. esphome/components/ld2450/number/__init__.py +121 -0
  56. esphome/components/ld2450/number/presence_timeout_number.cpp +12 -0
  57. esphome/components/ld2450/number/presence_timeout_number.h +18 -0
  58. esphome/components/ld2450/number/zone_coordinate_number.cpp +14 -0
  59. esphome/components/ld2450/number/zone_coordinate_number.h +19 -0
  60. esphome/components/ld2450/select/__init__.py +56 -0
  61. esphome/components/ld2450/select/baud_rate_select.cpp +12 -0
  62. esphome/components/ld2450/select/baud_rate_select.h +18 -0
  63. esphome/components/ld2450/select/zone_type_select.cpp +12 -0
  64. esphome/components/ld2450/select/zone_type_select.h +18 -0
  65. esphome/components/ld2450/sensor.py +156 -0
  66. esphome/components/ld2450/switch/__init__.py +45 -0
  67. esphome/components/ld2450/switch/bluetooth_switch.cpp +12 -0
  68. esphome/components/ld2450/switch/bluetooth_switch.h +18 -0
  69. esphome/components/ld2450/switch/multi_target_switch.cpp +12 -0
  70. esphome/components/ld2450/switch/multi_target_switch.h +18 -0
  71. esphome/components/ld2450/text_sensor.py +62 -0
  72. esphome/components/ltr390/ltr390.cpp +7 -7
  73. esphome/components/ltr390/ltr390.h +0 -1
  74. esphome/components/lvgl/defines.py +0 -2
  75. esphome/components/lvgl/font.cpp +1 -1
  76. esphome/components/lvgl/lvgl_esphome.cpp +27 -19
  77. esphome/components/lvgl/widgets/img.py +1 -3
  78. esphome/components/mcp2515/mcp2515.cpp +1 -0
  79. esphome/components/mdns/__init__.py +1 -1
  80. esphome/components/mixer/speaker/mixer_speaker.cpp +6 -1
  81. esphome/components/mixer/speaker/mixer_speaker.h +2 -0
  82. esphome/components/mlx90393/sensor.py +53 -33
  83. esphome/components/mlx90393/sensor_mlx90393.cpp +4 -0
  84. esphome/components/mlx90393/sensor_mlx90393.h +8 -3
  85. esphome/components/mqtt/__init__.py +2 -2
  86. esphome/components/msa3xx/__init__.py +189 -0
  87. esphome/components/msa3xx/binary_sensor.py +40 -0
  88. esphome/components/msa3xx/msa3xx.cpp +417 -0
  89. esphome/components/msa3xx/msa3xx.h +311 -0
  90. esphome/components/msa3xx/sensor.py +42 -0
  91. esphome/components/msa3xx/text_sensor.py +38 -0
  92. esphome/components/nfc/binary_sensor/__init__.py +4 -4
  93. esphome/components/opentherm/binary_sensor/__init__.py +4 -4
  94. esphome/components/opentherm/generate.py +6 -6
  95. esphome/components/opentherm/sensor/__init__.py +5 -6
  96. esphome/components/packages/__init__.py +35 -11
  97. esphome/components/pn532/binary_sensor.py +4 -4
  98. esphome/components/rc522/binary_sensor.py +4 -4
  99. esphome/components/resampler/speaker/resampler_speaker.h +2 -0
  100. esphome/components/socket/bsd_sockets_impl.cpp +1 -0
  101. esphome/components/socket/lwip_sockets_impl.cpp +1 -0
  102. esphome/components/socket/socket.h +3 -1
  103. esphome/components/speaker/speaker.h +2 -2
  104. esphome/components/ssd1306_base/__init__.py +7 -7
  105. esphome/components/thermostat/climate.py +1 -1
  106. esphome/components/tmp1075/tmp1075.cpp +7 -11
  107. esphome/components/tmp1075/tmp1075.h +1 -2
  108. esphome/components/tormatic/__init__.py +1 -0
  109. esphome/components/tormatic/cover.py +47 -0
  110. esphome/components/tormatic/tormatic_cover.cpp +355 -0
  111. esphome/components/tormatic/tormatic_cover.h +60 -0
  112. esphome/components/tormatic/tormatic_protocol.h +211 -0
  113. esphome/components/touchscreen/binary_sensor/__init__.py +3 -0
  114. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp +7 -1
  115. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +3 -1
  116. esphome/components/touchscreen/touchscreen.cpp +3 -4
  117. esphome/components/udp/udp_component.h +4 -1
  118. esphome/components/web_server/list_entities.cpp +70 -66
  119. esphome/components/web_server/list_entities.h +43 -22
  120. esphome/components/web_server/web_server.cpp +345 -68
  121. esphome/components/web_server/web_server.h +138 -6
  122. esphome/components/web_server_base/__init__.py +1 -1
  123. esphome/components/web_server_idf/__init__.py +2 -0
  124. esphome/components/web_server_idf/web_server_idf.cpp +177 -30
  125. esphome/components/web_server_idf/web_server_idf.h +53 -4
  126. esphome/config_validation.py +23 -125
  127. esphome/const.py +5 -1
  128. esphome/core/config.py +15 -6
  129. esphome/core/defines.h +1 -1
  130. esphome/core/helpers.h +24 -3
  131. esphome/core/time.cpp +1 -0
  132. esphome/cpp_generator.py +3 -3
  133. esphome/dashboard/core.py +30 -21
  134. esphome/dashboard/dns.py +7 -1
  135. esphome/dashboard/entries.py +83 -16
  136. esphome/dashboard/settings.py +0 -4
  137. esphome/dashboard/status/mdns.py +43 -14
  138. esphome/dashboard/status/mqtt.py +22 -9
  139. esphome/dashboard/status/ping.py +54 -10
  140. esphome/dashboard/web_server.py +56 -24
  141. esphome/storage_json.py +4 -0
  142. esphome/wizard.py +13 -17
  143. esphome/writer.py +1 -3
  144. esphome/yaml_util.py +36 -33
  145. esphome/zeroconf.py +9 -21
  146. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/METADATA +7 -7
  147. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/RECORD +151 -111
  148. esphome/components/cst816/binary_sensor/cst816_button.h +0 -27
  149. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/LICENSE +0 -0
  150. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/WHEEL +0 -0
  151. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/entry_points.txt +0 -0
  152. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/top_level.txt +0 -0
@@ -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
- class EntryState(StrEnum):
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
- _BOOL_TO_ENTRY_STATE = {
42
- True: EntryState.ONLINE,
43
- False: EntryState.OFFLINE,
44
- None: EntryState.UNKNOWN,
55
+ _BOOL_TO_REACHABLE_STATE = {
56
+ True: ReachableState.ONLINE,
57
+ False: ReachableState.OFFLINE,
58
+ None: ReachableState.UNKNOWN,
45
59
  }
46
- _ENTRY_STATE_TO_BOOL = {
47
- EntryState.ONLINE: True,
48
- EntryState.OFFLINE: False,
49
- EntryState.UNKNOWN: None,
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
- def bool_to_entry_state(value: bool) -> EntryState:
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 _BOOL_TO_ENTRY_STATE[value]
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 _ENTRY_STATE_TO_BOOL[value]
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 = EntryState.UNKNOWN
339
+ self.state = UNKNOWN_STATE
273
340
  self._to_dict: dict[str, Any] | None = None
274
341
 
275
342
  def __repr__(self) -> str:
@@ -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")
@@ -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 ..core import DASHBOARD
15
- from ..entries import DashboardEntry, bool_to_entry_state
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 = 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
- entries.async_set_state(entry, bool_to_entry_state(online))
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
- entries.async_set_state(entry, bool_to_entry_state(result))
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
- dashboard = DASHBOARD
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
- if not entry.no_mdns:
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
@@ -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 ..core import DASHBOARD
11
- from ..entries import EntryState
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 = 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
- for entry in current_entries:
35
- if entry.name == data["name"]:
36
- entries.set_state(entry, EntryState.ONLINE)
37
- return
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 entry.no_mdns:
60
- entries.set_state(entry, EntryState.OFFLINE)
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()
@@ -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 ..core import DASHBOARD
12
- from ..entries import DashboardEntry, EntryState, bool_to_entry_state
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 = 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
- entry for entry in current_entries if entry.address is not None
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
- entries.async_set_state(entry, EntryState.UNKNOWN)
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, _ = entry_addresses
86
- entries.async_set_state(entry, bool_to_entry_state(ping_result))
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:
@@ -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 StorageJSON, ext_storage_path, trash_storage_path
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 EntryState, entry_state_to_bool
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, EntryState.UNKNOWN)
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
- self.send_error(404)
600
- return
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 DeleteRequestHandler(BaseHandler):
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
- trash_path = trash_storage_path()
929
- mkdir_p(trash_path)
930
- shutil.move(config_file, os.path.join(trash_path, configuration))
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(trash_path, name))
961
+ shutil.rmtree(build_folder, os.path.join(archive_path, name))
939
962
 
940
963
 
941
- class UndoDeleteRequestHandler(BaseHandler):
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
- trash_path = trash_storage_path()
947
- shutil.move(os.path.join(trash_path, configuration), config_file)
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", DeleteRequestHandler),
1189
- (f"{rel}undo-delete", UndoDeleteRequestHandler),
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...",
esphome/storage_json.py CHANGED
@@ -36,6 +36,10 @@ def trash_storage_path() -> str:
36
36
  return CORE.relative_config_path("trash")
37
37
 
38
38
 
39
+ def archive_storage_path() -> str:
40
+ return CORE.relative_config_path("archive")
41
+
42
+
39
43
  class StorageJSON:
40
44
  def __init__(
41
45
  self,