esphome 2025.7.0b5__py3-none-any.whl → 2025.7.2__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 (38) hide show
  1. esphome/components/api/homeassistant_service.h +13 -1
  2. esphome/components/e131/e131_packet.cpp +7 -1
  3. esphome/components/esp32/helpers.cpp +40 -0
  4. esphome/components/esp32_camera/__init__.py +22 -0
  5. esphome/components/esp8266/helpers.cpp +4 -0
  6. esphome/components/ethernet/ethernet_component.cpp +9 -2
  7. esphome/components/gpio/binary_sensor/__init__.py +15 -1
  8. esphome/components/libretiny/helpers.cpp +4 -0
  9. esphome/components/logger/__init__.py +2 -2
  10. esphome/components/lvgl/types.py +2 -2
  11. esphome/components/lvgl/widgets/meter.py +4 -1
  12. esphome/components/mqtt/mqtt_client.cpp +8 -4
  13. esphome/components/rp2040/helpers.cpp +4 -0
  14. esphome/components/speaker/media_player/audio_pipeline.cpp +14 -2
  15. esphome/components/speaker/media_player/audio_pipeline.h +1 -0
  16. esphome/components/voice_assistant/voice_assistant.cpp +52 -20
  17. esphome/components/voice_assistant/voice_assistant.h +11 -2
  18. esphome/components/web_server/__init__.py +14 -9
  19. esphome/components/web_server/ota/ota_web_server.cpp +21 -2
  20. esphome/components/web_server/web_server.cpp +6 -4
  21. esphome/components/web_server/web_server_v1.cpp +3 -1
  22. esphome/components/wifi/wifi_component_esp32_arduino.cpp +9 -22
  23. esphome/components/wireguard/wireguard.cpp +13 -3
  24. esphome/const.py +1 -1
  25. esphome/core/base_automation.h +2 -2
  26. esphome/core/component.cpp +2 -2
  27. esphome/core/event_pool.h +2 -2
  28. esphome/core/helpers.h +17 -0
  29. esphome/core/lock_free_queue.h +2 -7
  30. esphome/core/scheduler.cpp +1 -1
  31. esphome/core/scheduler.h +4 -3
  32. esphome/util.py +38 -0
  33. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/METADATA +1 -1
  34. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/RECORD +38 -38
  35. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/WHEEL +0 -0
  36. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/entry_points.txt +0 -0
  37. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/licenses/LICENSE +0 -0
  38. {esphome-2025.7.0b5.dist-info → esphome-2025.7.2.dist-info}/top_level.txt +0 -0
@@ -11,6 +11,18 @@ namespace esphome {
11
11
  namespace api {
12
12
 
13
13
  template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> {
14
+ private:
15
+ // Helper to convert value to string - handles the case where value is already a string
16
+ template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
17
+
18
+ // Overloads for string types - needed because std::to_string doesn't support them
19
+ static std::string value_to_string(char *val) {
20
+ return val ? std::string(val) : std::string();
21
+ } // For lambdas returning char* (e.g., itoa)
22
+ static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
23
+ static std::string value_to_string(const std::string &val) { return val; }
24
+ static std::string value_to_string(std::string &&val) { return std::move(val); }
25
+
14
26
  public:
15
27
  TemplatableStringValue() : TemplatableValue<std::string, X...>() {}
16
28
 
@@ -19,7 +31,7 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
19
31
 
20
32
  template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0>
21
33
  TemplatableStringValue(F f)
22
- : TemplatableValue<std::string, X...>([f](X... x) -> std::string { return to_string(f(x...)); }) {}
34
+ : TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {}
23
35
  };
24
36
 
25
37
  template<typename... Ts> class TemplatableKeyValuePair {
@@ -4,6 +4,7 @@
4
4
  #include "esphome/components/network/ip_address.h"
5
5
  #include "esphome/core/log.h"
6
6
  #include "esphome/core/util.h"
7
+ #include "esphome/core/helpers.h"
7
8
 
8
9
  #include <lwip/igmp.h>
9
10
  #include <lwip/init.h>
@@ -71,7 +72,11 @@ bool E131Component::join_igmp_groups_() {
71
72
  ip4_addr_t multicast_addr =
72
73
  network::IPAddress(239, 255, ((universe.first >> 8) & 0xff), ((universe.first >> 0) & 0xff));
73
74
 
74
- auto err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
75
+ err_t err;
76
+ {
77
+ LwIPLock lock;
78
+ err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr);
79
+ }
75
80
 
76
81
  if (err) {
77
82
  ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe.first);
@@ -104,6 +109,7 @@ void E131Component::leave_(int universe) {
104
109
  if (listen_method_ == E131_MULTICAST) {
105
110
  ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff));
106
111
 
112
+ LwIPLock lock;
107
113
  igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr);
108
114
  }
109
115
 
@@ -1,4 +1,5 @@
1
1
  #include "esphome/core/helpers.h"
2
+ #include "esphome/core/defines.h"
2
3
 
3
4
  #ifdef USE_ESP32
4
5
 
@@ -30,6 +31,45 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
30
31
  IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
31
32
  IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); }
32
33
 
34
+ #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
35
+ #include "lwip/priv/tcpip_priv.h"
36
+ #endif
37
+
38
+ LwIPLock::LwIPLock() {
39
+ #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
40
+ // When CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, lwIP uses a global mutex to protect
41
+ // its internal state. Any thread can take this lock to safely access lwIP APIs.
42
+ //
43
+ // sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) returns true if the current thread
44
+ // already holds the lwIP core lock. This prevents recursive locking attempts and
45
+ // allows nested LwIPLock instances to work correctly.
46
+ //
47
+ // If we don't already hold the lock, acquire it. This will block until the lock
48
+ // is available if another thread currently holds it.
49
+ if (!sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
50
+ LOCK_TCPIP_CORE();
51
+ }
52
+ #endif
53
+ }
54
+
55
+ LwIPLock::~LwIPLock() {
56
+ #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING
57
+ // Only release the lwIP core lock if this thread currently holds it.
58
+ //
59
+ // sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) queries lwIP's internal lock
60
+ // ownership tracking. It returns true only if the current thread is registered
61
+ // as the lock holder.
62
+ //
63
+ // This check is essential because:
64
+ // 1. We may not have acquired the lock in the constructor (if we already held it)
65
+ // 2. The lock might have been released by other means between constructor and destructor
66
+ // 3. Calling UNLOCK_TCPIP_CORE() without holding the lock causes undefined behavior
67
+ if (sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) {
68
+ UNLOCK_TCPIP_CORE();
69
+ }
70
+ #endif
71
+ }
72
+
33
73
  void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
34
74
  #if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
35
75
  // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default
@@ -1,3 +1,5 @@
1
+ import logging
2
+
1
3
  from esphome import automation, pins
2
4
  import esphome.codegen as cg
3
5
  from esphome.components import i2c
@@ -8,6 +10,7 @@ from esphome.const import (
8
10
  CONF_CONTRAST,
9
11
  CONF_DATA_PINS,
10
12
  CONF_FREQUENCY,
13
+ CONF_I2C,
11
14
  CONF_I2C_ID,
12
15
  CONF_ID,
13
16
  CONF_PIN,
@@ -20,6 +23,9 @@ from esphome.const import (
20
23
  )
21
24
  from esphome.core import CORE
22
25
  from esphome.core.entity_helpers import setup_entity
26
+ import esphome.final_validate as fv
27
+
28
+ _LOGGER = logging.getLogger(__name__)
23
29
 
24
30
  DEPENDENCIES = ["esp32"]
25
31
 
@@ -250,6 +256,22 @@ CONFIG_SCHEMA = cv.All(
250
256
  cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID),
251
257
  )
252
258
 
259
+
260
+ def _final_validate(config):
261
+ if CONF_I2C_PINS not in config:
262
+ return
263
+ fconf = fv.full_config.get()
264
+ if fconf.get(CONF_I2C):
265
+ raise cv.Invalid(
266
+ "The `i2c_pins:` config option is incompatible with an dedicated `i2c:` block, use `i2c_id` instead"
267
+ )
268
+ _LOGGER.warning(
269
+ "The `i2c_pins:` config option is deprecated. Use `i2c_id:` with a dedicated `i2c:` definition instead."
270
+ )
271
+
272
+
273
+ FINAL_VALIDATE_SCHEMA = _final_validate
274
+
253
275
  SETTERS = {
254
276
  # pin assignment
255
277
  CONF_DATA_PINS: "set_data_pins",
@@ -22,6 +22,10 @@ void Mutex::unlock() {}
22
22
  IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); }
23
23
  IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); }
24
24
 
25
+ // ESP8266 doesn't support lwIP core locking, so this is a no-op
26
+ LwIPLock::LwIPLock() {}
27
+ LwIPLock::~LwIPLock() {}
28
+
25
29
  void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
26
30
  wifi_get_macaddr(STATION_IF, mac);
27
31
  }
@@ -420,6 +420,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() {
420
420
  }
421
421
 
422
422
  network::IPAddress EthernetComponent::get_dns_address(uint8_t num) {
423
+ LwIPLock lock;
423
424
  const ip_addr_t *dns_ip = dns_getserver(num);
424
425
  return dns_ip;
425
426
  }
@@ -527,6 +528,7 @@ void EthernetComponent::start_connect_() {
527
528
  ESPHL_ERROR_CHECK(err, "DHCPC set IP info error");
528
529
 
529
530
  if (this->manual_ip_.has_value()) {
531
+ LwIPLock lock;
530
532
  if (this->manual_ip_->dns1.is_set()) {
531
533
  ip_addr_t d;
532
534
  d = this->manual_ip_->dns1;
@@ -559,8 +561,13 @@ bool EthernetComponent::is_connected() { return this->state_ == EthernetComponen
559
561
  void EthernetComponent::dump_connect_params_() {
560
562
  esp_netif_ip_info_t ip;
561
563
  esp_netif_get_ip_info(this->eth_netif_, &ip);
562
- const ip_addr_t *dns_ip1 = dns_getserver(0);
563
- const ip_addr_t *dns_ip2 = dns_getserver(1);
564
+ const ip_addr_t *dns_ip1;
565
+ const ip_addr_t *dns_ip2;
566
+ {
567
+ LwIPLock lock;
568
+ dns_ip1 = dns_getserver(0);
569
+ dns_ip2 = dns_getserver(1);
570
+ }
564
571
 
565
572
  ESP_LOGCONFIG(TAG,
566
573
  " IP Address: %s\n"
@@ -29,7 +29,21 @@ CONFIG_SCHEMA = (
29
29
  .extend(
30
30
  {
31
31
  cv.Required(CONF_PIN): pins.gpio_input_pin_schema,
32
- cv.Optional(CONF_USE_INTERRUPT, default=True): cv.boolean,
32
+ # Interrupts are disabled by default for bk72xx, ln882x, and rtl87xx platforms
33
+ # due to hardware limitations or lack of reliable interrupt support. This ensures
34
+ # stable operation on these platforms. Future maintainers should verify platform
35
+ # capabilities before changing this default behavior.
36
+ cv.SplitDefault(
37
+ CONF_USE_INTERRUPT,
38
+ bk72xx=False,
39
+ esp32=True,
40
+ esp8266=True,
41
+ host=True,
42
+ ln882x=False,
43
+ nrf52=True,
44
+ rp2040=True,
45
+ rtl87xx=False,
46
+ ): cv.boolean,
33
47
  cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum(
34
48
  INTERRUPT_TYPES, upper=True
35
49
  ),
@@ -26,6 +26,10 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); }
26
26
  IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); }
27
27
  IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); }
28
28
 
29
+ // LibreTiny doesn't support lwIP core locking, so this is a no-op
30
+ LwIPLock::LwIPLock() {}
31
+ LwIPLock::~LwIPLock() {}
32
+
29
33
  void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
30
34
  WiFi.macAddress(mac);
31
35
  }
@@ -183,7 +183,7 @@ def validate_local_no_higher_than_global(value):
183
183
  Logger = logger_ns.class_("Logger", cg.Component)
184
184
  LoggerMessageTrigger = logger_ns.class_(
185
185
  "LoggerMessageTrigger",
186
- automation.Trigger.template(cg.int_, cg.const_char_ptr, cg.const_char_ptr),
186
+ automation.Trigger.template(cg.uint8, cg.const_char_ptr, cg.const_char_ptr),
187
187
  )
188
188
 
189
189
  CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash"
@@ -368,7 +368,7 @@ async def to_code(config):
368
368
  await automation.build_automation(
369
369
  trigger,
370
370
  [
371
- (cg.int_, "level"),
371
+ (cg.uint8, "level"),
372
372
  (cg.const_char_ptr, "tag"),
373
373
  (cg.const_char_ptr, "message"),
374
374
  ],
@@ -192,7 +192,7 @@ class WidgetType:
192
192
 
193
193
  class NumberType(WidgetType):
194
194
  def get_max(self, config: dict):
195
- return int(config[CONF_MAX_VALUE] or 100)
195
+ return int(config.get(CONF_MAX_VALUE, 100))
196
196
 
197
197
  def get_min(self, config: dict):
198
- return int(config[CONF_MIN_VALUE] or 0)
198
+ return int(config.get(CONF_MIN_VALUE, 0))
@@ -14,6 +14,7 @@ from esphome.const import (
14
14
  CONF_VALUE,
15
15
  CONF_WIDTH,
16
16
  )
17
+ from esphome.cpp_generator import IntLiteral
17
18
 
18
19
  from ..automation import action_to_code
19
20
  from ..defines import (
@@ -188,6 +189,8 @@ class MeterType(WidgetType):
188
189
  rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2
189
190
  if CONF_ROTATION in scale_conf:
190
191
  rotation = await lv_angle.process(scale_conf[CONF_ROTATION])
192
+ if isinstance(rotation, IntLiteral):
193
+ rotation = int(str(rotation)) // 10
191
194
  with LocalVariable(
192
195
  "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var)
193
196
  ) as meter_var:
@@ -264,7 +267,7 @@ class MeterType(WidgetType):
264
267
  color_start,
265
268
  color_end,
266
269
  v[CONF_LOCAL],
267
- size.process(v[CONF_WIDTH]),
270
+ await size.process(v[CONF_WIDTH]),
268
271
  ),
269
272
  )
270
273
  if t == CONF_IMAGE:
@@ -193,13 +193,17 @@ void MQTTClientComponent::start_dnslookup_() {
193
193
  this->dns_resolve_error_ = false;
194
194
  this->dns_resolved_ = false;
195
195
  ip_addr_t addr;
196
+ err_t err;
197
+ {
198
+ LwIPLock lock;
196
199
  #if USE_NETWORK_IPV6
197
- err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr,
198
- MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV6_IPV4);
200
+ err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
201
+ this, LWIP_DNS_ADDRTYPE_IPV6_IPV4);
199
202
  #else
200
- err_t err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr,
201
- MQTTClientComponent::dns_found_callback, this, LWIP_DNS_ADDRTYPE_IPV4);
203
+ err = dns_gethostbyname_addrtype(this->credentials_.address.c_str(), &addr, MQTTClientComponent::dns_found_callback,
204
+ this, LWIP_DNS_ADDRTYPE_IPV4);
202
205
  #endif /* USE_NETWORK_IPV6 */
206
+ }
203
207
  switch (err) {
204
208
  case ERR_OK: {
205
209
  // Got IP immediately
@@ -44,6 +44,10 @@ void Mutex::unlock() {}
44
44
  IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); }
45
45
  IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
46
46
 
47
+ // RP2040 doesn't support lwIP core locking, so this is a no-op
48
+ LwIPLock::LwIPLock() {}
49
+ LwIPLock::~LwIPLock() {}
50
+
47
51
  void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
48
52
  #ifdef USE_WIFI
49
53
  WiFi.macAddress(mac);
@@ -200,7 +200,7 @@ AudioPipelineState AudioPipeline::process_state() {
200
200
  if ((this->read_task_handle_ != nullptr) || (this->decode_task_handle_ != nullptr)) {
201
201
  this->delete_tasks_();
202
202
  if (this->hard_stop_) {
203
- // Stop command was sent, so immediately end of the playback
203
+ // Stop command was sent, so immediately end the playback
204
204
  this->speaker_->stop();
205
205
  this->hard_stop_ = false;
206
206
  } else {
@@ -210,13 +210,25 @@ AudioPipelineState AudioPipeline::process_state() {
210
210
  }
211
211
  }
212
212
  this->is_playing_ = false;
213
- return AudioPipelineState::STOPPED;
213
+ if (!this->speaker_->is_running()) {
214
+ return AudioPipelineState::STOPPED;
215
+ } else {
216
+ this->is_finishing_ = true;
217
+ }
214
218
  }
215
219
 
216
220
  if (this->pause_state_) {
217
221
  return AudioPipelineState::PAUSED;
218
222
  }
219
223
 
224
+ if (this->is_finishing_) {
225
+ if (!this->speaker_->is_running()) {
226
+ this->is_finishing_ = false;
227
+ } else {
228
+ return AudioPipelineState::PLAYING;
229
+ }
230
+ }
231
+
220
232
  if ((this->read_task_handle_ == nullptr) && (this->decode_task_handle_ == nullptr)) {
221
233
  // No tasks are running, so the pipeline is stopped.
222
234
  xEventGroupClearBits(this->event_group_, EventGroupBits::PIPELINE_COMMAND_STOP);
@@ -114,6 +114,7 @@ class AudioPipeline {
114
114
 
115
115
  bool hard_stop_{false};
116
116
  bool is_playing_{false};
117
+ bool is_finishing_{false};
117
118
  bool pause_state_{false};
118
119
  bool task_stack_in_psram_;
119
120
 
@@ -35,6 +35,27 @@ void VoiceAssistant::setup() {
35
35
  temp_ring_buffer->write((void *) data.data(), data.size());
36
36
  }
37
37
  });
38
+
39
+ #ifdef USE_MEDIA_PLAYER
40
+ if (this->media_player_ != nullptr) {
41
+ this->media_player_->add_on_state_callback([this]() {
42
+ switch (this->media_player_->state) {
43
+ case media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING:
44
+ if (this->media_player_response_state_ == MediaPlayerResponseState::URL_SENT) {
45
+ // State changed to announcing after receiving the url
46
+ this->media_player_response_state_ = MediaPlayerResponseState::PLAYING;
47
+ }
48
+ break;
49
+ default:
50
+ if (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING) {
51
+ // No longer announcing the TTS response
52
+ this->media_player_response_state_ = MediaPlayerResponseState::FINISHED;
53
+ }
54
+ break;
55
+ }
56
+ });
57
+ }
58
+ #endif
38
59
  }
39
60
 
40
61
  float VoiceAssistant::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
@@ -223,6 +244,13 @@ void VoiceAssistant::loop() {
223
244
  msg.wake_word_phrase = this->wake_word_;
224
245
  this->wake_word_ = "";
225
246
 
247
+ // Reset media player state tracking
248
+ #ifdef USE_MEDIA_PLAYER
249
+ if (this->media_player_ != nullptr) {
250
+ this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
251
+ }
252
+ #endif
253
+
226
254
  if (this->api_client_ == nullptr || !this->api_client_->send_message(msg)) {
227
255
  ESP_LOGW(TAG, "Could not request start");
228
256
  this->error_trigger_->trigger("not-connected", "Could not request start");
@@ -314,17 +342,10 @@ void VoiceAssistant::loop() {
314
342
  #endif
315
343
  #ifdef USE_MEDIA_PLAYER
316
344
  if (this->media_player_ != nullptr) {
317
- playing = (this->media_player_->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING);
318
-
319
- if (playing && this->media_player_wait_for_announcement_start_) {
320
- // Announcement has started playing, wait for it to finish
321
- this->media_player_wait_for_announcement_start_ = false;
322
- this->media_player_wait_for_announcement_end_ = true;
323
- }
345
+ playing = (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING);
324
346
 
325
- if (!playing && this->media_player_wait_for_announcement_end_) {
326
- // Announcement has finished playing
327
- this->media_player_wait_for_announcement_end_ = false;
347
+ if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) {
348
+ this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
328
349
  this->cancel_timeout("playing");
329
350
  ESP_LOGD(TAG, "Announcement finished playing");
330
351
  this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
@@ -555,7 +576,7 @@ void VoiceAssistant::request_stop() {
555
576
  break;
556
577
  case State::AWAITING_RESPONSE:
557
578
  this->signal_stop_();
558
- // Fallthrough intended to stop a streaming TTS announcement that has potentially started
579
+ break;
559
580
  case State::STREAMING_RESPONSE:
560
581
  #ifdef USE_MEDIA_PLAYER
561
582
  // Stop any ongoing media player announcement
@@ -565,6 +586,10 @@ void VoiceAssistant::request_stop() {
565
586
  .set_announcement(true)
566
587
  .perform();
567
588
  }
589
+ if (this->started_streaming_tts_) {
590
+ // Haven't reached the TTS_END stage, so send the stop signal to HA.
591
+ this->signal_stop_();
592
+ }
568
593
  #endif
569
594
  break;
570
595
  case State::RESPONSE_FINISHED:
@@ -648,13 +673,16 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
648
673
  if (this->media_player_ != nullptr) {
649
674
  for (const auto &arg : msg.data) {
650
675
  if ((arg.name == "tts_start_streaming") && (arg.value == "1") && !this->tts_response_url_.empty()) {
676
+ this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
677
+
651
678
  this->media_player_->make_call().set_media_url(this->tts_response_url_).set_announcement(true).perform();
652
679
 
653
- this->media_player_wait_for_announcement_start_ = true;
654
- this->media_player_wait_for_announcement_end_ = false;
655
680
  this->started_streaming_tts_ = true;
681
+ this->start_playback_timeout_();
682
+
656
683
  tts_url_for_trigger = this->tts_response_url_;
657
684
  this->tts_response_url_.clear(); // Reset streaming URL
685
+ this->set_state_(State::STREAMING_RESPONSE, State::STREAMING_RESPONSE);
658
686
  }
659
687
  }
660
688
  }
@@ -713,18 +741,22 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
713
741
  this->defer([this, url]() {
714
742
  #ifdef USE_MEDIA_PLAYER
715
743
  if ((this->media_player_ != nullptr) && (!this->started_streaming_tts_)) {
744
+ this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
745
+
716
746
  this->media_player_->make_call().set_media_url(url).set_announcement(true).perform();
717
747
 
718
- this->media_player_wait_for_announcement_start_ = true;
719
- this->media_player_wait_for_announcement_end_ = false;
720
- // Start the playback timeout, as the media player state isn't immediately updated
721
748
  this->start_playback_timeout_();
722
749
  }
750
+ this->started_streaming_tts_ = false; // Helps indicate reaching the TTS_END stage
723
751
  #endif
724
752
  this->tts_end_trigger_->trigger(url);
725
753
  });
726
754
  State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE;
727
- this->set_state_(new_state, new_state);
755
+ if (new_state != this->state_) {
756
+ // Don't needlessly change the state. The intent progress stage may have already changed the state to streaming
757
+ // response.
758
+ this->set_state_(new_state, new_state);
759
+ }
728
760
  break;
729
761
  }
730
762
  case api::enums::VOICE_ASSISTANT_RUN_END: {
@@ -875,6 +907,9 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
875
907
  #ifdef USE_MEDIA_PLAYER
876
908
  if (this->media_player_ != nullptr) {
877
909
  this->tts_start_trigger_->trigger(msg.text);
910
+
911
+ this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
912
+
878
913
  if (!msg.preannounce_media_id.empty()) {
879
914
  this->media_player_->make_call().set_media_url(msg.preannounce_media_id).set_announcement(true).perform();
880
915
  }
@@ -886,9 +921,6 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
886
921
  .perform();
887
922
  this->continue_conversation_ = msg.start_conversation;
888
923
 
889
- this->media_player_wait_for_announcement_start_ = true;
890
- this->media_player_wait_for_announcement_end_ = false;
891
- // Start the playback timeout, as the media player state isn't immediately updated
892
924
  this->start_playback_timeout_();
893
925
 
894
926
  if (this->continuous_) {
@@ -90,6 +90,15 @@ struct Configuration {
90
90
  uint32_t max_active_wake_words;
91
91
  };
92
92
 
93
+ #ifdef USE_MEDIA_PLAYER
94
+ enum class MediaPlayerResponseState {
95
+ IDLE,
96
+ URL_SENT,
97
+ PLAYING,
98
+ FINISHED,
99
+ };
100
+ #endif
101
+
93
102
  class VoiceAssistant : public Component {
94
103
  public:
95
104
  VoiceAssistant();
@@ -272,8 +281,8 @@ class VoiceAssistant : public Component {
272
281
  media_player::MediaPlayer *media_player_{nullptr};
273
282
  std::string tts_response_url_{""};
274
283
  bool started_streaming_tts_{false};
275
- bool media_player_wait_for_announcement_start_{false};
276
- bool media_player_wait_for_announcement_end_{false};
284
+
285
+ MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE};
277
286
  #endif
278
287
 
279
288
  bool local_output_{false};
@@ -74,13 +74,14 @@ def validate_local(config: ConfigType) -> ConfigType:
74
74
  return config
75
75
 
76
76
 
77
- def validate_ota_removed(config: ConfigType) -> ConfigType:
78
- # Only raise error if OTA is explicitly enabled (True)
79
- # If it's False or not specified, we can safely ignore it
80
- if config.get(CONF_OTA):
77
+ def validate_ota(config: ConfigType) -> ConfigType:
78
+ # The OTA option only accepts False to explicitly disable OTA for web_server
79
+ # IMPORTANT: Setting ota: false ONLY affects the web_server component
80
+ # The captive_portal component will still be able to perform OTA updates
81
+ if CONF_OTA in config and config[CONF_OTA] is not False:
81
82
  raise cv.Invalid(
82
- f"The '{CONF_OTA}' option has been removed from 'web_server'. "
83
- f"Please use the new OTA platform structure instead:\n\n"
83
+ f"The '{CONF_OTA}' option in 'web_server' only accepts 'false' to disable OTA. "
84
+ f"To enable OTA, please use the new OTA platform structure instead:\n\n"
84
85
  f"ota:\n"
85
86
  f" - platform: web_server\n\n"
86
87
  f"See https://esphome.io/components/ota for more information."
@@ -185,7 +186,7 @@ CONFIG_SCHEMA = cv.All(
185
186
  web_server_base.WebServerBase
186
187
  ),
187
188
  cv.Optional(CONF_INCLUDE_INTERNAL, default=False): cv.boolean,
188
- cv.Optional(CONF_OTA, default=False): cv.boolean,
189
+ cv.Optional(CONF_OTA): cv.boolean,
189
190
  cv.Optional(CONF_LOG, default=True): cv.boolean,
190
191
  cv.Optional(CONF_LOCAL): cv.boolean,
191
192
  cv.Optional(CONF_SORTING_GROUPS): cv.ensure_list(sorting_group),
@@ -203,7 +204,7 @@ CONFIG_SCHEMA = cv.All(
203
204
  default_url,
204
205
  validate_local,
205
206
  validate_sorting_groups,
206
- validate_ota_removed,
207
+ validate_ota,
207
208
  )
208
209
 
209
210
 
@@ -288,7 +289,11 @@ async def to_code(config):
288
289
  cg.add(var.set_css_url(config[CONF_CSS_URL]))
289
290
  cg.add(var.set_js_url(config[CONF_JS_URL]))
290
291
  # OTA is now handled by the web_server OTA platform
291
- # The CONF_OTA option is kept only for backwards compatibility validation
292
+ # The CONF_OTA option is kept to allow explicitly disabling OTA for web_server
293
+ # IMPORTANT: This ONLY affects the web_server component, NOT captive_portal
294
+ # Captive portal will still be able to perform OTA updates even when this is set
295
+ if config.get(CONF_OTA) is False:
296
+ cg.add_define("USE_WEBSERVER_OTA_DISABLED")
292
297
  cg.add(var.set_expose_log(config[CONF_LOG]))
293
298
  if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
294
299
  cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
@@ -5,6 +5,10 @@
5
5
  #include "esphome/core/application.h"
6
6
  #include "esphome/core/log.h"
7
7
 
8
+ #ifdef USE_CAPTIVE_PORTAL
9
+ #include "esphome/components/captive_portal/captive_portal.h"
10
+ #endif
11
+
8
12
  #ifdef USE_ARDUINO
9
13
  #ifdef USE_ESP8266
10
14
  #include <Updater.h>
@@ -25,7 +29,22 @@ class OTARequestHandler : public AsyncWebHandler {
25
29
  void handleUpload(AsyncWebServerRequest *request, const String &filename, size_t index, uint8_t *data, size_t len,
26
30
  bool final) override;
27
31
  bool canHandle(AsyncWebServerRequest *request) const override {
28
- return request->url() == "/update" && request->method() == HTTP_POST;
32
+ // Check if this is an OTA update request
33
+ bool is_ota_request = request->url() == "/update" && request->method() == HTTP_POST;
34
+
35
+ #if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL)
36
+ // IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component
37
+ // Captive portal can still perform OTA updates - check if request is from active captive portal
38
+ // Note: global_captive_portal is the standard way components communicate in ESPHome
39
+ return is_ota_request && captive_portal::global_captive_portal != nullptr &&
40
+ captive_portal::global_captive_portal->is_active();
41
+ #elif defined(USE_WEBSERVER_OTA_DISABLED)
42
+ // OTA disabled for web_server and no captive portal compiled in
43
+ return false;
44
+ #else
45
+ // OTA enabled for web_server
46
+ return is_ota_request;
47
+ #endif
29
48
  }
30
49
 
31
50
  // NOLINTNEXTLINE(readability-identifier-naming)
@@ -152,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin
152
171
 
153
172
  // Finalize
154
173
  if (final) {
155
- ESP_LOGD(TAG, "OTA final chunk: index=%u, len=%u, total_read=%u, contentLength=%u", index, len,
174
+ ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%u, contentLength=%zu", index, len,
156
175
  this->ota_read_length_, request->contentLength());
157
176
 
158
177
  // For Arduino framework, the Update library tracks expected size from firmware header