diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a31ab63dbc..9309071db7 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -190,14 +190,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()); @@ -209,6 +212,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/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 { 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)