Compare commits

..
Author SHA1 Message Date
J. Nick Koston 66be2805e4 Merge branch 'dev' into noise-spare-ephemeral 2026-09-08 23:09:24 +02:00
J. Nick Koston 6c5ab89d5f [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) 2026-09-09 09:08:16 +12:00
J. Nick Koston 8f511a365a [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) 2026-09-09 09:07:06 +12:00
Kevin Ahrendt 006f31af93 [i2s_audio] Fix spurious driver failure (#19045) 2026-09-09 09:05:02 +12:00
Kevin Ahrendt 5bb112f407 [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) 2026-09-09 09:04:34 +12:00
dependabot[bot] 4ab9298ab3 Bump esptool from 5.3.1 to 5.4.0 (#19023) 2026-09-08 22:11:36 +02:00
Kevin AhrendtandCopilot Autofix powered by AI 3926612281 [core] Fix use-after-free when deleting a running StaticTask (#19048)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 07:46:17 +12:00
J. Nick Koston c9fb996758 Note the spare key is not wiped on shutdown 2026-09-07 17:04:29 +02:00
J. Nick Koston 6aa6b682df Share the spare ephemeral key size and note the key stays in RAM until consumed 2026-09-07 17:02:04 +02:00
J. Nick Koston 5cc758dea3 Include noise.h under the spare ephemeral define and assert the empty slot precondition 2026-09-07 16:51:02 +02:00
J. Nick Koston bf718a28b9 [noise] Assert the spare key reaches the wire, drop the ESP8266 warning threshold
The gtest now checks that the responder's message carries the slot's
public key and that the next handshake uses a different one, so a
silently refused spare cannot pass. The api refill sites are guarded by
the feature define they use. The ESP8266 blocking threshold change is
left to a separate core change for operations that cannot be shortened.
2026-09-07 16:35:54 +02:00
J. Nick Koston a595590386 [api] Inline the connect grace predicate
One caller and a two term body: inlined it is 48 bytes smaller on
ESP8266 than the out of line function plus its call.
2026-09-07 16:14:21 +02:00
J. Nick Koston a0dc5a8d14 [api] Check the network once per loop pass for the refill and the clients 2026-09-07 15:50:14 +02:00
J. Nick Koston edec6aaf5e [noise] Declare the noise-c state alias with using 2026-09-07 13:21:19 +02:00
J. Nick Koston 55804e6e16 [noise] Use the clamp bit of the spare key as its ready flag
A clamped X25519 private key always has bit 254 set, so byte 31 of the
slot says whether a key is present and a wiped slot reads empty; the
separate flag and its padding go, leaving the slot at exactly 64 bytes.
2026-09-07 12:45:25 +02:00
J. Nick Koston 228f8894a9 [noise] Consume the spare key inside the handshake and let noise own its define
NoiseResponderHandshake::init() now hands the slot straight to noise-c and
wipes it, so the api and ota call sites are unchanged, nothing copies the
key pair and no transport has to remember the wipe. The slot compiles
under USE_NOISE_SPARE_EPHEMERAL, which the api component enables as the
refiller, instead of the noise component keying on an api define. The
per tick check sits in loop() with the refill out of line, and the grace
predicate lives next to the handshake timeout it mirrors.
2026-09-07 12:33:50 +02:00
J. Nick Koston 866574ca14 [noise] Keep only the slot flag test inline in the api loop
The per tick check is a byte load and branch now; the network check,
client scan and refill live in prepare_spare_ephemeral_slow_(), called
only while the slot is empty.
2026-09-07 12:23:09 +02:00
J. Nick Koston 5747c736c2 [noise] Inline the spare slot check, keep the slot empty if the base multiply fails
has_spare_ephemeral() is polled every api loop tick, so the flag is now
an extern and the accessor lives in the header.
2026-09-07 12:21:04 +02:00
J. Nick Koston 0f6c266cd7 [noise] Give the refill pass 100 ms before the blocking warning on ESP8266 2026-09-07 00:58:02 +02:00
J. Nick Koston 05dbc5ee59 [noise] Hold the refill only for connections still inside their grace period
Gating on the noise handshake alone let the refill land between the
handshake and the hello response, inside the window being optimized; a
connection now holds the slot while it is unauthenticated and younger
than a second, so a fresh client gets through its hello first and a stale
half open one stops holding the slot after that.
2026-09-07 00:47:55 +02:00
J. Nick Koston 29f7439154 [noise] Shorten the comments 2026-09-07 00:39:22 +02:00
J. Nick Koston 89cd183a9f [noise] Gate the refill on the noise handshake, cover its loop time on ESP8266
The refill now waits only for api clients still in their noise handshake,
not for any client that has yet to send its hello, so a stale half open
connection cannot keep the slot empty for a minute. On ESP8266 the api
server raises its blocking warning threshold to 80 ms in setup(), since
the refill takes about 60 ms there and used to run inside every handshake
anyway; and prepare_spare_ephemeral() clears the ready flag before
filling, so a random source failure can never leave a mismatched pair.
2026-09-07 00:34:20 +02:00
J. Nick Koston 370cfb8898 [noise] Generate the responder ephemeral key ahead of the handshake
The responder's ephemeral key pair was generated inside the handshake
write step, a base point multiply of about 60 ms on ESP8266 that every
connecting client waited for. The noise component now keeps one spare key
pair (64 bytes of static storage, only in builds with an encrypted api
since the api server is the only refiller), the api server refills it from
loop() once the network is up and no api client is mid handshake, and both
the api and ota handshakes take it through noise-c's
noise_handshakestate_set_local_ephemeral(). A handshake that finds the
slot empty, or whose spare noise-c refuses, generates its own key as
before. The host gtest suite covers the slot's single use, the key pair's
consistency, and a full handshake whose message carries the supplied key.
2026-09-07 00:17:20 +02:00
42 changed files with 424 additions and 423 deletions
+2
View File
@@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
enable_spare_ephemeral,
encryption_schema,
new_psk_progmem,
validate_encryption_key,
@@ -603,6 +604,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
enable_spare_ephemeral()
else:
cg.add_define("USE_API_PLAINTEXT")
+7
View File
@@ -316,9 +316,16 @@ class APIConnection final : public APIServerConnectionBase {
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
// How long a new connection holds off the spare ephemeral refill
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// Unauthenticated and within its grace period; an older unauthenticated
// connection is a stale half open client and no longer counts
bool is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
bool is_connection_setup() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
+25 -2
View File
@@ -143,6 +143,15 @@ void APIServer::loop() {
this->accept_new_connections_();
}
// Checked once per pass for the refill and for the clients below
const bool connected = network::is_connected();
#ifdef USE_NOISE_SPARE_EPHEMERAL
// Only the flag test is inline; refilling is the rare path
if (connected && !noise::has_spare_ephemeral()) {
this->refill_spare_ephemeral_();
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -159,8 +168,7 @@ void APIServer::loop() {
}
// Process clients and remove disconnected ones in a single pass
// Check network connectivity once for all clients
if (!network::is_connected()) {
if (!connected) {
// Network is down - disconnect all clients
for (auto &client : this->active_clients()) {
client->on_fatal_error();
@@ -188,6 +196,21 @@ void APIServer::loop() {
}
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
// Called with the network up; refill only while no api client is still
// connecting (an OTA handshake is not visible here and just pays the refill
// it triggered).
void APIServer::refill_spare_ephemeral_() {
const uint32_t now = App.get_loop_component_start_time();
for (auto &client : this->active_clients()) {
if (client->is_still_connecting(now)) {
return;
}
}
noise::prepare_spare_ephemeral();
}
#endif
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
+4 -1
View File
@@ -5,7 +5,7 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#ifdef USE_API_NOISE
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
@@ -363,6 +363,9 @@ class APIServer final : public Component,
uint8_t provisioning_source_{0};
#endif
#ifdef USE_NOISE_SPARE_EPHEMERAL
void refill_spare_ephemeral_();
#endif
#ifdef USE_API_NOISE
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
@@ -6,6 +6,7 @@
#include "esphome/components/network/util.h"
#include "esphome/core/log.h"
#include <cerrno>
#include <sys/select.h>
namespace esphome::async_tcp {
@@ -41,15 +42,7 @@ bool AsyncClient::connect(const char *host, uint16_t port) {
return false;
}
if (socket_->setblocking(false) != 0) {
// Capture before the log and close() clobber errno
const int saved_errno = errno;
ESP_LOGE(TAG, "Failed to set nonblocking: errno %d", saved_errno);
close();
if (error_cb_)
error_cb_(error_arg_, this, saved_errno);
return false;
}
socket_->setblocking(false);
int err = socket_->connect((struct sockaddr *) &addr, addrlen);
if (err == 0) {
@@ -104,22 +97,45 @@ void AsyncClient::loop() {
return;
if (connecting_) {
int err = 0;
switch (socket::poll_connect(*socket_, err)) {
case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING:
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED:
// For connecting, we need to check writability, not readability
// The Application's select() only monitors read FDs, so we do our own check here
// For ESP platforms lwip_select() might be faster, but this code isn't used
// on those platforms anyway. If it was, we'd fix the Application select()
// to report writability instead of doing it this way.
int fd = socket_->get_fd();
if (fd < 0) {
ESP_LOGW(TAG, "Invalid socket fd");
close();
return;
}
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv);
if (ret > 0 && FD_ISSET(fd, &writefds)) {
int error = 0;
socklen_t len = sizeof(error);
if (socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) == 0 && error == 0) {
connecting_ = false;
connected_ = true;
if (connect_cb_)
connect_cb_(connect_arg_, this);
break;
case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR:
ESP_LOGW(TAG, "Connection failed: %d", err);
} else {
ESP_LOGW(TAG, "Connection failed: %d", error);
close();
if (error_cb_)
error_cb_(error_arg_, this, err);
break;
error_cb_(error_arg_, this, error);
}
} else if (ret < 0) {
const int err = errno;
ESP_LOGE(TAG, "Select error: %d", err);
close();
if (error_cb_)
error_cb_(error_arg_, this, err);
}
} else if (connected_) {
// For connected sockets, use the Application's select() results
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock();
if (this->file_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
void AudioTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
}
void AudioSinkTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
#ifdef USE_SPEAKER
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
}
bool AudioTransferBuffer::has_buffered_data() const {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0;
if (bytes_to_read > 0) {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
}
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
} else
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_written =
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
} else if (this->sink_callback_ != nullptr) {
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
return (this->speaker_->has_buffered_data() || (this->available() > 0));
}
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
@@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -444,10 +447,7 @@ void ESPHomeOTAComponent::handle_data_() {
tv.tv_usec = 0;
this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
if (this->client_->setblocking(true) != 0) {
this->log_socket_error_(LOG_STR("blocking"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
this->client_->setblocking(true);
// Acknowledge auth OK - 1 byte
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
break;
}
// Still starting up or winding down from a previous run
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
break;
}
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-failure", 1000);
break;
}
if (this->speaker_task_handle_ == nullptr) {
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
break;
case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -218,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
}
bool I2SAudioSpeakerBase::has_buffered_data() const {
if (this->audio_ring_buffer_.use_count() > 0) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
if (temp_ring_buffer != nullptr) {
return temp_ring_buffer->available() > 0;
}
return false;
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
return;
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (this->ring_buffer_.use_count() > 1) {
if (temp_ring_buffer != nullptr) {
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
}
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED);
@@ -48,7 +48,7 @@ class MicrophoneSource final {
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_.use_count() == 0) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
}
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
}
size_t bytes_written = 0;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer.use_count() > 0) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
if (bytes_written > 0) {
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_.use_count() == 0) {
if (this->audio_source_ == nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
this->ring_buffer_ = temp_ring_buffer;
}
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
return ESP_ERR_NO_MEM;
}
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const {
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
}
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0;
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source.use_count() == 0) {
if (audio_source == nullptr) {
// No audio source allocated, so skip processing this speaker
continue;
}
+7 -2
View File
@@ -86,14 +86,19 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
return ENCRYPTION_SCHEMA(config)
def enable_spare_ephemeral() -> None:
"""Compile the spare ephemeral key slot; the component that refills it calls this."""
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.24")
cg.add_library("esphome/noise-c", "0.1.26")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.6")
cg.add_library("esphome/libsodium", "1.10021.8")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
+35
View File
@@ -1,12 +1,14 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -24,6 +26,39 @@ void NoiseContext::load_psk(psk_t &out) const {
progmem_memcpy(out.data(), this->psk_, out.size());
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
static constexpr size_t PRIVATE_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE;
static constexpr size_t PUBLIC_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE;
uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void prepare_spare_ephemeral() {
uint8_t *private_key = spare_ephemeral;
uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE;
// Same steps as noise-c's curve25519 keygen; the clamp sets the ready bit,
// a failure wipes the slot so the handshake generates its own key
if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) {
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
return;
}
private_key[0] &= 0xF8;
private_key[PRIVATE_KEY_SIZE - 1] = (private_key[PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
if (crypto_scalarmult_curve25519_base(public_key, private_key) != 0) {
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
}
}
int consume_spare_ephemeral(NoiseHandshakeState *state) {
if (!has_spare_ephemeral()) {
return 0;
}
// noise-c keeps its own copy, so the slot is wiped either way
int err = noise_handshakestate_set_local_ephemeral(state, spare_ephemeral, PRIVATE_KEY_SIZE,
spare_ephemeral + PRIVATE_KEY_SIZE, PUBLIC_KEY_SIZE);
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
return err;
}
#endif // USE_NOISE_SPARE_EPHEMERAL
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
+23
View File
@@ -6,6 +6,9 @@
#include <cstdint>
#include "esphome/core/log.h"
// noise-c handshake state; the full definition lives in <noise/protocol.h>
using NoiseHandshakeState = struct NoiseHandshakeState_s;
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
@@ -38,6 +41,26 @@ class NoiseContext {
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
#ifdef USE_NOISE_SPARE_EPHEMERAL
// One responder ephemeral key pair generated ahead of time (about 60 ms on
// ESP8266), refilled by the api server while idle and consumed by the next
// handshake of any noise transport; an empty slot means the handshake
// generates its own key. The private key stays in RAM until consumed; it is
// not wiped on shutdown.
// Private key then public key; zero when empty
static constexpr size_t SPARE_EPHEMERAL_KEY_SIZE = 32;
static constexpr size_t SPARE_EPHEMERAL_SIZE = 2 * SPARE_EPHEMERAL_KEY_SIZE;
extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// Polled every api loop tick, so it must inline. A clamped X25519 private key
// always has bit 254 set, so that byte doubles as the ready flag.
inline bool has_spare_ephemeral() { return (spare_ephemeral[SPARE_EPHEMERAL_KEY_SIZE - 1] & 0x40) != 0; }
/// Fill the slot; blocks for the base point multiply
void prepare_spare_ephemeral();
/// Hand the slot's key pair to a handshake that has not started and wipe the
/// slot; 0 when the slot was empty or the key was taken, else the noise-c error
int consume_spare_ephemeral(NoiseHandshakeState *state);
#endif
// Shared wire format for the noise transports (api and ota): every frame is
// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload.
// Handshake payloads start with a status byte; transport payloads end with
@@ -57,6 +57,13 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog
HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err);
return this->fail_init_(err);
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
err = consume_spare_ephemeral(this->handshake_);
// Not fatal: the handshake generates its own key instead
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err);
}
#endif
err = noise_handshakestate_start(this->handshake_);
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_start", err);
+2 -1
View File
@@ -37,7 +37,8 @@ class NoiseResponderHandshake {
NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete;
/// Create and start the handshake with the context's PSK and the prologue.
/// A repeated call frees the previous handshake state and starts over.
/// A repeated call frees the previous handshake state and starts over. A
/// spare ephemeral key, when one is ready, is used instead of generating.
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len);
/// ACTION_FAILED is the catch-all: returned before init(), after split()
/// has released the state, and when noise-c reports a failed handshake.
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
}
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
}
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
} else {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
} else {
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
bool has_ring_buffer_data = false;
if (this->requires_resampling_()) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
}
}
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
this_resampler->ring_buffer_ = temp_ring_buffer;
@@ -59,15 +59,13 @@ int BSDSocketImpl::close() {
int BSDSocketImpl::setblocking(bool blocking) {
int fl = ::fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
return ::fcntl(this->fd_, F_SETFL, fl);
::fcntl(this->fd_, F_SETFL, fl);
return 0;
}
size_t BSDSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
-7
View File
@@ -205,13 +205,6 @@ static constexpr size_t SOCKADDR_STR_LEN = 46; // INET6_ADDRSTRLEN
static constexpr size_t SOCKADDR_STR_LEN = 16; // INET_ADDRSTRLEN
#endif
/// Outcome of polling a non-blocking connect(); see socket::poll_connect().
enum class ConnectPollResult : uint8_t {
CONNECT_POLL_RESULT_PENDING,
CONNECT_POLL_RESULT_CONNECTED,
CONNECT_POLL_RESULT_ERROR,
};
} // namespace esphome::socket
#endif
+65 -143
View File
@@ -48,33 +48,8 @@ static const char *const TAG = "socket";
#ifdef USE_ESP8266
// optimistic_yield() rate limit in microseconds of CONT time; cheap when hot.
static constexpr uint32_t ESP8266_YIELD_INTERVAL_US = 1000;
// Let SYS run so queued WiFi traffic reaches lwip; CONT and SYS are cooperative
static inline void yield_to_sys() { optimistic_yield(ESP8266_YIELD_INTERVAL_US); }
#else
static inline void yield_to_sys() {}
#endif
// errno for a failed tcp_* call
static int lwip_err_to_errno(err_t err) {
switch (err) {
case ERR_MEM:
return ENOMEM;
case ERR_BUF:
return EAGAIN; // transient, e.g. no free local port
case ERR_RTE:
return EHOSTUNREACH; // no route, e.g. no address yet
case ERR_VAL:
case ERR_ARG:
return EINVAL;
case ERR_USE:
return EADDRINUSE;
case ERR_ISCONN:
return EISCONN;
default:
return EIO;
}
}
// set to 1 to enable verbose lwip logging
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
@@ -87,8 +62,8 @@ static int lwip_err_to_errno(err_t err) {
// Must be called before destroying the object that tcp_arg points to —
// tcp_abort() triggers the err callback synchronously, which would
// otherwise call back into a partially-destroyed object.
// tcp_sent/tcp_poll are never registered and the connect callback cannot
// fire after abort or close, so neither is cleared.
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
static void pcb_detach_abort(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
tcp_recv(pcb, nullptr);
@@ -101,7 +76,8 @@ static void pcb_detach_abort(struct tcp_pcb *pcb) {
// After tcp_close(), the PCB remains alive during the TCP close handshake
// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP
// would call recv/err on a destroyed socket object, corrupting the heap.
// Callbacks are left as in pcb_detach_abort().
// tcp_sent/tcp_poll are not cleared because this implementation
// never registers them.
// Returns ERR_OK on success; on failure the PCB is aborted instead.
static err_t pcb_detach_close(struct tcp_pcb *pcb) {
tcp_arg(pcb, nullptr);
@@ -125,51 +101,67 @@ LWIPRawCommon::~LWIPRawCommon() {
}
}
bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const {
if (name == nullptr) {
errno = EINVAL;
return false;
}
#if LWIP_IPV6
if (this->family_ == AF_INET6) {
if (addrlen < sizeof(sockaddr_in6)) {
errno = EINVAL;
return false;
}
auto *addr6 = reinterpret_cast<const sockaddr_in6 *>(name);
*port = ntohs(addr6->sin6_port);
inet6_addr_to_ip6addr(ip_2_ip6(ip), &addr6->sin6_addr);
// ANY lets bind() accept both families; connect() picks the concrete type
IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY);
return true;
}
#endif
if (this->family_ != AF_INET || addrlen < sizeof(sockaddr_in)) {
errno = EINVAL;
return false;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
*port = ntohs(addr4->sin_port);
ip_addr_set_ip4_u32(ip, addr4->sin_addr.s_addr);
return true;
}
int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) {
LWIP_LOCK();
if (this->pcb_ == nullptr) {
errno = EBADF;
return -1;
}
ip_addr_t ip;
uint16_t port;
if (!this->sockaddr2ip_(name, addrlen, &ip, &port)) {
if (name == nullptr) {
errno = EINVAL;
return -1;
}
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
ip_addr_t ip;
in_port_t port;
#if LWIP_IPV6
if (this->family_ == AF_INET) {
if (addrlen < sizeof(sockaddr_in)) {
errno = EINVAL;
return -1;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
port = ntohs(addr4->sin_port);
ip.type = IPADDR_TYPE_V4;
ip.u_addr.ip4.addr = addr4->sin_addr.s_addr;
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip4addr_ntoa(&ip.u_addr.ip4), port);
} else if (this->family_ == AF_INET6) {
if (addrlen < sizeof(sockaddr_in6)) {
errno = EINVAL;
return -1;
}
auto *addr6 = reinterpret_cast<const sockaddr_in6 *>(name);
port = ntohs(addr6->sin6_port);
ip.type = IPADDR_TYPE_ANY;
memcpy(&ip.u_addr.ip6.addr, &addr6->sin6_addr.un.u8_addr, 16);
LWIP_LOG("tcp_bind(%p ip=%s port=%u)", this->pcb_, ip6addr_ntoa(&ip.u_addr.ip6), port);
} else {
errno = EINVAL;
return -1;
}
#else
if (this->family_ != AF_INET) {
errno = EINVAL;
return -1;
}
auto *addr4 = reinterpret_cast<const sockaddr_in *>(name);
port = ntohs(addr4->sin_port);
ip.addr = addr4->sin_addr.s_addr;
LWIP_LOG("tcp_bind(%p ip=%u port=%u)", this->pcb_, ip.addr, port);
#endif
err_t err = tcp_bind(this->pcb_, &ip, port);
if (err == ERR_USE) {
LWIP_LOG(" -> err ERR_USE");
errno = EADDRINUSE;
return -1;
}
if (err == ERR_VAL) {
LWIP_LOG(" -> err ERR_VAL");
errno = EINVAL;
return -1;
}
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = lwip_err_to_errno(err);
errno = EIO;
return -1;
}
return 0;
@@ -186,7 +178,7 @@ int LWIPRawCommon::close() {
this->pcb_ = nullptr;
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = lwip_err_to_errno(err);
errno = err == ERR_MEM ? ENOMEM : EIO;
return -1;
}
return 0;
@@ -213,7 +205,7 @@ int LWIPRawCommon::shutdown(int how) {
err_t err = tcp_shutdown(this->pcb_, shut_rx, shut_tx);
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = lwip_err_to_errno(err);
errno = err == ERR_MEM ? ENOMEM : EIO;
return -1;
}
return 0;
@@ -433,82 +425,7 @@ void LWIPRawImpl::s_err_fn(void *arg, err_t err) {
// ERR_ABRT: aborted through tcp_abort or TCP timer
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err);
if (arg_this->connect_err_ == EINPROGRESS) {
// Refused (RST) or SYN retries exhausted; written before pcb_ so
// poll_connect() never sees a dead pcb without its reason
arg_this->connect_err_ = err == ERR_RST ? ECONNREFUSED : ETIMEDOUT;
}
arg_this->pcb_ = nullptr;
esphome::wake_loop_any_context();
}
err_t LWIPRawImpl::s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err) {
// LWIP CALLBACK, same constraints as s_err_fn; err is always ERR_OK
auto *arg_this = reinterpret_cast<LWIPRawImpl *>(arg);
arg_this->connect_err_ = EISCONN;
esphome::wake_loop_any_context();
return ERR_OK;
}
int LWIPRawImpl::connect(const struct sockaddr *addr, socklen_t addrlen) {
LWIP_LOCK();
if (this->pcb_ == nullptr) {
errno = EBADF;
return -1;
}
if (this->connect_err_ == EINPROGRESS || this->connect_err_ == EISCONN) {
errno = this->connect_err_ == EINPROGRESS ? EALREADY : EISCONN;
return -1;
}
ip_addr_t ip;
uint16_t port;
if (!this->sockaddr2ip_(addr, addrlen, &ip, &port)) {
return -1;
}
#if LWIP_IPV6
// tcp_connect needs a concrete type; a remembered IPv4 peer arrives v4-mapped
if (IP_IS_ANY_TYPE_VAL(ip)) {
if (ip6_addr_isipv4mappedipv6(ip_2_ip6(&ip))) {
unmap_ipv4_mapped_ipv6(ip_2_ip4(&ip), ip_2_ip6(&ip));
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V4);
} else {
IP_SET_TYPE_VAL(ip, IPADDR_TYPE_V6);
}
}
#endif
LWIP_LOG("tcp_connect(%p ip=%s port=%u)", this->pcb_, ipaddr_ntoa(&ip), port);
err_t err = tcp_connect(this->pcb_, &ip, port, LWIPRawImpl::s_connected_fn);
if (err != ERR_OK) {
LWIP_LOG(" -> err %d", err);
errno = lwip_err_to_errno(err);
return -1;
}
this->connect_err_ = EINPROGRESS;
errno = EINPROGRESS;
return -1;
}
ConnectPollResult LWIPRawImpl::poll_connect(int &err_out) const {
// pcb_ first; see the ordering note on the declaration
if (this->pcb_ == nullptr) {
// Only a recorded connect failure carries its own reason
const bool failed = this->connect_err_ == ECONNREFUSED || this->connect_err_ == ETIMEDOUT;
err_out = failed ? this->connect_err_ : ECONNRESET;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
switch (this->connect_err_) {
case EINPROGRESS:
yield_to_sys(); // so the SYN-ACK is processed between polls
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
case EISCONN:
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
case 0:
err_out = EINVAL; // no connect was started
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
default:
err_out = this->connect_err_;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
}
err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) {
@@ -623,11 +540,14 @@ ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) {
}
ssize_t LWIPRawImpl::read(void *buf, size_t len) {
// Let queued WiFi RX reach lwip first; otherwise inbound segments can
// sit unprocessed for seconds while the main loop polls
#ifdef USE_ESP8266
// Would block: yield to SYS so queued WiFi RX reaches lwip and this read
// may succeed. Without this, inbound segments can sit unprocessed for
// seconds while the main loop polls (CONT/SYS are cooperative on ESP8266).
if (this->waiting_for_data_()) {
yield_to_sys();
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
}
#endif
// See waiting_for_data_() for safety of unlocked reads.
if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) {
this->wait_for_data_();
@@ -716,10 +636,12 @@ int LWIPRawImpl::internal_output_() {
return -1;
}
}
#ifdef USE_ESP8266
// Flushed: yield to SYS so the queued segments reach the WiFi driver
// instead of waiting seconds for an unrelated SYS slot. Callers only get
// here after a successful tcp_write, so idle paths never yield.
yield_to_sys();
optimistic_yield(ESP8266_YIELD_INTERVAL_US);
#endif
return 0;
}
@@ -50,8 +50,6 @@ class LWIPRawCommon {
protected:
int ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen);
/// sockaddr of this socket's family to lwip address and port; false with errno on mismatch
bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) const;
// Member ordering optimized to minimize padding on 32-bit systems
struct tcp_pcb *pcb_;
@@ -60,14 +58,7 @@ class LWIPRawCommon {
bool nodelay_ = false;
sa_family_t family_ = 0;
uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s)
// 0 before connect(), EINPROGRESS while pending, EISCONN once established,
// else the failure errno the callbacks recorded; fills the padding byte
uint8_t connect_err_ = 0;
static_assert(EINPROGRESS < 256 && EISCONN < 256 && ECONNREFUSED < 256 && ECONNRESET < 256 && ETIMEDOUT < 256,
"connect_err_ stores errno values in a byte");
};
// The connect state must stay in the padding so no socket pays RAM for it
static_assert(sizeof(LWIPRawCommon) == sizeof(struct tcp_pcb *) + 4, "LWIPRawCommon grew past one word of flags");
/// Connected socket implementation for LWIP raw TCP.
/// No virtual methods — callers always use the concrete type.
@@ -92,12 +83,6 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = EOPNOTSUPP;
return -1;
}
/// Non-blocking: returns -1/EINPROGRESS once the SYN is queued, see poll_connect().
/// addr must match the socket family; an IPv4 peer on AF_INET6 arrives v4-mapped.
int connect(const struct sockaddr *addr, socklen_t addrlen);
// Unlocked like ready(): the callbacks write the error byte before pcb_,
// so a torn read only costs one extra poll
ConnectPollResult poll_connect(int &err_out) const;
ssize_t read(void *buf, size_t len);
ssize_t readv(const struct iovec *iov, int iovcnt);
ssize_t recvfrom(void *, size_t, sockaddr *, socklen_t *) {
@@ -135,7 +120,6 @@ class LWIPRawImpl : public LWIPRawCommon {
static void s_err_fn(void *arg, err_t err);
static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err);
static err_t s_connected_fn(void *arg, struct tcp_pcb *pcb, err_t err);
protected:
// True when the socket could receive data but none has arrived yet.
@@ -153,9 +137,6 @@ class LWIPRawImpl : public LWIPRawCommon {
size_t rx_buf_offset_ = 0;
bool rx_closed_ = false;
};
// rx_buf_, rx_buf_offset_, then rx_closed_ padded to a word
static_assert(sizeof(LWIPRawImpl) == sizeof(LWIPRawCommon) + sizeof(pbuf *) + sizeof(size_t) + 4,
"LWIPRawImpl layout changed");
/// Listening socket implementation for LWIP raw TCP.
/// Separate from LWIPRawImpl — no virtual dispatch needed.
@@ -49,15 +49,13 @@ int LwIPSocketImpl::close() {
int LwIPSocketImpl::setblocking(bool blocking) {
int fl = lwip_fcntl(this->fd_, F_GETFL, 0);
if (fl < 0) {
return fl;
}
if (blocking) {
fl &= ~O_NONBLOCK;
} else {
fl |= O_NONBLOCK;
}
return lwip_fcntl(this->fd_, F_SETFL, fl);
lwip_fcntl(this->fd_, F_SETFL, fl);
return 0;
}
size_t LwIPSocketImpl::getpeername_to(std::span<char, SOCKADDR_STR_LEN> buf) {
+2 -54
View File
@@ -2,9 +2,6 @@
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
#include <cerrno>
#include <cstring>
#ifdef USE_SOCKET_IMPL_BSD_SOCKETS
#include <sys/select.h>
#endif
#include <string>
#include "esphome/core/log.h"
#include "esphome/core/application.h"
@@ -168,10 +165,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
#else
// Use LWIP-specific functions
ip6_addr_t ip6;
if (inet6_aton(ip_address, &ip6) == 0) {
errno = EINVAL;
return 0;
}
inet6_aton(ip_address, &ip6);
memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr));
#endif
return sizeof(sockaddr_in6);
@@ -191,58 +185,12 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return 0;
}
#else
// Unlike inet_addr(), inet_aton() can signal failure while still
// accepting the broadcast address 255.255.255.255
if (inet_aton(ip_address, &server->sin_addr) == 0) {
errno = EINVAL;
return 0;
}
server->sin_addr.s_addr = inet_addr(ip_address);
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
}
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
ConnectPollResult poll_connect(Socket &sock, int &err_out) {
int fd = sock.get_fd();
if (fd < 0 || fd >= FD_SETSIZE) {
// FD_SET on either is undefined behavior
err_out = EBADF;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
// Connect completion is a write event; the main loop only selects on reads
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval tv = {0, 0};
#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS
// LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly
int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv);
#else
// Global-scope select: the entity namespace esphome::select shadows it here
int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv);
#endif
if (ret < 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
if (ret == 0) {
return ConnectPollResult::CONNECT_POLL_RESULT_PENDING;
}
int error = 0;
socklen_t len = sizeof(error);
if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) {
err_out = errno;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
if (error != 0) {
err_out = error;
return ConnectPollResult::CONNECT_POLL_RESULT_ERROR;
}
return ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED;
}
#endif
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) {
#if USE_NETWORK_IPV6
if (addrlen < sizeof(sockaddr_in6)) {
-8
View File
@@ -145,14 +145,6 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
/// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or
/// errno) on fd implementations and the failure the callbacks recorded on raw lwip.
#ifdef USE_SOCKET_IMPL_LWIP_TCP
inline ConnectPollResult poll_connect(Socket &sock, int &err_out) { return sock.poll_connect(err_out); }
#else
ConnectPollResult poll_connect(Socket &sock, int &err_out);
#endif
/// Format sockaddr into caller-provided buffer, returns length written (excluding null)
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf);
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
this->read_task_.deallocate();
this->decode_task_.deallocate();
// Both are attempted every time; a task that is still running on the other core is freed by a
// subsequent call, and freeing an already freed task succeeds without doing anything
bool read_task_freed = this->read_task_.deallocate();
bool decode_task_freed = this->decode_task_.deallocate();
if (!read_task_freed || !decode_task_freed) {
// A task is still running on the other core, so keep the pipeline in its current state and try
// again on the next call
return AudioPipelineState::PLAYING;
}
if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback
this->speaker_->stop();
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
if (err == ESP_OK) {
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
}
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
err = reader->add_sink(temp_ring_buffer);
}
}
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
if (err == ESP_OK) {
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
}
if (err != ESP_OK) {
// Send specific error message
+2 -11
View File
@@ -13,12 +13,7 @@ void UDPComponent::setup() {
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
for (const auto &address : this->addresses_) {
struct sockaddr saddr {};
if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) {
ESP_LOGW(TAG, "Invalid address %s", address);
// A dropped address silently receives nothing; surface the misconfiguration
this->status_set_warning(LOG_STR("invalid address"));
continue;
}
socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_);
this->sockaddrs_.push_back(saddr);
}
// set up broadcast socket
@@ -99,11 +94,7 @@ void UDPComponent::setup() {
// 8266 and RP2040 `Duino
for (const auto &address : this->addresses_) {
auto ipaddr = IPAddress();
if (!ipaddr.fromString(address)) {
ESP_LOGW(TAG, "Invalid address %s", address);
this->status_set_warning(LOG_STR("invalid address"));
continue;
}
ipaddr.fromString(address);
this->ipaddrs_.push_back(ipaddr);
}
if (this->should_listen_)
@@ -34,10 +34,6 @@ void WakeOnLanButton::press_action() {
struct sockaddr_storage saddr {};
auto addr_len =
socket::set_sockaddr(reinterpret_cast<sockaddr *>(&saddr), sizeof(saddr), "255.255.255.255", this->port_);
if (addr_len == 0) {
ESP_LOGW(TAG, "Invalid broadcast address");
return;
}
uint8_t buffer[6 + sizeof this->macaddr_ * 16];
memcpy(buffer, PREFIX, sizeof(PREFIX));
for (size_t i = 0; i != 16; i++) {
+1
View File
@@ -231,6 +231,7 @@
#define USE_IMPROV_SERIAL_NEXT_URL
#define USE_MD5
#define USE_NOISE
#define USE_NOISE_SPARE_EPHEMERAL
#define USE_SHA256
#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2
#define USE_MQTT
+23 -7
View File
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true;
}
void StaticTask::destroy() {
if (this->handle_ != nullptr) {
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
bool StaticTask::destroy() {
if (this->handle_ == nullptr) {
return true;
}
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
// the other core to yield though, so the task may still be running on it for a moment.
vTaskSuspend(this->handle_);
if (eTaskGetState(this->handle_) != eSuspended) {
// The task is still running on the other core and using its stack. Deleting it now would only put it on
// the termination list and return, so the caller has to try again once it has been swapped out.
return false;
}
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
return true;
}
void StaticTask::deallocate() {
this->destroy();
bool StaticTask::deallocate() {
if (!this->destroy()) {
return false;
}
if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
this->stack_buffer_ = nullptr;
this->stack_size_ = 0;
}
return true;
}
} // namespace esphome
+12 -5
View File
@@ -11,6 +11,7 @@ namespace esphome {
/** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
*/
class StaticTask {
public:
@@ -23,7 +24,7 @@ class StaticTask {
/// @brief Allocate stack and create task.
/// @param fn Task function
/// @param name Task name (for debug)
/// @param stack_size Stack size in StackType_t words
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
/// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
@@ -31,11 +32,17 @@ class StaticTask {
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram);
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
void destroy();
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
/// @return true if the task was deleted; false if it is still running on another core, in which case the
/// caller should try again later.
bool destroy();
/// @brief Delete the task (if running) and free the stack buffer.
void deallocate();
/// @brief Delete the task (if created) and free the stack buffer.
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
/// which case the caller should try again later.
bool deallocate();
protected:
TaskHandle_t handle_{nullptr};
+6 -3
View File
@@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
# to get a lost chunk ack through after the retransmit run seen in practice
DATA_PHASE_TIMEOUT = 160.0
_LOGGER = logging.getLogger(__name__)
@@ -694,8 +698,7 @@ def perform_ota(
_LOGGER.info("Handshake complete")
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
sock.settimeout(DATA_PHASE_TIMEOUT)
if extended_proto:
send_check(sock, ota_type, "ota type")
@@ -854,7 +857,7 @@ def run_ota_impl_(
# clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its
# 90s data timeout, which outlasts this budget; the retries target the
# 105s data timeout, which outlasts this budget; the retries target the
# common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = ""
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.24 ; used by noise (api, ota)
esphome/noise-c@0.1.26 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+1 -1
View File
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
tzdata>=2026.3 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.1
esptool==5.4.0
click==8.3.3
aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
+8
View File
@@ -1,3 +1,4 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
@@ -5,3 +6,10 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
manifest.to_code = to_code_testing
@@ -157,6 +157,74 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
noise_cipherstate_free(recv_cipher);
}
// Drive one full NNpsk0 handshake between a fresh initiator and responder;
// responder_e receives the ephemeral public key the responder put on the
// wire (the clear text start of its message, taken before the initiator
// consumes the buffer in place)
static void run_handshake(NoiseResponderHandshake &responder, uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE]) {
const psk_t psk = make_psk(7);
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GE(reply_len, SPARE_EPHEMERAL_KEY_SIZE);
std::memcpy(responder_e, msg, SPARE_EPHEMERAL_KEY_SIZE);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
}
TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) {
ASSERT_FALSE(has_spare_ephemeral());
NoiseResponderHandshake responder;
uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE];
run_handshake(responder, responder_e);
EXPECT_FALSE(has_spare_ephemeral());
}
TEST(SpareEphemeralTest, ConsumeHandsTheKeyToANewState) {
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
NoiseHandshakeState *state = nullptr;
ASSERT_EQ(noise_handshakestate_new_by_id(&state, &nid, NOISE_ROLE_RESPONDER), 0);
const psk_t psk = make_psk(7);
ASSERT_EQ(noise_handshakestate_set_pre_shared_key(state, psk.data(), psk.size()), 0);
ASSERT_EQ(noise_handshakestate_set_prologue(state, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(consume_spare_ephemeral(state), 0);
EXPECT_FALSE(has_spare_ephemeral());
noise_handshakestate_free(state);
}
TEST(SpareEphemeralTest, SlotKeyIsOnTheWireAndConsumedOnce) {
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
uint8_t expected_pub[SPARE_EPHEMERAL_KEY_SIZE];
std::memcpy(expected_pub, spare_ephemeral + SPARE_EPHEMERAL_KEY_SIZE, sizeof(expected_pub));
NoiseResponderHandshake first;
uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE];
run_handshake(first, responder_e);
// The spare, not a generated key, went out; and it went out once
EXPECT_EQ(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0);
EXPECT_FALSE(has_spare_ephemeral());
NoiseResponderHandshake second;
run_handshake(second, responder_e);
EXPECT_NE(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0);
EXPECT_FALSE(has_spare_ephemeral());
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
@@ -1,4 +0,0 @@
substitutions:
network_enable_ipv6: "true"
<<: !include common.yaml
@@ -1,17 +0,0 @@
esphome:
name: socket-set-sockaddr
on_boot:
then:
- lambda: |-
// 0 for text that is not an address, the length otherwise, broadcast included
struct sockaddr_storage addr;
auto *sa = reinterpret_cast<struct sockaddr *>(&addr);
ESP_LOGI("test", "SET_SOCKADDR invalid=%u valid=%u broadcast=%u",
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "not an address", 1234),
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "192.0.2.1", 1234),
(unsigned) socket::set_sockaddr(sa, sizeof(addr), "255.255.255.255", 1234));
host:
api:
logger:
level: INFO
@@ -1,40 +0,0 @@
"""Integration test for the socket::set_sockaddr failure contract."""
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_socket_set_sockaddr(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""set_sockaddr reports an invalid address with 0 and accepts broadcast."""
loop = asyncio.get_running_loop()
result: asyncio.Future[tuple[int, int, int]] = loop.create_future()
def on_log_line(line: str) -> None:
match = re.search(
r"SET_SOCKADDR invalid=(\d+) valid=(\d+) broadcast=(\d+)", line
)
if match and not result.done():
result.set_result(tuple(int(g) for g in match.groups()))
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
assert (await client.device_info()).name == "socket-set-sockaddr"
try:
invalid, valid, broadcast = await asyncio.wait_for(result, timeout=10.0)
except TimeoutError:
pytest.fail("SET_SOCKADDR marker never appeared")
assert invalid == 0
assert valid > 0
assert broadcast == valid
+17 -17
View File
@@ -35,8 +35,8 @@ def _load_script():
def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key."""
mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0"
)
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n"
"platform = fake/platform@1\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.26\n"
" ${common.lib_deps}\n"
" internal_lib\n"
"[env:b]\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.24\n"
" esphome/noise-c @ 0.1.26\n"
)
mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.24"]
assert libs == ["esphome/noise-c @ 0.1.26"]
assert platforms == ["fake/platform@1"]
assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [
"-l",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.26",
"-p",
"fake/platform@1",
]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install(
cls,
[
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 0.1.26",
"esphome/already @ 1.0",
"https://x/framework.tar.xz",
],
)
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
assert cls.calls == ["esphome/noise-c @ 0.1.26"]
assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.24"] is None
assert compats["esphome/noise-c @ 0.1.26"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"name": "vendored", "version": "https://github.com/x/y.git"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"})
cls.deps = {
"esphome/noise-c @ 0.1.24": [
"esphome/noise-c @ 0.1.26": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
+3
View File
@@ -416,6 +416,9 @@ def test_perform_ota_no_auth(
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
in caplog.text
)
# The data phase timeout must outlast the device's 105 s data timeout
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
assert espota2.DATA_PHASE_TIMEOUT > 105.0
@pytest.mark.usefixtures("mock_time")
+2 -2
View File
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1]
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec)))
)
pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"]