diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 15b61e9015..34474220ca 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -294,17 +294,16 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: return config -def _validate_outgoing_connection_platform(value: ConfigType) -> ConfigType: +def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + if CONF_OUTGOING_CONNECTION not in config: + return config if CORE.is_esp8266 or CORE.is_rp2: raise cv.Invalid( "outgoing_connection is not supported on this platform because its " - "socket layer cannot make outgoing connections" + "socket layer cannot make outgoing connections", + path=[CONF_OUTGOING_CONNECTION], ) - return value - - -def _validate_outgoing_connection(config: ConfigType) -> ConfigType: - if CONF_OUTGOING_CONNECTION in config and CONF_ENCRYPTION not in config: + if CONF_ENCRYPTION not in config: raise cv.Invalid( "outgoing_connection requires 'encryption' so the peer is verified by key", path=[CONF_OUTGOING_CONNECTION], @@ -312,17 +311,12 @@ def _validate_outgoing_connection(config: ConfigType) -> ConfigType: return config -OUTGOING_CONNECTION_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_HOST): cv.ipaddress, - cv.Optional(CONF_PORT, default=6054): cv.port, - cv.Optional( - CONF_DELAY, default="60s" - ): cv.positive_time_period_milliseconds, - } - ), - _validate_outgoing_connection_platform, +OUTGOING_CONNECTION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_HOST): cv.ipaddress, + cv.Optional(CONF_PORT, default=6054): cv.port, + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + } ) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 11c87ef056..0452400b50 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1784,9 +1784,6 @@ void APIConnection::complete_authentication_() { zwave_proxy::global_zwave_proxy->api_connection_authenticated(this); } #endif -#ifdef USE_API_OUTGOING_CONNECTION - this->parent_->on_client_authenticated(); -#endif } bool APIConnection::send_hello_response_(const HelloRequest &msg) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index e8f8375aae..f36315cbfa 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -376,11 +376,18 @@ class APIConnection final : public APIServerConnectionBase { } #ifdef USE_API_OUTGOING_CONNECTION - /// Outgoing connection: the noise helper sends its server hello - /// first so the peer can pick the matching key. Outgoing connections are only - /// dialed when a PSK is set, so the helper is always the noise helper. - /// Must be called before start(). - void mark_outgoing() { static_cast(this->helper_.get())->set_server_hello_first(); } + /// Outgoing connection: send our server hello immediately so the peer can + /// pick the matching key. Outgoing connections are only dialed when a PSK + /// is set, so the helper is always the noise helper. Call after start(). + void mark_outgoing() { + if (this->flags_.remove) { + return; // start() failed; the connection is already being torn down + } + APIError err = static_cast(this->helper_.get())->send_server_hello_first(); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Server hello failed"), err); + } + } #endif protected: diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index ea8787e5d7..84548a3077 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -61,13 +61,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { -#ifdef USE_API_OUTGOING_CONNECTION - // Outgoing mode is marked in state_; restore what init_common_() expects - const bool outgoing = this->state_ == State::CLIENT_HELLO_OUTGOING; - if (outgoing) { - this->state_ = State::INITIALIZE; - } -#endif APIError err = init_common_(); if (err != APIError::OK) { return err; @@ -86,15 +79,15 @@ APIError APINoiseFrameHelper::init() { #endif state_ = State::CLIENT_HELLO; -#ifdef USE_API_OUTGOING_CONNECTION - if (outgoing) { - // The peer needs our name and MAC to pick the key before its first message - state_ = State::CLIENT_HELLO_OUTGOING; - return this->send_server_hello_frame_(); - } -#endif return APIError::OK; } +#ifdef USE_API_OUTGOING_CONNECTION +APIError APINoiseFrameHelper::send_server_hello_first() { + // The peer needs our name and MAC to pick the key before its first message + this->state_ = State::CLIENT_HELLO_OUTGOING; + return this->send_server_hello_frame_(); +} +#endif #ifdef USE_API_PLAINTEXT APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { APIError err = this->init(); @@ -304,12 +297,8 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { #ifdef USE_API_OUTGOING_CONNECTION if (this->state_ == State::CLIENT_HELLO_OUTGOING) { - // Server hello already went out in init() - aerr = init_handshake_(); - if (aerr != APIError::OK) - return aerr; - state_ = State::HANDSHAKE; - return APIError::OK; + // Server hello already went out at handoff + return this->start_handshake_(); } #endif state_ = State::SERVER_HELLO; @@ -345,12 +334,12 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { APIError aerr = this->send_server_hello_frame_(); if (aerr != APIError::OK) return aerr; - - // start handshake - aerr = init_handshake_(); + return this->start_handshake_(); +} +APIError APINoiseFrameHelper::start_handshake_() { + APIError aerr = init_handshake_(); if (aerr != APIError::OK) return aerr; - state_ = State::HANDSHAKE; return APIError::OK; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 30b6361770..a807d6e5da 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -30,10 +30,10 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError init_from_handoff(const uint8_t *header, uint8_t header_len); #endif #ifdef USE_API_OUTGOING_CONNECTION - // init() then sends the server hello first so the peer can pick the key - // before its PSK-mixed message. Call before init(); stored in state_ so - // the helper does not grow. - void set_server_hello_first() { this->state_ = State::CLIENT_HELLO_OUTGOING; } + // Send the server hello immediately so the peer can pick the key before + // its PSK-mixed message. Call after init(); the mode is tracked in state_ + // so the helper does not grow. + APIError send_server_hello_first(); #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; @@ -46,6 +46,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_client_hello_(); APIError state_action_server_hello_(); APIError send_server_hello_frame_(); + APIError start_handshake_(); APIError state_action_handshake_(); APIError state_action_handshake_read_(); APIError state_action_handshake_write_(); diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 42075d380c..1bdf451245 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -21,12 +21,20 @@ static const char *const TAG = "api.outgoing"; void OutgoingConnectionManager::setup() { #ifndef API_OUTGOING_CONNECTION_HOST this->target_pref_ = global_preferences->make_preference(629847102UL, true); - if (this->target_pref_.load(&this->saved_)) { - // Defend against a corrupt or truncated preference blob - this->saved_.host[socket::SOCKADDR_STR_LEN - 1] = '\0'; - } else { - this->saved_.host[0] = '\0'; + if (!this->target_pref_.load(&this->saved_)) { + this->saved_ = {}; } + // Defend against a corrupt or truncated preference blob + this->saved_.host[socket::SOCKADDR_STR_LEN - 1] = '\0'; +#ifndef USE_NETWORK_IPV6 + if (strchr(this->saved_.host, ':') != nullptr) { + // Remembered by an earlier IPv6 build; set_sockaddr() would silently + // turn it into 255.255.255.255 + ESP_LOGW(TAG, "Clearing unusable IPv6 target %s", this->saved_.host); + this->saved_ = {}; + this->target_pref_.save(&this->saved_); + } +#endif #endif } @@ -43,9 +51,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { switch (this->state_) { case DialState::DIAL_STATE_IDLE: // Target went away; give it the configured delay to reconnect first - this->state_ = DialState::DIAL_STATE_WAITING; - this->state_ts_ = now; - this->wait_ = API_OUTGOING_CONNECTION_DELAY; + this->schedule_wait_(now, API_OUTGOING_CONNECTION_DELAY); break; case DialState::DIAL_STATE_WAITING: if (now - this->state_ts_ >= this->wait_) { @@ -66,26 +72,19 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { return; } const char *host = this->target_host_(); - if (host == nullptr || server->at_client_limit_() || !server->noise_ctx_.has_psk()) { - ESP_LOGD(TAG, "Not dialing: %s", - host == nullptr ? "no target" - : server->at_client_limit_() ? "max connections" - : "no key"); + if (host == nullptr) { + // The steady state until a dial-back client has ever connected + ESP_LOGV(TAG, "Not dialing: no target"); + this->schedule_wait_(now, PRECONDITION_RETRY_MS); + return; + } + const bool at_limit = server->at_client_limit_(); + if (at_limit || !server->noise_ctx_.has_psk()) { + ESP_LOGD(TAG, "Not dialing: %s", at_limit ? "max connections" : "no key"); // Not a dial failure; retry without escalating the backoff this->schedule_wait_(now, PRECONDITION_RETRY_MS); return; } -#if !defined(USE_NETWORK_IPV6) && !defined(API_OUTGOING_CONNECTION_HOST) - if (strchr(host, ':') != nullptr) { - // Remembered by an earlier IPv6 build; set_sockaddr() would silently - // turn it into 255.255.255.255 here - ESP_LOGW(TAG, "Clearing unusable IPv6 target %s", host); - this->saved_.host[0] = '\0'; - this->target_pref_.save(&this->saved_); - this->schedule_wait_(now, PRECONDITION_RETRY_MS); - return; - } -#endif struct sockaddr_storage addr; socklen_t addr_len = socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT); @@ -104,13 +103,7 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len); if (err == 0) { // Immediate success (possible for localhost) - this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); - if (this->dialed_conn_ == nullptr) { - this->schedule_retry_(now); - } else { - this->dial_handoff_ts_ = now; - this->schedule_wait_(now, PRECONDITION_RETRY_MS); - } + this->handoff_(server, now); return; } if (errno != EINPROGRESS) { @@ -134,8 +127,8 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { } this->last_poll_ = now; int fd = this->dial_socket_->get_fd(); - if (fd < 0 || fd >= FD_SETSIZE) { - ESP_LOGW(TAG, "Bad fd for connect poll: %d", fd); + if (fd >= FD_SETSIZE) { + ESP_LOGW(TAG, "fd %d out of select range", fd); this->schedule_retry_(now); return; } @@ -171,35 +164,42 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } + this->handoff_(server, now); +} + +void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) { this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); if (this->dialed_conn_ == nullptr) { this->schedule_retry_(now); return; } - this->dial_handoff_ts_ = now; - // Hold unescalated until the peer proves itself or dies unproven - this->schedule_wait_(now, PRECONDITION_RETRY_MS); + // Connected; dialed_conn_ gates further dialing until the session settles + this->state_ = DialState::DIAL_STATE_IDLE; } -void OutgoingConnectionManager::schedule_retry_(uint32_t now) { +void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) { this->dial_socket_.reset(); // no-op when the socket was handed off this->state_ = DialState::DIAL_STATE_WAITING; this->state_ts_ = now; + this->wait_ = wait; +} + +void OutgoingConnectionManager::schedule_retry_(uint32_t now) { // +/-20% jitter so a fleet of devices does not retry one server in lockstep const uint32_t jitter_span = this->backoff_ / 5; - this->wait_ = this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)); + this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1))); this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS); } -void OutgoingConnectionManager::on_client_removed(APIConnection *conn) { +void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) { if (conn != this->dialed_conn_) { return; } this->dialed_conn_ = nullptr; const uint32_t now = App.get_loop_component_start_time(); - if (now - this->dial_handoff_ts_ >= DIAL_PROVEN_MS) { - // Outlived the handshake timeout, so it authenticated: a working peer - // (e.g. a host: target that never sends the flag) disconnected normally + if (was_authenticated) { + // A working peer (e.g. a host: target that never sends the flag) + // disconnected normally this->backoff_ = BACKOFF_MIN_MS; this->schedule_wait_(now, API_OUTGOING_CONNECTION_DELAY); } else { @@ -238,16 +238,15 @@ void OutgoingConnectionManager::on_target_client(APIConnection *conn) { } void OutgoingConnectionManager::dump_config() const { - ESP_LOGCONFIG(TAG, " Outgoing connection port: %u", API_OUTGOING_CONNECTION_PORT); #ifdef API_OUTGOING_CONNECTION_HOST - ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", API_OUTGOING_CONNECTION_HOST); + const char *host = API_OUTGOING_CONNECTION_HOST; #else - if (this->saved_.host[0] != '\0') { - ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host); - } else { - ESP_LOGCONFIG(TAG, " Outgoing connection host: none remembered yet"); - } + const char *host = this->saved_.host[0] != '\0' ? this->saved_.host : "none remembered yet"; #endif + ESP_LOGCONFIG(TAG, + " Outgoing connection port: %u\n" + " Outgoing connection host: %s", + API_OUTGOING_CONNECTION_PORT, host); } } // namespace esphome::api diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 83f6cdc847..10d418b691 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -35,12 +35,9 @@ class OutgoingConnectionManager { void loop(APIServer *server); /// A key-verified client declared itself a dial-back target; last one wins void on_target_client(APIConnection *conn); - /// Clears the dialed-connection gate; dying unproven escalates the backoff - void on_client_removed(APIConnection *conn); - void on_shutdown() { - this->dial_socket_.reset(); - this->state_ = DialState::DIAL_STATE_IDLE; - } + /// Clears the dialed-connection gate; dying unauthenticated escalates the backoff + void on_client_removed(APIConnection *conn, bool was_authenticated); + void on_shutdown() { this->dial_socket_.reset(); } void dump_config() const; protected: @@ -55,21 +52,23 @@ class OutgoingConnectionManager { static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; static constexpr uint32_t NETWORK_RETRY_MS = 500; - // Longer than the 60s handshake timeout: a dialed session still alive past - // this authenticated, so its death is a normal disconnect, not a bad dial - static constexpr uint32_t DIAL_PROVEN_MS = 65000; + static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; + // Boot waits for the client to connect in first; a deep sleep wake window + // is short, so connecting out immediately is the wake state +#ifdef USE_DEEP_SLEEP + static constexpr uint32_t BOOT_WAIT_MS = 0; +#else + static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; +#endif void try_dial_(APIServer *server, uint32_t now); void poll_connect_(APIServer *server, uint32_t now); + // Hand the connected socket to the server and gate on the new connection + void handoff_(APIServer *server, uint32_t now); // Close any half-open dial and wait a jittered backoff before retrying void schedule_retry_(uint32_t now); // Wait without escalating the backoff (used for unmet preconditions) - void schedule_wait_(uint32_t now, uint32_t wait) { - this->dial_socket_.reset(); - this->state_ = DialState::DIAL_STATE_WAITING; - this->state_ts_ = now; - this->wait_ = wait; - } + void schedule_wait_(uint32_t now, uint32_t wait); const char *target_host_() const { #ifdef API_OUTGOING_CONNECTION_HOST return API_OUTGOING_CONNECTION_HOST; @@ -77,7 +76,6 @@ class OutgoingConnectionManager { return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr; #endif } - static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // Pointers first (4 bytes each on 32-bit) std::unique_ptr dial_socket_; @@ -89,16 +87,9 @@ class OutgoingConnectionManager { // 4-byte types uint32_t backoff_{BACKOFF_MIN_MS}; - // Boot waits for the client to connect in first; a deep sleep wake window - // is short, so connecting out immediately is the wake state -#ifdef USE_DEEP_SLEEP - uint32_t wait_{0}; -#else - uint32_t wait_{API_OUTGOING_CONNECTION_DELAY}; -#endif + uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; uint32_t last_poll_{0}; - uint32_t dial_handoff_ts_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d5b46e6640..cc95966694 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -212,13 +212,13 @@ void APIServer::remove_client_(uint8_t client_index) { std::string client_peername(client->get_peername_to(peername_buf)); #endif + // Read before the swap-and-reset below destroys the connection + const bool was_authenticated = client->is_authenticated(); #ifdef USE_API_OUTGOING_CONNECTION if (client->flags_.outgoing_connection_target) { this->outgoing_target_count_--; } - this->outgoing_conn_.on_client_removed(client.get()); - // Read before the swap-and-reset below destroys the connection - const bool was_authenticated = client->is_authenticated(); + this->outgoing_conn_.on_client_removed(client.get(), was_authenticated); #endif // Close socket now (was deferred from on_fatal_error to allow getpeername) @@ -241,12 +241,12 @@ void APIServer::remove_client_(uint8_t client_index) { // (suppressed while provisioning is pending - see loop()). if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); -#ifdef USE_API_OUTGOING_CONNECTION - // An unauthenticated session (e.g. a dial to a host that accepts TCP but - // never speaks the API) must not keep resetting the reboot watchdog - if (was_authenticated) -#endif + // A session that never authenticated (e.g. a port scan, or a dial to a + // host that accepts TCP but never speaks the API) must not reset the + // reboot watchdog + if (was_authenticated) { this->last_connected_ = App.get_loop_component_start_time(); + } } #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER @@ -285,10 +285,11 @@ void APIServer::add_client_(APIConnection *conn) { this->clients_[this->api_connection_count_++].reset(conn); conn->start(); - // First client connected - clear warning and update timestamp + // First client connected - clear warning. The reboot watchdog timestamp is + // refreshed when an authenticated client is removed (see remove_client_), + // never on bare TCP connects. if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_clear_warning(); - this->last_connected_ = App.get_loop_component_start_time(); } } @@ -301,21 +302,12 @@ APIConnection *APIServer::add_outgoing_client_(std::unique_ptr s return nullptr; } auto *conn = new APIConnection(std::move(sock), this); + this->add_client_(conn); + // After start(): sends our server hello first so the peer can pick the key conn->mark_outgoing(); - // Unlike add_client_, no watchdog refresh: a dial that never authenticates - // must not keep petting the no-client reboot timeout - this->clients_[this->api_connection_count_++].reset(conn); - conn->start(); return conn; } -void APIServer::on_client_authenticated() { - if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { - this->status_clear_warning(); - this->last_connected_ = App.get_loop_component_start_time(); - } -} - void APIServer::on_outgoing_target_client(APIConnection *conn) { this->outgoing_target_count_++; this->outgoing_conn_.on_target_client(conn); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 2a7f9bef59..e61645758c 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -85,9 +85,6 @@ class APIServer final : public Component, #ifdef USE_API_OUTGOING_CONNECTION // Called by APIConnection when a client declares itself a dial-back target in its hello void on_outgoing_target_client(APIConnection *conn); - // Called by APIConnection on authentication so dialed sessions feed the - // reboot watchdog only once they are real - void on_client_authenticated(); #endif void handle_disconnect(APIConnection *conn); diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6777e6cabc..9d66ba61c1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -702,3 +702,11 @@ async def run_compiled( ) yield _run_compiled + + +@pytest.fixture +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Point host preferences at a per-test dir so every run starts clean + (host preferences otherwise persist to ~/.esphome/prefs, keyed only by + device name).""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) diff --git a/tests/integration/test_api_outgoing_connection.py b/tests/integration/test_api_outgoing_connection.py index a03a2b42ab..d51a9e75c6 100644 --- a/tests/integration/test_api_outgoing_connection.py +++ b/tests/integration/test_api_outgoing_connection.py @@ -30,10 +30,8 @@ HA_CLIENT_INFO = "Home Assistant 2026.8.0" HELLO_TARGET_FLAG = b"\x20\x01" -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so every run starts with no saved peer.""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) +# Every run must start with no saved peer +pytestmark = pytest.mark.usefixtures("isolated_preferences") def _frame(payload: bytes) -> bytes: diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py index bcea2a2471..d2a8bfa62b 100644 --- a/tests/integration/test_api_zero_psk_provisioning.py +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -24,10 +24,8 @@ NEW_KEY = base64.b64encode(b"n" * 32) KEY_ACTIVATION_DELAY = 0.5 -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so every run starts unprovisioned.""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) +# Every run must start unprovisioned +pytestmark = pytest.mark.usefixtures("isolated_preferences") @pytest.mark.asyncio diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 657e273fe7..739bd2e42f 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -10,13 +10,8 @@ import pytest from .state_utils import InitialStateHelper, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction - -@pytest.fixture(autouse=True) -def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Keep host preferences per-test so RESTORE_AND_ON never loads a stale value left - behind by a previous run (host preferences otherwise persist to ~/.esphome/prefs, - keyed only by device name).""" - monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) +# RESTORE_AND_ON must never load a stale value left behind by a previous run +pytestmark = pytest.mark.usefixtures("isolated_preferences") @pytest.mark.asyncio