diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ea3268a583..ca5d3a97ba 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1435,6 +1435,24 @@ bool APIConnection::try_send_log_message(int level, const char *tag, const char return this->send_buffer(buffer, SubscribeLogsResponse::MESSAGE_TYPE); } +void APIConnection::complete_authentication_() { + // Early return if already authenticated + if (this->flags_.connection_state == static_cast(ConnectionState::AUTHENTICATED)) { + return; + } + + this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); + ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); +#ifdef USE_API_CLIENT_CONNECTED_TRIGGER + this->parent_->get_client_connected_trigger()->trigger(this->client_info_, this->client_peername_); +#endif +#ifdef USE_HOMEASSISTANT_TIME + if (homeassistant::global_homeassistant_time != nullptr) { + this->send_time_request(); + } +#endif +} + HelloResponse APIConnection::hello(const HelloRequest &msg) { this->client_info_ = msg.client_info; this->client_peername_ = this->helper_->getpeername(); @@ -1450,7 +1468,14 @@ HelloResponse APIConnection::hello(const HelloRequest &msg) { resp.server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; resp.name = App.get_name(); +#ifdef USE_API_PASSWORD + // Password required - wait for authentication this->flags_.connection_state = static_cast(ConnectionState::CONNECTED); +#else + // No password configured - auto-authenticate + this->complete_authentication_(); +#endif + return resp; } ConnectResponse APIConnection::connect(const ConnectRequest &msg) { @@ -1463,23 +1488,14 @@ ConnectResponse APIConnection::connect(const ConnectRequest &msg) { // bool invalid_password = 1; resp.invalid_password = !correct; if (correct) { - ESP_LOGD(TAG, "%s connected", this->get_client_combined_info().c_str()); - this->flags_.connection_state = static_cast(ConnectionState::AUTHENTICATED); -#ifdef USE_API_CLIENT_CONNECTED_TRIGGER - this->parent_->get_client_connected_trigger()->trigger(this->client_info_, this->client_peername_); -#endif -#ifdef USE_HOMEASSISTANT_TIME - if (homeassistant::global_homeassistant_time != nullptr) { - this->send_time_request(); - } -#endif + this->complete_authentication_(); } return resp; } DeviceInfoResponse APIConnection::device_info(const DeviceInfoRequest &msg) { DeviceInfoResponse resp{}; #ifdef USE_API_PASSWORD - resp.uses_password = this->parent_->uses_password(); + resp.uses_password = true; #else resp.uses_password = false; #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0051a143de..0a3cb7b4d4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -273,6 +273,9 @@ class APIConnection : public APIServerConnection { ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); protected: + // Helper function to handle authentication completion + void complete_authentication_(); + // Helper function to fill common entity info fields static void fill_entity_info_base(esphome::EntityBase *entity, InfoResponseProtoMessage &response) { // Set common fields that are shared by all entity types diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index f5be672c9a..5b87a773b5 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -219,8 +219,6 @@ void APIServer::dump_config() { } #ifdef USE_API_PASSWORD -bool APIServer::uses_password() const { return !this->password_.empty(); } - bool APIServer::check_password(const std::string &password) const { // depend only on input password length const char *a = this->password_.c_str(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index f41064b62b..edbd289421 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -39,7 +39,6 @@ class APIServer : public Component, public Controller { bool teardown() override; #ifdef USE_API_PASSWORD bool check_password(const std::string &password) const; - bool uses_password() const; void set_password(const std::string &password); #endif void set_port(uint16_t port); diff --git a/tests/integration/fixtures/host_mode_api_password.yaml b/tests/integration/fixtures/host_mode_api_password.yaml new file mode 100644 index 0000000000..038b6871e0 --- /dev/null +++ b/tests/integration/fixtures/host_mode_api_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: host-mode-api-password +host: +api: + password: "test_password_123" +logger: + level: DEBUG +# Test sensor to verify connection works +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 42.0; + update_interval: 0.1s diff --git a/tests/integration/test_host_mode_api_password.py b/tests/integration/test_host_mode_api_password.py new file mode 100644 index 0000000000..098fc38142 --- /dev/null +++ b/tests/integration/test_host_mode_api_password.py @@ -0,0 +1,53 @@ +"""Integration test for API password authentication.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import APIConnectionError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_api_password( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API authentication with password.""" + async with run_compiled(yaml_config): + # Connect with correct password + async with api_client_connected(password="test_password_123") as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.uses_password is True + assert device_info.name == "host-mode-api-password" + + # Subscribe to states to ensure authenticated connection works + loop = asyncio.get_running_loop() + state_future: asyncio.Future[bool] = loop.create_future() + states = {} + + def on_state(state): + states[state.key] = state + if not state_future.done(): + state_future.set_result(True) + + client.subscribe_states(on_state) + + # Wait for at least one state with timeout + try: + await asyncio.wait_for(state_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("No states received within timeout") + + # Should have received at least one state (the test sensor) + assert len(states) > 0 + + # Test with wrong password - should fail + with pytest.raises(APIConnectionError, match="Invalid password"): + async with api_client_connected(password="wrong_password"): + pass # Should not reach here