esphome 2025.7.1__py3-none-any.whl → 2025.7.3__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 (36) hide show
  1. esphome/components/api/homeassistant_service.h +3 -0
  2. esphome/components/bme680_bsec/__init__.py +10 -2
  3. esphome/components/esp32_camera/__init__.py +22 -0
  4. esphome/components/esp32_touch/esp32_touch_v1.cpp +16 -10
  5. esphome/components/fastled_clockless/light.py +17 -2
  6. esphome/components/fastled_spi/light.py +10 -0
  7. esphome/components/gpio/binary_sensor/__init__.py +34 -2
  8. esphome/components/logger/__init__.py +2 -2
  9. esphome/components/lvgl/types.py +2 -2
  10. esphome/components/lvgl/widgets/meter.py +3 -0
  11. esphome/components/neopixelbus/light.py +10 -1
  12. esphome/components/sdl/sdl_esphome.cpp +3 -0
  13. esphome/components/speaker/media_player/audio_pipeline.cpp +14 -2
  14. esphome/components/speaker/media_player/audio_pipeline.h +1 -0
  15. esphome/components/tuya/fan/__init__.py +6 -5
  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/ota/ota_web_server.cpp +2 -2
  19. esphome/components/web_server/web_server.cpp +3 -1
  20. esphome/components/wireguard/wireguard.cpp +13 -3
  21. esphome/config_validation.py +58 -26
  22. esphome/const.py +1 -1
  23. esphome/core/application.cpp +39 -24
  24. esphome/core/application.h +2 -0
  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/lock_free_queue.h +2 -7
  29. esphome/core/scheduler.cpp +1 -1
  30. esphome/core/scheduler.h +4 -3
  31. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/METADATA +1 -1
  32. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/RECORD +36 -36
  33. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/WHEEL +0 -0
  34. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/entry_points.txt +0 -0
  35. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/licenses/LICENSE +0 -0
  36. {esphome-2025.7.1.dist-info → esphome-2025.7.3.dist-info}/top_level.txt +0 -0
@@ -16,6 +16,9 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
16
16
  template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
17
17
 
18
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)
19
22
  static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
20
23
  static std::string value_to_string(const std::string &val) { return val; }
21
24
  static std::string value_to_string(std::string &&val) { return std::move(val); }
@@ -1,7 +1,7 @@
1
1
  import esphome.codegen as cg
2
2
  from esphome.components import esp32, i2c
3
3
  import esphome.config_validation as cv
4
- from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET
4
+ from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework
5
5
 
6
6
  CODEOWNERS = ["@trvrnrth"]
7
7
  DEPENDENCIES = ["i2c"]
@@ -56,7 +56,15 @@ CONFIG_SCHEMA = cv.All(
56
56
  ): cv.positive_time_period_minutes,
57
57
  }
58
58
  ).extend(i2c.i2c_device_schema(0x76)),
59
- cv.only_with_arduino,
59
+ cv.only_with_framework(
60
+ frameworks=Framework.ARDUINO,
61
+ suggestions={
62
+ Framework.ESP_IDF: (
63
+ "bme68x_bsec2_i2c",
64
+ "sensor/bme68x_bsec2",
65
+ )
66
+ },
67
+ ),
60
68
  cv.Any(
61
69
  cv.only_on_esp8266,
62
70
  cv.All(
@@ -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",
@@ -16,6 +16,8 @@ namespace esp32_touch {
16
16
 
17
17
  static const char *const TAG = "esp32_touch";
18
18
 
19
+ static const uint32_t SETUP_MODE_THRESHOLD = 0xFFFF;
20
+
19
21
  void ESP32TouchComponent::setup() {
20
22
  // Create queue for touch events
21
23
  // Queue size calculation: children * 4 allows for burst scenarios where ISR
@@ -44,7 +46,11 @@ void ESP32TouchComponent::setup() {
44
46
 
45
47
  // Configure each touch pad
46
48
  for (auto *child : this->children_) {
47
- touch_pad_config(child->get_touch_pad(), child->get_threshold());
49
+ if (this->setup_mode_) {
50
+ touch_pad_config(child->get_touch_pad(), SETUP_MODE_THRESHOLD);
51
+ } else {
52
+ touch_pad_config(child->get_touch_pad(), child->get_threshold());
53
+ }
48
54
  }
49
55
 
50
56
  // Register ISR handler
@@ -114,8 +120,8 @@ void ESP32TouchComponent::loop() {
114
120
  child->publish_state(new_state);
115
121
  // Original ESP32: ISR only fires when touched, release is detected by timeout
116
122
  // Note: ESP32 v1 uses inverted logic - touched when value < threshold
117
- ESP_LOGV(TAG, "Touch Pad '%s' state: ON (value: %" PRIu32 " < threshold: %" PRIu32 ")",
118
- child->get_name().c_str(), event.value, child->get_threshold());
123
+ ESP_LOGV(TAG, "Touch Pad '%s' state: %s (value: %" PRIu32 " < threshold: %" PRIu32 ")",
124
+ child->get_name().c_str(), ONOFF(new_state), event.value, child->get_threshold());
119
125
  }
120
126
  break; // Exit inner loop after processing matching pad
121
127
  }
@@ -188,11 +194,6 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) {
188
194
  // as any pad remains touched. This allows us to detect both new touches and
189
195
  // continued touches, but releases must be detected by timeout in the main loop.
190
196
 
191
- // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2!
192
- // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE
193
- // Therefore: touched = (value < threshold)
194
- // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold)
195
-
196
197
  // Process all configured pads to check their current state
197
198
  // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt,
198
199
  // so we must scan all configured pads to find which ones were touched
@@ -211,11 +212,16 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) {
211
212
  }
212
213
 
213
214
  // Skip pads that aren’t in the trigger mask
214
- bool is_touched = (mask >> pad) & 1;
215
- if (!is_touched) {
215
+ if (((mask >> pad) & 1) == 0) {
216
216
  continue;
217
217
  }
218
218
 
219
+ // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2!
220
+ // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE
221
+ // Therefore: touched = (value < threshold)
222
+ // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold)
223
+ bool is_touched = value < child->get_threshold();
224
+
219
225
  // Always send the current state - the main loop will filter for changes
220
226
  // We send both touched and untouched states because the ISR doesn't
221
227
  // track previous state (to keep ISR fast and simple)
@@ -2,7 +2,13 @@ from esphome import pins
2
2
  import esphome.codegen as cg
3
3
  from esphome.components import fastled_base
4
4
  import esphome.config_validation as cv
5
- from esphome.const import CONF_CHIPSET, CONF_NUM_LEDS, CONF_PIN, CONF_RGB_ORDER
5
+ from esphome.const import (
6
+ CONF_CHIPSET,
7
+ CONF_NUM_LEDS,
8
+ CONF_PIN,
9
+ CONF_RGB_ORDER,
10
+ Framework,
11
+ )
6
12
 
7
13
  AUTO_LOAD = ["fastled_base"]
8
14
 
@@ -48,13 +54,22 @@ CONFIG_SCHEMA = cv.All(
48
54
  cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
49
55
  }
50
56
  ),
51
- _validate,
57
+ cv.only_with_framework(
58
+ frameworks=Framework.ARDUINO,
59
+ suggestions={
60
+ Framework.ESP_IDF: (
61
+ "esp32_rmt_led_strip",
62
+ "light/esp32_rmt_led_strip",
63
+ )
64
+ },
65
+ ),
52
66
  cv.require_framework_version(
53
67
  esp8266_arduino=cv.Version(2, 7, 4),
54
68
  esp32_arduino=cv.Version(99, 0, 0),
55
69
  max_version=True,
56
70
  extra_message="Please see note on documentation for FastLED",
57
71
  ),
72
+ _validate,
58
73
  )
59
74
 
60
75
 
@@ -9,6 +9,7 @@ from esphome.const import (
9
9
  CONF_DATA_RATE,
10
10
  CONF_NUM_LEDS,
11
11
  CONF_RGB_ORDER,
12
+ Framework,
12
13
  )
13
14
 
14
15
  AUTO_LOAD = ["fastled_base"]
@@ -33,6 +34,15 @@ CONFIG_SCHEMA = cv.All(
33
34
  cv.Optional(CONF_DATA_RATE): cv.frequency,
34
35
  }
35
36
  ),
37
+ cv.only_with_framework(
38
+ frameworks=Framework.ARDUINO,
39
+ suggestions={
40
+ Framework.ESP_IDF: (
41
+ "spi_led_strip",
42
+ "light/spi_led_strip",
43
+ )
44
+ },
45
+ ),
36
46
  cv.require_framework_version(
37
47
  esp8266_arduino=cv.Version(2, 7, 4),
38
48
  esp32_arduino=cv.Version(99, 0, 0),
@@ -4,7 +4,13 @@ from esphome import pins
4
4
  import esphome.codegen as cg
5
5
  from esphome.components import binary_sensor
6
6
  import esphome.config_validation as cv
7
- from esphome.const import CONF_ID, CONF_NAME, CONF_NUMBER, CONF_PIN
7
+ from esphome.const import (
8
+ CONF_ALLOW_OTHER_USES,
9
+ CONF_ID,
10
+ CONF_NAME,
11
+ CONF_NUMBER,
12
+ CONF_PIN,
13
+ )
8
14
  from esphome.core import CORE
9
15
 
10
16
  from .. import gpio_ns
@@ -29,7 +35,21 @@ CONFIG_SCHEMA = (
29
35
  .extend(
30
36
  {
31
37
  cv.Required(CONF_PIN): pins.gpio_input_pin_schema,
32
- cv.Optional(CONF_USE_INTERRUPT, default=True): cv.boolean,
38
+ # Interrupts are disabled by default for bk72xx, ln882x, and rtl87xx platforms
39
+ # due to hardware limitations or lack of reliable interrupt support. This ensures
40
+ # stable operation on these platforms. Future maintainers should verify platform
41
+ # capabilities before changing this default behavior.
42
+ cv.SplitDefault(
43
+ CONF_USE_INTERRUPT,
44
+ bk72xx=False,
45
+ esp32=True,
46
+ esp8266=True,
47
+ host=True,
48
+ ln882x=False,
49
+ nrf52=True,
50
+ rp2040=True,
51
+ rtl87xx=False,
52
+ ): cv.boolean,
33
53
  cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum(
34
54
  INTERRUPT_TYPES, upper=True
35
55
  ),
@@ -62,6 +82,18 @@ async def to_code(config):
62
82
  )
63
83
  use_interrupt = False
64
84
 
85
+ # Check if pin is shared with other components (allow_other_uses)
86
+ # When a pin is shared, interrupts can interfere with other components
87
+ # (e.g., duty_cycle sensor) that need to monitor the pin's state changes
88
+ if use_interrupt and config[CONF_PIN].get(CONF_ALLOW_OTHER_USES, False):
89
+ _LOGGER.info(
90
+ "GPIO binary_sensor '%s': Disabling interrupts because pin %s is shared with other components. "
91
+ "The sensor will use polling mode for compatibility with other pin uses.",
92
+ config.get(CONF_NAME, config[CONF_ID]),
93
+ config[CONF_PIN][CONF_NUMBER],
94
+ )
95
+ use_interrupt = False
96
+
65
97
  cg.add(var.set_use_interrupt(use_interrupt))
66
98
  if use_interrupt:
67
99
  cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE]))
@@ -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:
@@ -15,6 +15,7 @@ from esphome.const import (
15
15
  CONF_PIN,
16
16
  CONF_TYPE,
17
17
  CONF_VARIANT,
18
+ Framework,
18
19
  )
19
20
  from esphome.core import CORE
20
21
 
@@ -162,7 +163,15 @@ def _validate_method(value):
162
163
 
163
164
 
164
165
  CONFIG_SCHEMA = cv.All(
165
- cv.only_with_arduino,
166
+ cv.only_with_framework(
167
+ frameworks=Framework.ARDUINO,
168
+ suggestions={
169
+ Framework.ESP_IDF: (
170
+ "esp32_rmt_led_strip",
171
+ "light/esp32_rmt_led_strip",
172
+ )
173
+ },
174
+ ),
166
175
  cv.require_framework_version(
167
176
  esp8266_arduino=cv.Version(2, 4, 0),
168
177
  esp32_arduino=cv.Version(0, 0, 0),
@@ -48,6 +48,9 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *
48
48
  }
49
49
 
50
50
  void Sdl::draw_pixel_at(int x, int y, Color color) {
51
+ if (!this->get_clipping().inside(x, y))
52
+ return;
53
+
51
54
  SDL_Rect rect{x, y, 1, 1};
52
55
  auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB));
53
56
  SDL_UpdateTexture(this->texture_, &rect, &data, 2);
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  import esphome.codegen as cg
2
2
  from esphome.components import fan
3
3
  import esphome.config_validation as cv
4
- from esphome.const import CONF_OUTPUT_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT
4
+ from esphome.const import CONF_ID, CONF_SPEED_COUNT, CONF_SWITCH_DATAPOINT
5
5
 
6
6
  from .. import CONF_TUYA_ID, Tuya, tuya_ns
7
7
 
@@ -14,9 +14,9 @@ CONF_DIRECTION_DATAPOINT = "direction_datapoint"
14
14
  TuyaFan = tuya_ns.class_("TuyaFan", cg.Component, fan.Fan)
15
15
 
16
16
  CONFIG_SCHEMA = cv.All(
17
- fan.FAN_SCHEMA.extend(
17
+ fan.fan_schema(TuyaFan)
18
+ .extend(
18
19
  {
19
- cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(TuyaFan),
20
20
  cv.GenerateID(CONF_TUYA_ID): cv.use_id(Tuya),
21
21
  cv.Optional(CONF_OSCILLATION_DATAPOINT): cv.uint8_t,
22
22
  cv.Optional(CONF_SPEED_DATAPOINT): cv.uint8_t,
@@ -24,7 +24,8 @@ CONFIG_SCHEMA = cv.All(
24
24
  cv.Optional(CONF_DIRECTION_DATAPOINT): cv.uint8_t,
25
25
  cv.Optional(CONF_SPEED_COUNT, default=3): cv.int_range(min=1, max=256),
26
26
  }
27
- ).extend(cv.COMPONENT_SCHEMA),
27
+ )
28
+ .extend(cv.COMPONENT_SCHEMA),
28
29
  cv.has_at_least_one_key(CONF_SPEED_DATAPOINT, CONF_SWITCH_DATAPOINT),
29
30
  )
30
31
 
@@ -32,7 +33,7 @@ CONFIG_SCHEMA = cv.All(
32
33
  async def to_code(config):
33
34
  parent = await cg.get_variable(config[CONF_TUYA_ID])
34
35
 
35
- var = cg.new_Pvariable(config[CONF_OUTPUT_ID], parent, config[CONF_SPEED_COUNT])
36
+ var = cg.new_Pvariable(config[CONF_ID], parent, config[CONF_SPEED_COUNT])
36
37
  await cg.register_component(var, config)
37
38
  await fan.register_fan(var, config)
38
39
 
@@ -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};
@@ -76,7 +76,7 @@ void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) {
76
76
  percentage = (this->ota_read_length_ * 100.0f) / request->contentLength();
77
77
  ESP_LOGD(TAG, "OTA in progress: %0.1f%%", percentage);
78
78
  } else {
79
- ESP_LOGD(TAG, "OTA in progress: %u bytes read", this->ota_read_length_);
79
+ ESP_LOGD(TAG, "OTA in progress: %" PRIu32 " bytes read", this->ota_read_length_);
80
80
  }
81
81
  #ifdef USE_OTA_STATE_CALLBACK
82
82
  // Report progress - use call_deferred since we're in web server task
@@ -171,7 +171,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Strin
171
171
 
172
172
  // Finalize
173
173
  if (final) {
174
- ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%u, contentLength=%zu", index, len,
174
+ ESP_LOGD(TAG, "OTA final chunk: index=%zu, len=%zu, total_read=%" PRIu32 ", contentLength=%zu", index, len,
175
175
  this->ota_read_length_, request->contentLength());
176
176
 
177
177
  // For Arduino framework, the Update library tracks expected size from firmware header
@@ -1620,7 +1620,9 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa
1620
1620
  request->send(404);
1621
1621
  }
1622
1622
 
1623
- static std::string get_event_type(event::Event *event) { return event->last_event_type ? *event->last_event_type : ""; }
1623
+ static std::string get_event_type(event::Event *event) {
1624
+ return (event && event->last_event_type) ? *event->last_event_type : "";
1625
+ }
1624
1626
 
1625
1627
  std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) {
1626
1628
  auto *event = static_cast<event::Event *>(source);