Merge pull request #15342 from esphome/bump-2026.3.2
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled

2026.3.2
This commit is contained in:
Jesse Hills
2026-04-02 09:06:55 +13:00
committed by GitHub
27 changed files with 563 additions and 234 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.3.1
PROJECT_NUMBER = 2026.3.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
@@ -60,6 +60,9 @@ ESPTime DateTimeEntity::state_as_esptime() const {
obj.year = this->year_;
obj.month = this->month_;
obj.day_of_month = this->day_;
obj.day_of_week = 0;
obj.day_of_year = 0;
obj.is_dst = false;
obj.hour = this->hour_;
obj.minute = this->minute_;
obj.second = this->second_;
@@ -70,6 +70,7 @@ template<typename... Ts> class BLECharacteristicSetValueAction : public Action<T
public:
BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {}
TEMPLATABLE_VALUE(std::vector<uint8_t>, buffer)
void set_buffer(std::initializer_list<uint8_t> buffer) { this->buffer_ = std::vector<uint8_t>(buffer); }
void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); }
void play(const Ts &...x) override {
// If the listener is already set, do nothing
@@ -115,6 +116,7 @@ template<typename... Ts> class BLEDescriptorSetValueAction : public Action<Ts...
public:
BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {}
TEMPLATABLE_VALUE(std::vector<uint8_t>, buffer)
void set_buffer(std::initializer_list<uint8_t> buffer) { this->buffer_ = std::vector<uint8_t>(buffer); }
void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); }
void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); }
@@ -82,12 +82,18 @@ void ESP32BLETracker::setup() {
#ifdef USE_OTA_STATE_LISTENER
void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->stop_scan();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
for (auto *client : this->clients_) {
client->disconnect();
}
#endif
} else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) {
this->scan_continuous_before_ota_ = false;
this->scan_continuous_ = true;
// Do not restart scanning immediately here; allow loop() to
// safely restart scanning once the scanner and all clients are idle.
}
}
#endif
@@ -429,6 +429,9 @@ class ESP32BLETracker : public Component,
ScannerState scanner_state_{ScannerState::IDLE};
bool scan_continuous_;
bool scan_active_;
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false};
#endif
bool ble_was_disabled_{true};
bool raw_advertisements_{false};
bool parse_advertisements_{false};
+51 -11
View File
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
import re
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -18,8 +19,9 @@ from esphome.const import (
PLATFORM_ESP8266,
ThreadModel,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.helpers import copy_file_if_changed
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
from .const import (
@@ -40,12 +42,42 @@ from .const import (
)
from .gpio import PinInitialState, add_pin_initial_states_array
CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float"
# Heuristically matches scanf/sscanf calls with float format specifiers.
# Standard scanf float conversions: %f %F %e %E %g %G %a %A
# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length)
# Also matches non-standard patterns like %.2f as a heuristic — these are
# invalid in scanf but users may write them by analogy with printf.
# Uses [^;]*? to stay within a single statement, preventing false positives
# from e.g. sscanf(buf, "%d", &x); printf("%f", val);
_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]")
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier.
Comments are stripped before matching to avoid false positives from
commented-out code. The cost of a false positive is only ~8KB flash.
"""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
src = obj.comment_remover(obj.value)
if _SCANF_FLOAT_RE.search(src):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def set_core_data(config):
CORE.data[KEY_ESP8266] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266
@@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SERIAL): cv.boolean,
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
}
),
set_core_data,
@@ -201,16 +234,23 @@ async def to_code(config):
cg.add_define("ESPHOME_VARIANT", "ESP8266")
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option(
"extra_scripts",
[
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:remove_float_scanf.py",
"post:post_build.py",
],
)
enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT)
if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config):
enable_scanf_float = True
_LOGGER.warning(
"Lambda uses scanf with a float format specifier; "
"enabling scanf float support (~8KB flash)"
)
extra_scripts = [
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
-1
View File
@@ -677,7 +677,6 @@ haier_protocol::HaierMessage HonClimate::get_control_message() {
this->quiet_mode_state_ = (SwitchState) ((uint8_t) this->quiet_mode_state_ & 0b01);
}
out_data->beeper_status = ((!this->get_beeper_state()) || (!has_hvac_settings)) ? 1 : 0;
control_out_buffer[4] = 0; // This byte should be cleared before setting values
out_data->display_status = this->get_display_state() ? 1 : 0;
this->display_status_ = (SwitchState) ((uint8_t) this->display_status_ & 0b01);
out_data->health_mode = this->get_health_mode() ? 1 : 0;
+137 -137
View File
@@ -597,173 +597,173 @@ void MixerSpeaker::audio_mixer_task(void *params) {
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING);
std::unique_ptr<audio::AudioSinkTransferBuffer> output_transfer_buffer = audio::AudioSinkTransferBuffer::create(
this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
{ // Ensure C++ objects fall out of scope to ensure proper cleanup before stopping the task
std::unique_ptr<audio::AudioSinkTransferBuffer> output_transfer_buffer = audio::AudioSinkTransferBuffer::create(
this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
if (output_transfer_buffer == nullptr) {
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM);
if (output_transfer_buffer == nullptr) {
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM);
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
}
output_transfer_buffer->set_sink(this_mixer->output_speaker_);
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING);
bool sent_finished = false;
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
FixedVector<SourceSpeaker *> speakers_with_data;
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
speakers_with_data.init(this_mixer->source_speakers_.size());
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
if (event_group_bits & MIXER_TASK_COMMAND_STOP) {
break;
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
}
// Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves
output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false);
output_transfer_buffer->set_sink(this_mixer->output_speaker_);
const uint32_t output_frames_free =
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING);
speakers_with_data.clear();
transfer_buffers_with_data.clear();
bool sent_finished = false;
for (auto &speaker : this_mixer->source_speakers_) {
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::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
if (transfer_buffer.use_count() == 0) {
// No transfer buffer allocated, so skip processing this speaker
continue;
}
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
FixedVector<SourceSpeaker *> speakers_with_data;
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
speakers_with_data.init(this_mixer->source_speakers_.size());
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
if (transfer_buffer->available() > 0) {
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
transfer_buffers_with_data.push_back(transfer_buffer);
speakers_with_data.push_back(speaker);
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
if (event_group_bits & MIXER_TASK_COMMAND_STOP) {
break;
}
// Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves
output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false);
const uint32_t output_frames_free =
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
speakers_with_data.clear();
transfer_buffers_with_data.clear();
for (auto &speaker : this_mixer->source_speakers_) {
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::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
if (transfer_buffer.use_count() == 0) {
// No transfer buffer allocated, so skip processing this speaker
continue;
}
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
if (transfer_buffer->available() > 0) {
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
transfer_buffers_with_data.push_back(transfer_buffer);
speakers_with_data.push_back(speaker);
}
}
}
}
if (transfer_buffers_with_data.empty()) {
// No audio available for transferring, block task temporarily
delay(TASK_DELAY_MS);
continue;
}
if (transfer_buffers_with_data.empty()) {
// No audio available for transferring, block task temporarily
delay(TASK_DELAY_MS);
continue;
}
uint32_t frames_to_mix = output_frames_free;
uint32_t frames_to_mix = output_frames_free;
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
if (active_stream_info.get_sample_rate() ==
this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) {
// Speaker's sample rate matches the output speaker's, copy directly
if (active_stream_info.get_sample_rate() ==
this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) {
// Speaker's sample rate matches the output speaker's, copy directly
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()), active_stream_info,
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()),
active_stream_info, reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
// Set playback delay for newly contributing source
if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[0]->playback_delay_frames_.store(
this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release);
speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release);
// Set playback delay for newly contributing source
if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[0]->playback_delay_frames_.store(
this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release);
speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release);
}
// Update source speaker pending frames
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
// Update output transfer buffer length and pipeline frame count
output_transfer_buffer->increase_buffer_length(
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
} else {
// Speaker's stream info doesn't match the output speaker's, so it's a new source speaker
if (!this_mixer->output_speaker_->is_stopped()) {
if (!sent_finished) {
this_mixer->output_speaker_->finish();
sent_finished = true; // Avoid repeatedly sending the finish command
}
} else {
// Speaker has finished writing the current audio, update the stream information and restart the speaker
this_mixer->audio_stream_info_ =
audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_,
active_stream_info.get_sample_rate());
this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value());
this_mixer->output_speaker_->start();
// Reset pipeline frame count since we're starting fresh with a new sample rate
this_mixer->frames_in_pipeline_.store(0, std::memory_order_release);
sent_finished = false;
}
}
} else {
// Determine how many frames to mix
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(
transfer_buffers_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
if (i != transfer_buffers_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
}
}
// Update source speaker pending frames
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
// Get current pipeline depth for delay calculation (before incrementing)
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
// Update output transfer buffer length and pipeline frame count
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
// Set playback delay for newly contributing sources
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release);
}
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[i]->decrease_buffer_length(
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
}
// Update output transfer buffer length and pipeline frame count (once, not per source)
output_transfer_buffer->increase_buffer_length(
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
} else {
// Speaker's stream info doesn't match the output speaker's, so it's a new source speaker
if (!this_mixer->output_speaker_->is_stopped()) {
if (!sent_finished) {
this_mixer->output_speaker_->finish();
sent_finished = true; // Avoid repeatedly sending the finish command
}
} else {
// Speaker has finished writing the current audio, update the stream information and restart the speaker
this_mixer->audio_stream_info_ =
audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_,
active_stream_info.get_sample_rate());
this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value());
this_mixer->output_speaker_->start();
// Reset pipeline frame count since we're starting fresh with a new sample rate
this_mixer->frames_in_pipeline_.store(0, std::memory_order_release);
sent_finished = false;
}
}
} else {
// Determine how many frames to mix
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer =
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(transfer_buffers_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
if (i != transfer_buffers_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
}
}
// Get current pipeline depth for delay calculation (before incrementing)
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
// Set playback delay for newly contributing sources
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release);
}
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
transfer_buffers_with_data[i]->decrease_buffer_length(
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
}
// Update output transfer buffer length and pipeline frame count (once, not per source)
output_transfer_buffer->increase_buffer_length(
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
}
}
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING);
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING);
}
// Reset pipeline frame count since the task is stopping
this_mixer->frames_in_pipeline_.store(0, std::memory_order_release);
output_transfer_buffer.reset();
xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED);
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
+23 -16
View File
@@ -44,20 +44,27 @@ def validate_sensors(config):
return config
GAS_SENSOR = cv.Schema(
{
cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(
{
cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_,
cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_,
cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_,
cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_,
cv.Optional(CONF_STD_INITIAL, default=50): cv.int_,
cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_,
}
)
}
)
def _gas_sensor_schema(index_offset_default: int):
return cv.Schema(
{
cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(
{
cv.Optional(
CONF_INDEX_OFFSET, default=index_offset_default
): cv.int_,
cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_,
cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_,
cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_,
cv.Optional(CONF_STD_INITIAL, default=50): cv.int_,
cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_,
}
)
}
)
VOC_SENSOR = _gas_sensor_schema(100)
NOX_SENSOR = _gas_sensor_schema(1)
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -68,13 +75,13 @@ CONFIG_SCHEMA = cv.All(
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(GAS_SENSOR),
).extend(VOC_SENSOR),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(GAS_SENSOR),
).extend(NOX_SENSOR),
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t,
cv.Optional(CONF_COMPENSATION): cv.Schema(
+6 -2
View File
@@ -38,14 +38,18 @@ void SX127x::write_register_(uint8_t reg, uint8_t value) {
void SX127x::read_fifo_(std::vector<uint8_t> &packet) {
this->enable();
this->write_byte(REG_FIFO & 0x7F);
this->read_array(packet.data(), packet.size());
for (auto &byte : packet) {
byte = this->transfer_byte(0x00);
}
this->disable();
}
void SX127x::write_fifo_(const std::vector<uint8_t> &packet) {
this->enable();
this->write_byte(REG_FIFO | 0x80);
this->write_array(packet.data(), packet.size());
for (const auto &byte : packet) {
this->transfer_byte(byte);
}
this->disable();
}
@@ -606,6 +606,16 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu
}
void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) {
// Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off,
// even if supplemental_action_ is already idle (early-return path). This prevents a stale
// heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle
// when HEATING_MAX_RUN_TIME fires while the main action is already IDLE.
if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) {
this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME);
this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME);
this->cooling_max_runtime_exceeded_ = false;
this->heating_max_runtime_exceeded_ = false;
}
// setup_complete_ helps us ensure an action is called immediately after boot
if ((action == this->supplemental_action_) && this->setup_complete_) {
// already in target mode
@@ -975,8 +985,10 @@ void ThermostatClimate::cooling_on_timer_callback_() {
void ThermostatClimate::fan_mode_timer_callback_() {
ESP_LOGVV(TAG, "fan_mode timer expired");
this->switch_to_fan_mode_(this->fan_mode.value_or(climate::CLIMATE_FAN_ON));
if (this->supports_fan_only_action_uses_fan_mode_timer_)
if (this->supports_fan_only_action_uses_fan_mode_timer_) {
this->switch_to_action_(this->compute_action_());
this->switch_to_supplemental_action_(this->compute_supplemental_action_());
}
}
void ThermostatClimate::fanning_off_timer_callback_() {
+11 -1
View File
@@ -284,13 +284,23 @@ def validate_tz(value: str) -> str:
tzfile = _load_tzdata(value)
if tzfile is not None:
value = _extract_tz_string(tzfile)
is_iana = True
else:
is_iana = False
# Validate that the POSIX TZ string is parseable (skip empty strings)
if value:
try:
parse_posix_tz_python(value)
except ValueError as e:
raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e
if is_iana:
raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e
raise cv.Invalid(
f"Invalid POSIX timezone string '{value}': {e}. "
f"If you meant to use an IANA timezone, check the list of valid "
f"timezones at "
f"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
) from e
return value
+49 -18
View File
@@ -9,6 +9,10 @@ namespace tormatic {
static const char *const TAG = "tormatic.cover";
// Time to poll the UART when flushing after desync. At 9600 baud, a full
// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived.
static constexpr uint32_t DRAIN_TIMEOUT_MS = 15;
using namespace esphome::cover;
void Tormatic::setup() {
@@ -255,32 +259,51 @@ void Tormatic::stop_at_target_() {
// Read a GateStatus from the unit. The unit only sends messages in response to
// status requests or commands, so a message needs to be sent first.
optional<GateStatus> Tormatic::read_gate_status_() {
if (this->available() < sizeof(MessageHeader)) {
if (!this->pending_hdr_) {
if (this->available() < sizeof(MessageHeader)) {
return {};
}
this->pending_hdr_ = this->read_data_<MessageHeader>();
if (!this->pending_hdr_) {
return {};
}
// Sanity check: valid messages have small payloads (3-4 bytes). A large
// or impossible payload_size means the stream is out of sync (corrupted
// byte, dropped data, etc.). Flush the buffer so we can resync on the
// next request/response cycle.
if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) {
ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size());
this->pending_hdr_.reset();
this->drain_rx_();
return {};
}
}
// Wait for all payload bytes to arrive before processing.
if (this->available() < this->pending_hdr_->payload_size()) {
return {};
}
auto o_hdr = this->read_data_<MessageHeader>();
if (!o_hdr) {
ESP_LOGE(TAG, "Timeout reading message header");
return {};
}
auto hdr = o_hdr.value();
auto hdr = *this->pending_hdr_;
this->pending_hdr_.reset();
switch (hdr.type) {
case STATUS: {
if (hdr.payload_size() != sizeof(StatusReply)) {
ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(),
sizeof(StatusReply));
this->drain_rx_(hdr.payload_size());
return {};
}
// Read a StatusReply requested by update().
auto o_status = this->read_data_<StatusReply>();
if (!o_status) {
return {};
}
auto status = o_status.value();
return status.state;
return o_status->state;
}
case COMMAND:
@@ -343,16 +366,24 @@ template<typename T> optional<T> Tormatic::read_data_() {
return obj;
}
// Drain up to n amount of bytes from the uart rx buffer.
// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes
// (caller must ensure they are available). When n == 0, poll for 15ms to
// guarantee a full packet time at 9600 baud has elapsed, consuming any
// bytes still in transit.
void Tormatic::drain_rx_(uint16_t n) {
uint8_t data;
uint16_t count = 0;
while (this->available()) {
this->read_byte(&data);
count++;
if (n > 0 && count >= n) {
return;
if (n > 0) {
for (uint16_t i = 0; i < n; i++) {
if (!this->read_byte(&data)) {
return;
}
}
} else {
uint32_t start = millis();
while (millis() - start < DRAIN_TIMEOUT_MS) {
if (this->available()) {
this->read_byte(&data);
}
}
}
}
@@ -43,6 +43,7 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom
void handle_gate_status_(GateStatus s);
uint32_t seq_tx_{0};
optional<MessageHeader> pending_hdr_{};
GateStatus current_status_{PAUSED};
@@ -147,6 +147,20 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
// uart_param_config must be called after uart_driver_install and before any
// other uart_set_*() calls. The driver installation resets the UART peripheral
// registers to their default state, overwriting any previously configured baud
// rate or framing settings. Calling uart_param_config here ensures the requested
// settings are applied after the reset and before pin routing, inversion, and
// threshold configuration.
uart_config_t uart_config = this->get_config_();
err = uart_param_config(this->uart_num_, &uart_config);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err));
this->mark_failed();
return;
}
int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;
int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1;
int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1;
@@ -214,22 +228,15 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
// Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install().
auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART;
err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install()
err = uart_set_mode(this->uart_num_, mode);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err));
this->mark_failed();
return;
}
uart_config_t uart_config = this->get_config_();
err = uart_param_config(this->uart_num_, &uart_config);
if (err != ESP_OK) {
ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err));
this->mark_failed();
return;
}
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Register ISR callback to wake the main loop when UART data arrives.
// The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to
@@ -324,6 +331,9 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) {
}
bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
if (len == 0) {
return false;
}
size_t length_to_read = len;
int32_t read_len = 0;
if (!this->check_read_timeout_(len))
@@ -331,11 +341,10 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) {
if (this->has_peek_) {
length_to_read--;
*data = this->peek_byte_;
data++;
this->has_peek_ = false;
}
if (length_to_read > 0)
read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS);
read_len = uart_read_bytes(this->uart_num_, data + (len - length_to_read), length_to_read, 20 / portTICK_PERIOD_MS);
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(UART_DIRECTION_RX, data[i]);
@@ -235,16 +235,14 @@ bool HostUartComponent::read_array(uint8_t *data, size_t len) {
}
if (!this->check_read_timeout_(len))
return false;
uint8_t *data_ptr = data;
size_t length_to_read = len;
if (this->has_peek_) {
length_to_read--;
*data_ptr = this->peek_byte_;
data_ptr++;
*data = this->peek_byte_;
this->has_peek_ = false;
}
if (length_to_read > 0) {
int sz = ::read(this->file_descriptor_, data_ptr, length_to_read);
int sz = ::read(this->file_descriptor_, data + (len - length_to_read), length_to_read);
if (sz == -1) {
this->update_error_(strerror(errno));
return false;
+66 -19
View File
@@ -269,11 +269,11 @@ bool CompactString::operator==(const StringRef &other) const {
/// │ │ │
/// │ ┌──────────────┼──────────────┐ │
/// │ ↓ ↓ ↓ │
/// │ scan error no better AP +10 dB better AP │
/// │ disconnect no better AP +10 dB better AP │
/// │ │ │ │ │
/// │ ↓ ↓ ↓ │
/// │ ┌──────────────────────────────┐ ┌──────────────────────────┐ │
/// │ │ → IDLE │ │ CONNECTING │ │
/// │ │ → RECONNECTING │ │ CONNECTING │ │
/// │ │ (counter preserved) │ │ (process_roaming_scan_) │ │
/// │ └──────────────────────────────┘ └────────────┬─────────────┘ │
/// │ │ │
@@ -287,18 +287,25 @@ bool CompactString::operator==(const StringRef &other) const {
/// │ │ (counter reset to 0) │ │ (retry_connect called) │
/// │ └──────────────────────────────────┘ └───────────┬─────────────┘
/// │ │ │
/// │
/// │ ┌───────────────────────┐
/// │ │ → IDLE │
/// │ │ (counter preserved!) │
/// │ └───────────────────────┘
/// │ ┌─────────┴─────────┐
/// │ ↓ ↓
/// │ on target BSSID on other AP
/// │
/// │ ↓ ↓
/// │ ┌──────────────────┐ ┌────────────┐│
/// │ │ → IDLE │ │ → IDLE ││
/// │ │ (counter reset) │ │ (counter ││
/// │ │ (roam worked!) │ │ preserved)││
/// │ └──────────────────┘ └────────────┘│
/// │ │
/// │ Key behaviors: │
/// │ - After 3 checks: attempts >= 3, stop checking │
/// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │
/// │ - Scan error (SCANNING→IDLE): counter preserved
/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved
/// │ - Disconnect after scan (within grace period): counter preserved │
/// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │
/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong)
/// │ - Roaming success via retry (on target BSSID): counter reset
/// │ - Roaming fail (RECONNECTING on other AP): counter preserved │
/// └──────────────────────────────────────────────────────────────────────┘
// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
@@ -1583,17 +1590,33 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
// Only preserve attempts if reconnecting after a failed roam attempt
// This prevents ping-pong between APs when a roam target is unreachable
if (this->roaming_state_ == RoamingState::CONNECTING) {
// Successful roam to better AP - reset attempts so we can roam again later
// Successful roam to better AP on first try - reset attempts so we can roam again later
ESP_LOGD(TAG, "Roam successful");
this->roaming_attempts_ = 0;
} else if (this->roaming_state_ == RoamingState::RECONNECTING) {
// Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong
ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
// Check if we ended up on the roam target despite needing a retry
// (e.g., first connect failed but scan-based retry found and connected to the same better AP)
bssid_t current_bssid = this->wifi_bssid();
if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) {
char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(current_bssid.data(), bssid_buf);
ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS,
bssid_buf);
this->roaming_attempts_ = 0;
} else if (this->roaming_target_bssid_ != bssid_t{}) {
// Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong
ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
} else {
// Reconnected after scan-induced disconnect (no roam target) - keep attempts
ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
}
} else {
// Normal connection (boot, credentials changed, etc.)
this->roaming_attempts_ = 0;
}
this->roaming_state_ = RoamingState::IDLE;
this->roaming_target_bssid_ = {};
this->roaming_scan_end_ = 0;
// Clear all priority penalties - the next reconnect will happen when an AP disconnects,
// which means the landscape has likely changed and previous tracked failures are stale
@@ -2075,12 +2098,21 @@ void WiFiComponent::retry_connect() {
ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::RECONNECTING;
} else if (this->roaming_state_ == RoamingState::SCANNING) {
// Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter
ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::IDLE;
// Disconnected during roam scan - transition to RECONNECTING so the attempts
// counter is preserved when reconnection succeeds (IDLE would reset it)
ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::RECONNECTING;
} else if (this->roaming_state_ == RoamingState::IDLE) {
// Not a roaming-triggered reconnect, reset state
this->clear_roaming_state_();
// Check if a roaming scan recently completed - on ESP8266, going off-channel
// during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes.
// Transition to RECONNECTING so the attempts counter is preserved on reconnect.
if (this->roaming_scan_end_ != 0 && millis() - this->roaming_scan_end_ < ROAMING_SCAN_GRACE_PERIOD) {
ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::RECONNECTING;
} else {
// Not a roaming-triggered reconnect, reset state
this->clear_roaming_state_();
}
}
// RECONNECTING: keep state and counter, still trying to reconnect
@@ -2197,6 +2229,14 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP &params) {
params.set_hidden(false);
ESP_LOGD(TAG, "Loaded fast_connect settings");
#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) ||
(this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) {
ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel);
this->selected_sta_index_ = -1;
return false;
}
#endif
return true;
}
@@ -2315,6 +2355,8 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->
void WiFiComponent::clear_roaming_state_() {
this->roaming_attempts_ = 0;
this->roaming_last_check_ = 0;
this->roaming_scan_end_ = 0;
this->roaming_target_bssid_ = {};
this->roaming_state_ = RoamingState::IDLE;
}
@@ -2382,7 +2424,7 @@ void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: skip scan if signal is already good (no meaningful improvement possible)
int8_t rssi = this->wifi_rssi();
if (rssi > ROAMING_GOOD_RSSI) {
ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
ROAMING_MAX_ATTEMPTS);
return;
}
@@ -2396,6 +2438,9 @@ void WiFiComponent::process_roaming_scan_() {
this->scan_done_ = false;
// Default to IDLE - will be set to CONNECTING if we find a better AP
this->roaming_state_ = RoamingState::IDLE;
// Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout)
// can be attributed to the scan and avoid resetting the attempts counter
this->roaming_scan_end_ = millis();
// Get current connection info
int8_t current_rssi = this->wifi_rssi();
@@ -2444,10 +2489,12 @@ void WiFiComponent::process_roaming_scan_() {
WiFiAP roam_params = *selected;
apply_scan_result_to_params(roam_params, *best);
this->release_scan_results_();
// Mark as roaming attempt - affects retry behavior if connection fails
this->roaming_state_ = RoamingState::CONNECTING;
this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results
this->release_scan_results_();
// Connect directly - wifi_sta_connect_ handles disconnect internally
this->start_connecting(roam_params);
+8
View File
@@ -774,11 +774,17 @@ class WiFiComponent : public Component {
SemaphoreHandle_t high_performance_semaphore_{nullptr};
#endif
static constexpr uint8_t FIRST_5GHZ_CHANNEL = 36;
// Post-connect roaming constants
static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB
static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent
static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3;
// Grace period after roaming scan completes. If WiFi disconnects within this
// window (e.g., ESP8266 Beacon Timeout caused by going off-channel during scan),
// the disconnect is treated as roaming-related and the attempts counter is preserved.
static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds
// 4-byte members
float output_power_{NAN};
@@ -786,6 +792,7 @@ class WiFiComponent : public Component {
uint32_t last_connected_{0};
uint32_t reboot_timeout_{};
uint32_t roaming_last_check_{0};
uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed
#ifdef USE_WIFI_AP
uint32_t ap_timeout_{};
#endif
@@ -810,6 +817,7 @@ class WiFiComponent : public Component {
bool error_from_callback_{false};
RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY};
RoamingState roaming_state_{RoamingState::IDLE};
bssid_t roaming_target_bssid_{}; // BSSID of the AP we're trying to roam to
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
#endif
@@ -664,11 +664,22 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
config.show_hidden = 1;
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE;
// Use shorter dwell times for roaming scans - we only need to detect strong
// nearby APs, not do a thorough survey. This also reduces off-channel time
// which can cause Beacon Timeout disconnects on some APs.
// Roaming times match the ESP32 IDF scan defaults.
static constexpr uint32_t SCAN_PASSIVE_DEFAULT_MS = 500;
static constexpr uint32_t SCAN_PASSIVE_ROAMING_MS = 300;
static constexpr uint32_t SCAN_ACTIVE_MIN_DEFAULT_MS = 400;
static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500;
static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100;
static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300;
bool roaming = this->roaming_state_ == RoamingState::SCANNING;
if (passive) {
config.scan_time.passive = 500;
config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS;
} else {
config.scan_time.active.min = 400;
config.scan_time.active.max = 500;
config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS;
config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS;
}
#endif
bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback);
@@ -961,6 +961,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
config.scan_time.active.min = 100;
config.scan_time.active.max = 300;
}
// When scanning while connected (roaming), return to home channel between
// each scanned channel to maintain the connection (helps with BLE/WiFi coexistence)
if (this->roaming_state_ == RoamingState::SCANNING) {
config.coex_background_scan = true;
}
esp_err_t err = esp_wifi_scan_start(&config, false);
if (err != ESP_OK) {
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.3.1"
__version__ = "2026.3.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+1 -1
View File
@@ -231,7 +231,7 @@ void ESPTime::increment_day() {
void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
time_t res = 0;
if (!this->fields_in_range()) {
if (!this->fields_in_range(false, use_day_of_year)) {
this->timestamp = -1;
return;
}
+13 -5
View File
@@ -79,11 +79,19 @@ struct ESPTime {
/// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019)
bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); }
/// Check if all time fields of this ESPTime are in range.
bool fields_in_range() const {
return this->second < 61 && this->minute < 60 && this->hour < 24 && this->day_of_week > 0 &&
this->day_of_week < 8 && this->day_of_year > 0 && this->day_of_year < 367 && this->month > 0 &&
this->month < 13 && this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year);
/// Check if time fields are in range.
/// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields)
/// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields)
bool fields_in_range(bool check_day_of_week = true, bool check_day_of_year = true) const {
bool valid = this->second < 61 && this->minute < 60 && this->hour < 24 && this->month > 0 && this->month < 13 &&
this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year);
if (check_day_of_week) {
valid = valid && this->day_of_week > 0 && this->day_of_week < 8;
}
if (check_day_of_year) {
valid = valid && this->day_of_year > 0 && this->day_of_year < 367;
}
return valid;
}
/** Convert a string to ESPTime struct as specified by the format argument.
@@ -69,3 +69,11 @@ esp32_ble_server:
- ble_server.descriptor.set_value:
id: test_change_descriptor
value: !lambda return bytebuffer::ByteBuffer::wrap({0x03, 0x04, 0x05}).get_data();
- ble_server.characteristic.set_value:
id: test_change_characteristic
value:
data: [0xfc, 0xef, 0xfe, 0x86]
- ble_server.descriptor.set_value:
id: test_change_descriptor
value:
data: [0x01, 0x02, 0x03]
@@ -14,3 +14,6 @@ esphome:
assert(x == 95);
x = clamp_at_most(x, 40);
assert(x == 40);
- lambda: |-
float value = 0.0f;
sscanf("3.14", "%f", &value);
+54 -2
View File
@@ -1036,8 +1036,6 @@ static time_t esptime_recalc_local(int year, int month, int day, int hour, int m
t.hour = hour;
t.minute = min;
t.second = sec;
t.day_of_week = 1; // Placeholder for fields_in_range()
t.day_of_year = 1;
t.recalc_timestamp_local();
return t.timestamp;
}
@@ -1187,6 +1185,60 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) {
EXPECT_EQ(esp_result, libc_result);
}
TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) {
// Regression test for issue #15115: DateTimeEntity::state_as_esptime() constructs
// an ESPTime with only year/month/day/hour/minute/second set (no day_of_week or
// day_of_year). recalc_timestamp_local() must work without those fields.
const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0";
setenv("TZ", tz_str, 1);
tzset();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
// Construct ESPTime with only date/time fields (like state_as_esptime does)
ESPTime t{};
t.year = 2026;
t.month = 3;
t.day_of_month = 20;
t.hour = 23;
t.minute = 14;
t.second = 55;
// day_of_week and day_of_year are deliberately left as 0
t.recalc_timestamp_local();
// Must NOT return -1 (the bug: fields_in_range() rejected valid times)
EXPECT_NE(t.timestamp, -1);
// Verify against libc
time_t libc_result = libc_mktime(2026, 3, 20, 23, 14, 55);
EXPECT_EQ(t.timestamp, libc_result);
}
TEST(RecalcTimestampLocal, MinimalFieldsNoDST) {
// Same test but with a timezone that has no DST
const char *tz_str = "IST-5:30";
setenv("TZ", tz_str, 1);
tzset();
time::ParsedTimezone tz{};
ASSERT_TRUE(parse_posix_tz(tz_str, tz));
set_global_tz(tz);
ESPTime t{};
t.year = 2026;
t.month = 3;
t.day_of_month = 23;
t.hour = 10;
t.minute = 0;
t.second = 0;
t.recalc_timestamp_local();
EXPECT_NE(t.timestamp, -1);
time_t libc_result = libc_mktime(2026, 3, 23, 10, 0, 0);
EXPECT_EQ(t.timestamp, libc_result);
}
TEST(RecalcTimestampLocal, YearBoundaryDST) {
// Test southern hemisphere DST across year boundary
// Australia/Sydney: DST active from October to April (spans Jan 1)
@@ -0,0 +1,62 @@
"""Tests for ESP8266 component."""
import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float
from esphome.core import Lambda
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats
('sscanf(buf, "%d,%f", &a, &b)', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True