From dcb8c994cc09d6aa31b88d8aca47573cec708a2b Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Fri, 9 Jan 2026 02:24:01 +0100 Subject: [PATCH 1/4] [ac_dimmer] Added support for ESP-IDF (5+) (#7072) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/ac_dimmer/ac_dimmer.cpp | 35 ++-- esphome/components/ac_dimmer/ac_dimmer.h | 10 +- .../components/ac_dimmer/hw_timer_esp_idf.cpp | 152 ++++++++++++++++++ .../components/ac_dimmer/hw_timer_esp_idf.h | 17 ++ esphome/components/ac_dimmer/output.py | 1 - .../components/ac_dimmer/test.esp32-ard.yaml | 5 - .../components/ac_dimmer/test.esp32-idf.yaml | 5 + 7 files changed, 195 insertions(+), 30 deletions(-) create mode 100644 esphome/components/ac_dimmer/hw_timer_esp_idf.cpp create mode 100644 esphome/components/ac_dimmer/hw_timer_esp_idf.h delete mode 100644 tests/components/ac_dimmer/test.esp32-ard.yaml create mode 100644 tests/components/ac_dimmer/test.esp32-idf.yaml diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 04c01948c8..1e850a18fe 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -1,5 +1,3 @@ -#ifdef USE_ARDUINO - #include "ac_dimmer.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -9,12 +7,12 @@ #ifdef USE_ESP8266 #include #endif -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include + +#ifdef USE_ESP32 +#include "hw_timer_esp_idf.h" #endif -namespace esphome { -namespace ac_dimmer { +namespace esphome::ac_dimmer { static const char *const TAG = "ac_dimmer"; @@ -27,7 +25,14 @@ static AcDimmerDataStore *all_dimmers[32]; // NOLINT(cppcoreguidelines-avoid-no /// However other factors like gate driver propagation time /// are also considered and a really low value is not important /// See also: https://github.com/esphome/issues/issues/1632 -static const uint32_t GATE_ENABLE_TIME = 50; +static constexpr uint32_t GATE_ENABLE_TIME = 50; + +#ifdef USE_ESP32 +/// Timer frequency in Hz (1 MHz = 1µs resolution) +static constexpr uint32_t TIMER_FREQUENCY_HZ = 1000000; +/// Timer interrupt interval in microseconds +static constexpr uint64_t TIMER_INTERVAL_US = 50; +#endif /// Function called from timer interrupt /// Input is current time in microseconds (micros()) @@ -154,7 +159,7 @@ void IRAM_ATTR HOT AcDimmerDataStore::s_gpio_intr(AcDimmerDataStore *store) { #ifdef USE_ESP32 // ESP32 implementation, uses basically the same code but needs to wrap // timer_interrupt() function to auto-reschedule -static hw_timer_t *dimmer_timer = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static HWTimer *dimmer_timer = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void IRAM_ATTR HOT AcDimmerDataStore::s_timer_intr() { timer_interrupt(); } #endif @@ -194,15 +199,15 @@ void AcDimmer::setup() { setTimer1Callback(&timer_interrupt); #endif #ifdef USE_ESP32 - // timer frequency of 1mhz - dimmer_timer = timerBegin(1000000); - timerAttachInterrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr); + dimmer_timer = timer_begin(TIMER_FREQUENCY_HZ); + timer_attach_interrupt(dimmer_timer, &AcDimmerDataStore::s_timer_intr); // For ESP32, we can't use dynamic interval calculation because the timerX functions // are not callable from ISR (placed in flash storage). // Here we just use an interrupt firing every 50 µs. - timerAlarm(dimmer_timer, 50, true, 0); + timer_alarm(dimmer_timer, TIMER_INTERVAL_US, true, 0); #endif } + void AcDimmer::write_state(float state) { state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation auto new_value = static_cast(roundf(state * 65535)); @@ -210,6 +215,7 @@ void AcDimmer::write_state(float state) { this->store_.init_cycle = this->init_with_half_cycle_; this->store_.value = new_value; } + void AcDimmer::dump_config() { ESP_LOGCONFIG(TAG, "AcDimmer:\n" @@ -230,7 +236,4 @@ void AcDimmer::dump_config() { ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2); } -} // namespace ac_dimmer -} // namespace esphome - -#endif // USE_ARDUINO +} // namespace esphome::ac_dimmer diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h index fd1bbc28db..ca2a19210a 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.h +++ b/esphome/components/ac_dimmer/ac_dimmer.h @@ -1,13 +1,10 @@ #pragma once -#ifdef USE_ARDUINO - #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace ac_dimmer { +namespace esphome::ac_dimmer { enum DimMethod { DIM_METHOD_LEADING_PULSE = 0, DIM_METHOD_LEADING, DIM_METHOD_TRAILING }; @@ -64,7 +61,4 @@ class AcDimmer : public output::FloatOutput, public Component { DimMethod method_; }; -} // namespace ac_dimmer -} // namespace esphome - -#endif // USE_ARDUINO +} // namespace esphome::ac_dimmer diff --git a/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp new file mode 100644 index 0000000000..543b476085 --- /dev/null +++ b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp @@ -0,0 +1,152 @@ +#ifdef USE_ESP32 + +#include "hw_timer_esp_idf.h" + +#include "freertos/FreeRTOS.h" +#include "esphome/core/log.h" + +#include "driver/gptimer.h" +#include "esp_clk_tree.h" +#include "soc/clk_tree_defs.h" + +static const char *const TAG = "hw_timer_esp_idf"; + +namespace esphome::ac_dimmer { + +// GPTimer divider constraints from ESP-IDF documentation +static constexpr uint32_t GPTIMER_DIVIDER_MIN = 2; +static constexpr uint32_t GPTIMER_DIVIDER_MAX = 65536; + +using voidFuncPtr = void (*)(); +using voidFuncPtrArg = void (*)(void *); + +struct InterruptConfigT { + voidFuncPtr fn{nullptr}; + void *arg{nullptr}; +}; + +struct HWTimer { + gptimer_handle_t timer_handle{nullptr}; + InterruptConfigT interrupt_handle{}; + bool timer_started{false}; +}; + +HWTimer *timer_begin(uint32_t frequency) { + esp_err_t err = ESP_OK; + uint32_t counter_src_hz = 0; + uint32_t divider = 0; + soc_module_clk_t clk; + for (auto clk_candidate : SOC_GPTIMER_CLKS) { + clk = clk_candidate; + esp_clk_tree_src_get_freq_hz(clk, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &counter_src_hz); + divider = counter_src_hz / frequency; + if ((divider >= GPTIMER_DIVIDER_MIN) && (divider <= GPTIMER_DIVIDER_MAX)) { + break; + } else { + divider = 0; + } + } + + if (divider == 0) { + ESP_LOGE(TAG, "Resolution not possible; aborting"); + return nullptr; + } + + gptimer_config_t config = { + .clk_src = static_cast(clk), + .direction = GPTIMER_COUNT_UP, + .resolution_hz = frequency, + .flags = {.intr_shared = true}, + }; + + HWTimer *timer = new HWTimer(); + + err = gptimer_new_timer(&config, &timer->timer_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "GPTimer creation failed; error %d", err); + delete timer; + return nullptr; + } + + err = gptimer_enable(timer->timer_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "GPTimer enable failed; error %d", err); + gptimer_del_timer(timer->timer_handle); + delete timer; + return nullptr; + } + + err = gptimer_start(timer->timer_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "GPTimer start failed; error %d", err); + gptimer_disable(timer->timer_handle); + gptimer_del_timer(timer->timer_handle); + delete timer; + return nullptr; + } + + timer->timer_started = true; + return timer; +} + +bool IRAM_ATTR timer_fn_wrapper(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *args) { + auto *isr = static_cast(args); + if (isr->fn) { + if (isr->arg) { + reinterpret_cast(isr->fn)(isr->arg); + } else { + isr->fn(); + } + } + // Return false to indicate that no higher-priority task was woken and no context switch is requested. + return false; +} + +static void timer_attach_interrupt_functional_arg(HWTimer *timer, void (*user_func)(void *), void *arg) { + if (timer == nullptr) { + ESP_LOGE(TAG, "Timer handle is nullptr"); + return; + } + gptimer_event_callbacks_t cbs = { + .on_alarm = timer_fn_wrapper, + }; + + timer->interrupt_handle.fn = reinterpret_cast(user_func); + timer->interrupt_handle.arg = arg; + + if (timer->timer_started) { + gptimer_stop(timer->timer_handle); + } + gptimer_disable(timer->timer_handle); + esp_err_t err = gptimer_register_event_callbacks(timer->timer_handle, &cbs, &timer->interrupt_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Timer Attach Interrupt failed; error %d", err); + } + gptimer_enable(timer->timer_handle); + if (timer->timer_started) { + gptimer_start(timer->timer_handle); + } +} + +void timer_attach_interrupt(HWTimer *timer, voidFuncPtr user_func) { + timer_attach_interrupt_functional_arg(timer, reinterpret_cast(user_func), nullptr); +} + +void timer_alarm(HWTimer *timer, uint64_t alarm_value, bool autoreload, uint64_t reload_count) { + if (timer == nullptr) { + ESP_LOGE(TAG, "Timer handle is nullptr"); + return; + } + gptimer_alarm_config_t alarm_cfg = { + .alarm_count = alarm_value, + .reload_count = reload_count, + .flags = {.auto_reload_on_alarm = autoreload}, + }; + esp_err_t err = gptimer_set_alarm_action(timer->timer_handle, &alarm_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Timer Alarm Write failed; error %d", err); + } +} + +} // namespace esphome::ac_dimmer +#endif diff --git a/esphome/components/ac_dimmer/hw_timer_esp_idf.h b/esphome/components/ac_dimmer/hw_timer_esp_idf.h new file mode 100644 index 0000000000..1b2401ebda --- /dev/null +++ b/esphome/components/ac_dimmer/hw_timer_esp_idf.h @@ -0,0 +1,17 @@ +#pragma once +#ifdef USE_ESP32 + +#include "driver/gptimer_types.h" + +namespace esphome::ac_dimmer { + +struct HWTimer; + +HWTimer *timer_begin(uint32_t frequency); + +void timer_attach_interrupt(HWTimer *timer, void (*user_func)()); +void timer_alarm(HWTimer *timer, uint64_t alarm_value, bool autoreload, uint64_t reload_count); + +} // namespace esphome::ac_dimmer + +#endif diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 9f9afb6d80..efc24b65e7 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -32,7 +32,6 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_with_arduino, ) diff --git a/tests/components/ac_dimmer/test.esp32-ard.yaml b/tests/components/ac_dimmer/test.esp32-ard.yaml deleted file mode 100644 index eaa4901f03..0000000000 --- a/tests/components/ac_dimmer/test.esp32-ard.yaml +++ /dev/null @@ -1,5 +0,0 @@ -substitutions: - gate_pin: GPIO4 - zero_cross_pin: GPIO5 - -<<: !include common.yaml diff --git a/tests/components/ac_dimmer/test.esp32-idf.yaml b/tests/components/ac_dimmer/test.esp32-idf.yaml new file mode 100644 index 0000000000..3ec069f430 --- /dev/null +++ b/tests/components/ac_dimmer/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + gate_pin: GPIO18 + zero_cross_pin: GPIO19 + +<<: !include common.yaml From 5afe4b7b12db3811f4a3d5128ddaec12b2897dad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Jan 2026 16:41:34 -1000 Subject: [PATCH 2/4] [wifi] Warn when AP is configured without captive_portal or web_server (#13087) --- esphome/components/wifi/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 7ba1b5e417..e8bc2edd8f 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -238,12 +238,21 @@ def _apply_min_auth_mode_default(config): def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config - has_improv = "esp32_improv" in fv.full_config.get() - has_improv_serial = "improv_serial" in fv.full_config.get() + full_config = fv.full_config.get() + has_improv = "esp32_improv" in full_config + has_improv_serial = "improv_serial" in full_config + has_captive_portal = "captive_portal" in full_config + has_web_server = "web_server" in full_config if not (has_sta or has_ap or has_improv or has_improv_serial): raise cv.Invalid( "Please specify at least an SSID or an Access Point to create." ) + if has_ap and not has_captive_portal and not has_web_server: + _LOGGER.warning( + "WiFi AP is configured but neither captive_portal nor web_server is enabled. " + "The AP will not be usable for configuration or monitoring. " + "Add 'captive_portal:' or 'web_server:' to your configuration." + ) FINAL_VALIDATE_SCHEMA = cv.All( From 2c165e4817054f60cf50d7eecb492a57d24d7c2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Jan 2026 20:36:08 -1000 Subject: [PATCH 3/4] [web_server] Use centralized length constants for buffer sizing (#13073) --- esphome/components/web_server/web_server.cpp | 12 +++++++----- esphome/core/config.py | 13 ++++++++++--- esphome/core/entity_base.h | 9 ++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e5705d7b47..cab177c182 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -498,14 +498,16 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Build id into stack buffer - ArduinoJson copies the string // Format: {prefix}/{device?}/{name} - // Buffer size guaranteed by schema validation (NAME_MAX_LENGTH=120): - // With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263, rounded up to 280 for safety margin - // Without devices: domain(20) + "/" + name(120) + null = 142, rounded up to 150 for safety margin + // Buffer sizes use constants from entity_base.h validated in core/config.py + // Note: Device name uses ESPHOME_FRIENDLY_NAME_MAX_LEN (sub-device max 120), not ESPHOME_DEVICE_NAME_MAX_LEN + // (hostname) #ifdef USE_DEVICES - char id_buf[280]; + static constexpr size_t ID_BUF_SIZE = + ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #else - char id_buf[150]; + static constexpr size_t ID_BUF_SIZE = ESPHOME_DOMAIN_MAX_LEN + 1 + ESPHOME_FRIENDLY_NAME_MAX_LEN + 1; #endif + char id_buf[ID_BUF_SIZE]; char *p = id_buf; memcpy(p, prefix, prefix_len); p += prefix_len; diff --git a/esphome/core/config.py b/esphome/core/config.py index b7e6ab9bee..21ed8ced1a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -184,17 +184,24 @@ if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" in os.environ: else: _compile_process_limit_default = cv.UNDEFINED +# Keep in sync with ESPHOME_FRIENDLY_NAME_MAX_LEN in esphome/core/entity_base.h +FRIENDLY_NAME_MAX_LEN = 120 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), - cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)), + cv.Required(CONF_NAME): cv.All( + cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + ), } ) DEVICE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Device), - cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)), + cv.Required(CONF_NAME): cv.All( + cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) + ), cv.Optional(CONF_AREA_ID): cv.use_id(Area), } ) @@ -210,7 +217,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_NAME): cv.valid_name, # Keep max=120 in sync with OBJECT_ID_MAX_LEN in esphome/core/entity_base.h cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All( - cv.string_no_slash, cv.Length(max=120) + cv.string_no_slash, cv.Length(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 1649077dd0..5f75872a0f 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -16,7 +16,14 @@ namespace esphome { // Maximum device name length - keep in sync with validate_hostname() in esphome/core/config.py static constexpr size_t ESPHOME_DEVICE_NAME_MAX_LEN = 31; -// Maximum size for object_id buffer - keep in sync with friendly_name cv.Length(max=120) in esphome/core/config.py +// Maximum friendly name length for entities and sub-devices - keep in sync with FRIENDLY_NAME_MAX_LEN in +// esphome/core/config.py +static constexpr size_t ESPHOME_FRIENDLY_NAME_MAX_LEN = 120; + +// Maximum domain length (longest: "alarm_control_panel" = 19) +static constexpr size_t ESPHOME_DOMAIN_MAX_LEN = 20; + +// Maximum size for object_id buffer (friendly_name + null + margin) static constexpr size_t OBJECT_ID_MAX_LEN = 128; enum EntityCategory : uint8_t { From cd43b4114e2c650e61d0b3a86a3f56389646defb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Jan 2026 20:36:24 -1000 Subject: [PATCH 4/4] [api] Fire on_client_disconnected trigger after removing client from list (#13088) --- esphome/components/api/api_server.cpp | 14 +++++++++++--- .../fixtures/api_conditional_memory.yaml | 8 ++++++++ tests/integration/test_api_conditional_memory.py | 8 ++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4ececfec94..336672f50b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -186,14 +186,17 @@ void APIServer::loop() { } // Rare case: handle disconnection -#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER - this->client_disconnected_trigger_->trigger(std::string(client->get_name()), std::string(client->get_peername())); -#endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES this->unregister_active_action_calls_for_connection(client.get()); #endif ESP_LOGV(TAG, "Remove connection %s", client->get_name()); +#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER + // Save client info before removal for the trigger + std::string client_name(client->get_name()); + std::string client_peername(client->get_peername()); +#endif + // Swap with the last element and pop (avoids expensive vector shifts) if (client_index < this->clients_.size() - 1) { std::swap(this->clients_[client_index], this->clients_.back()); @@ -205,6 +208,11 @@ void APIServer::loop() { this->status_set_warning(); this->last_connected_ = App.get_loop_component_start_time(); } + +#ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER + // Fire trigger after client is removed so api.connected reflects the true state + this->client_disconnected_trigger_->trigger(client_name, client_peername); +#endif // Don't increment client_index since we need to process the swapped element } } diff --git a/tests/integration/fixtures/api_conditional_memory.yaml b/tests/integration/fixtures/api_conditional_memory.yaml index 22e8ed79d6..4a0923b93f 100644 --- a/tests/integration/fixtures/api_conditional_memory.yaml +++ b/tests/integration/fixtures/api_conditional_memory.yaml @@ -24,6 +24,14 @@ api: - logger.log: format: "Client %s disconnected from %s" args: [client_info.c_str(), client_address.c_str()] + # Verify fix for issue #11131: api.connected should reflect true state in trigger + - if: + condition: + api.connected: + then: + - logger.log: "Other clients still connected" + else: + - logger.log: "No clients remaining" logger: level: DEBUG diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index 349b572859..91625770d9 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -23,12 +23,14 @@ async def test_api_conditional_memory( # Track log messages connected_future = loop.create_future() disconnected_future = loop.create_future() + no_clients_future = loop.create_future() service_simple_future = loop.create_future() service_args_future = loop.create_future() # Patterns to match in logs connected_pattern = re.compile(r"Client .* connected from") disconnected_pattern = re.compile(r"Client .* disconnected from") + no_clients_pattern = re.compile(r"No clients remaining") service_simple_pattern = re.compile(r"Simple service called") service_args_pattern = re.compile( r"Service called with: test_string, 123, 1, 42\.50" @@ -40,6 +42,8 @@ async def test_api_conditional_memory( connected_future.set_result(True) elif not disconnected_future.done() and disconnected_pattern.search(line): disconnected_future.set_result(True) + elif not no_clients_future.done() and no_clients_pattern.search(line): + no_clients_future.set_result(True) elif not service_simple_future.done() and service_simple_pattern.search(line): service_simple_future.set_result(True) elif not service_args_future.done() and service_args_pattern.search(line): @@ -109,3 +113,7 @@ async def test_api_conditional_memory( # Client disconnected here, wait for disconnect log await asyncio.wait_for(disconnected_future, timeout=5.0) + + # Verify fix for issue #11131: api.connected should be false in trigger + # when the last client disconnects + await asyncio.wait_for(no_clients_future, timeout=5.0)