Compare commits

..

50 Commits

Author SHA1 Message Date
Jesse Hills b7dabe236e Merge pull request #15342 from esphome/bump-2026.3.2
2026.3.2
2026-04-02 09:06:55 +13:00
Jesse Hills 600ca01fd3 Bump version to 2026.3.2 2026-04-01 13:18:24 +13:00
J. Nick Koston 65051153ac [esp32_ble_tracker] Restart BLE scan after OTA failure (#15308)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-01 13:18:24 +13:00
Kevin Ahrendt 514c0c8331 [mixer] Fix memory leak in mixer task on stop/start cycles (#15185) 2026-04-01 13:18:24 +13:00
Edward Firmo dc634b8c7b [uart] fix baud rate not applied on load_settings() for ESP32 (IDF) (#15341) 2026-04-01 13:18:24 +13:00
Jonathan Swoboda 66a4acafd0 [tormatic] Fix UART stream desync on ESP32 (#15337) 2026-04-01 13:18:24 +13:00
Jonathan Swoboda 3bf45d8fe0 [haier] Fix hOn half-degree temperature setting (#15312) 2026-04-01 13:18:24 +13:00
Keith Burzinski 9cd7c5e700 [thermostat] Fix stale max_runtime_exceeded causing spurious supplemental heating/cooling (#15274) 2026-04-01 13:18:24 +13:00
J. Nick Koston d79cf1d718 [esp8266] Add enable_scanf_float option (#15284) 2026-04-01 13:18:24 +13:00
J. Nick Koston 3d8a3a91f2 [esp32_ble_server] Fix set_value action with static data lists (#15285) 2026-04-01 13:18:24 +13:00
Jonathan Swoboda 3fd3dcc7e5 [sgp4x] Fix NOx index_offset default (should be 1, not 100) (#15212) 2026-04-01 13:18:23 +13:00
Jonathan Swoboda 7b5a4b466a [uart] Fix debug callback missing peeked byte and reading past end (#15169) 2026-04-01 13:18:23 +13:00
Jonathan Swoboda 92642df419 [wifi] Filter fast_connect by band_mode and use background scan for roaming (#15152) 2026-04-01 13:18:23 +13:00
J. Nick Koston f5f99071fb [wifi] Fix roaming counter reset from delayed disconnect and successful retry (#15126) 2026-04-01 13:18:23 +13:00
J. Nick Koston cb15e98765 [datetime] Fix state_as_esptime() returning invalid timestamp (#15128) 2026-04-01 13:18:23 +13:00
Jonathan Swoboda 2f2c7ac393 [sx127x] Fix FIFO read corruption (#15114) 2026-04-01 13:18:23 +13:00
J. Nick Koston d9788aaefc [wifi] Reduce ESP8266 roaming scan dwell time to match ESP32 (#15127) 2026-04-01 13:18:23 +13:00
J. Nick Koston f7b410fd0c [wifi] Fix roaming attempt counter reset on disconnect during scan (#15099) 2026-04-01 13:18:23 +13:00
J. Nick Koston e261b5de65 [time] Point to valid IANA timezone list on validation failure (#15110) 2026-04-01 13:18:23 +13:00
Jesse Hills 7ecdf6db2e Merge pull request #15084 from esphome/bump-2026.3.1
2026.3.1
2026-03-23 16:09:32 +13:00
J. Nick Koston 83d02c602a [logger] Fix dummy_main.cpp Logger constructor for clang-tidy (#15088)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:25:04 +13:00
J. Nick Koston 6d16c57747 [sht4x] Add missing hal.h include for millis() on ESP-IDF (#15087) 2026-03-23 11:23:21 +13:00
J. Nick Koston 45c0e6ef7f [logger] Fix unit test Logger constructor call (#15086) 2026-03-23 09:52:46 +13:00
Jesse Hills 320474b62d Bump version to 2026.3.1 2026-03-23 09:28:58 +13:00
Jason Kölker a3c483edf3 [pmsx003] Keep active-mode reads aligned (#14832) 2026-03-23 09:28:58 +13:00
J. Nick Koston 036be63f7b [logger] Fix race condition in task log buffer initialization (#15071)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-03-23 09:28:58 +13:00
Jonathan Swoboda bbfe324dd6 [ultrasonic] Fix ISR edge detection with debounce and trigger filtering (#15014) 2026-03-23 09:28:57 +13:00
J. Nick Koston de3292c828 [light] Fix gamma LUT quantizing small brightness to zero (#15060) 2026-03-23 09:28:57 +13:00
J. Nick Koston 67ab2e143c [uart] Fix RTL87xx compilation failure due to SUCCESS macro collision (#15054) 2026-03-23 09:28:57 +13:00
J. Nick Koston 9abc112f76 [sht4x] Fix heater causing measurement jitter (#15030) 2026-03-23 09:28:50 +13:00
J. Nick Koston b5880df93c [light] Fix constant_brightness broken by gamma LUT refactor (#15048)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 09:27:59 +13:00
J. Nick Koston 2352c732de [mqtt] Rate-limit component resends to prevent task WDT on reconnect (#15061) 2026-03-23 09:27:59 +13:00
Samuel Sieb 77264de3f6 [analog_threshhold] add missing header (#15058) 2026-03-23 09:27:59 +13:00
J. Nick Koston 42da281854 [time] Fix timezone_offset() and recalc_timestamp_local() always returning UTC (#14996) 2026-03-23 09:27:59 +13:00
J. Nick Koston 06cc5a29a7 [core] Add copy() method to StringRef for std::string compatibility (#15028) 2026-03-23 09:27:59 +13:00
J. Nick Koston 98b4e1ea15 [web_server] Increase httpd task stack size to prevent stack overflow (#14997)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 09:27:59 +13:00
Jonathan Swoboda 0bf6e1e839 [esp32_touch] Fix initial state never published when sensor untouched (#15032) 2026-03-23 09:27:59 +13:00
J. Nick Koston 3fe84eadef [wifi] Fix ESP8266 power_save_mode mapping (LIGHT/HIGH were swapped) (#15029) 2026-03-23 09:27:59 +13:00
J. Nick Koston 12eed0d384 [api] Increase noise handshake timeout to 60s for slow WiFi environments (#15022) 2026-03-23 09:27:59 +13:00
dependabot[bot] 28e8250b69 Bump aioesphomeapi from 44.6.1 to 44.6.2 (#15027)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 09:27:59 +13:00
Keith Roehrenbeck 0297260a57 [ld2450] Fix zone target counts including untracked ghost targets (#15026) 2026-03-23 09:27:59 +13:00
J. Nick Koston d4f7cb984c [uart] Fix UART0 default pin IOMUX loopback on ESP32 (#14978) 2026-03-23 09:27:59 +13:00
Clyde Stubbs 08187a01b1 [sdl] Fix get_width()/height() when rotation used (#14950) 2026-03-23 09:27:59 +13:00
J. Nick Koston daf3502e15 [logger] Fix ESP8266 crash with VERY_VERBOSE log level (#14980) 2026-03-23 09:27:59 +13:00
J. Nick Koston 08cab43548 [time] Fix lookup of top-level IANA timezone keys like UTC and GMT (#14952) 2026-03-23 09:27:59 +13:00
dependabot[bot] 5cbe936256 Bump aioesphomeapi from 44.6.0 to 44.6.1 (#14954)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 09:27:59 +13:00
Jonathan Swoboda 729d3d4bc2 [openthread] Guard InstanceLock against uninitialized semaphore (#14940)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:27:58 +13:00
Jonathan Swoboda 8af0991590 [ble_client] Fix RSSI sensor reporting same value for all clients (#14939)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:27:58 +13:00
J. Nick Koston 99d968f80a [http_request] Prevent double update task launch (#14910) 2026-03-23 09:27:58 +13:00
dependabot[bot] 705d548435 Bump aioesphomeapi from 44.5.2 to 44.6.0 (#14927)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 09:27:58 +13:00
65 changed files with 1312 additions and 367 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.0
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
@@ -1,5 +1,6 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/sensor/sensor.h"
+5 -1
View File
@@ -64,7 +64,11 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS *
// A stalled handshake from a buggy client or network glitch holds a connection
// slot, which can prevent legitimate clients from reconnecting. Also hardens
// against the less likely case of intentional connection slot exhaustion.
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000;
//
// 60s is intentionally high: on ESP8266 with power_save_mode: LIGHT and weak
// WiFi (-70 dBm+), TCP retransmissions push real-world handshake times to
// 28-30s. See https://github.com/esphome/esphome/issues/14999
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000;
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
@@ -47,6 +47,8 @@ void BLEClientRSSISensor::gap_event_handler(esp_gap_ble_cb_event_t event, esp_bl
switch (event) {
// server response on RSSI request:
case ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT:
if (!this->parent()->check_addr(param->read_rssi_cmpl.remote_addr))
return;
if (param->read_rssi_cmpl.status == ESP_BT_STATUS_SUCCESS) {
int8_t rssi = param->read_rssi_cmpl.rssi;
ESP_LOGI(TAG, "ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT RSSI: %d", rssi);
@@ -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};
@@ -360,11 +360,16 @@ void ESP32TouchComponent::loop() {
}
// Publish initial OFF state for sensors that haven't received events yet
bool all_initial_published = true;
for (auto *child : this->children_) {
this->publish_initial_state_if_needed_(child, now);
if (!child->initial_state_published_) {
all_initial_published = false;
}
}
if (!this->setup_mode_) {
// Only disable loop once all initial states are published
if (!this->setup_mode_ && all_initial_published) {
this->disable_loop();
}
}
+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;
@@ -74,6 +74,10 @@ void HttpRequestUpdate::update() {
}
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
#ifdef USE_ESP32
if (this->update_task_handle_ != nullptr) {
ESP_LOGW(TAG, "Update check already in progress");
return;
}
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#else
this->update_task(this);
@@ -204,6 +208,9 @@ defer:
// both success and error paths to avoid multiple std::function instantiations.
// Lambda captures only 2 pointers (8 bytes) — fits in std::function SBO on supported toolchains.
this_update->defer([this_update, result]() {
#ifdef USE_ESP32
this_update->update_task_handle_ = nullptr;
#endif
if (result->error_str != nullptr) {
this_update->status_set_error(result->error_str);
delete result;
+5 -4
View File
@@ -534,10 +534,11 @@ void LD2450Component::handle_periodic_data_() {
}
#endif
// Store target info for zone target count
this->target_info_[index].x = tx;
this->target_info_[index].y = ty;
this->target_info_[index].is_moving = is_moving;
// Store target info for zone target count. Zero out untracked targets (td==0)
// so stale coordinates don't produce ghost counts in count_targets_in_zone_().
this->target_info_[index].x = (td > 0) ? tx : 0;
this->target_info_[index].y = (td > 0) ? ty : 0;
this->target_info_[index].is_moving = (td > 0) && is_moving;
} // End loop thru targets
+21 -7
View File
@@ -81,18 +81,32 @@ def _get_data() -> LightData:
return CORE.data[DOMAIN]
def generate_gamma_table(gamma_correct: float) -> list[HexInt]:
"""Generate a 256-entry uint16 gamma lookup table.
For gamma > 0, non-zero indices are clamped to a minimum of 1 to preserve
the invariant that non-zero input always produces non-zero output. Without
this, small brightness values (e.g. 1%) get quantized to exactly 0.0,
which breaks zero_means_zero logic in FloatOutput.
"""
if gamma_correct > 0:
return [
HexInt(
max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
if i > 0
else HexInt(0)
)
for i in range(256)
]
return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
def _get_or_create_gamma_table(gamma_correct):
data = _get_data()
if gamma_correct in data.gamma_tables:
return data.gamma_tables[gamma_correct]
if gamma_correct > 0:
forward = [
HexInt(min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
for i in range(256)
]
else:
forward = [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
forward = generate_gamma_table(gamma_correct)
gamma_str = f"{gamma_correct}".replace(".", "_")
fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16)
@@ -154,6 +154,16 @@ class LightColorValues {
}
/// Convert these light color values to an CWWW representation with the given parameters.
///
/// Note on gamma and constant_brightness: This method operates on the raw/internal channel
/// values stored in this object. For cold_white_ and warm_white_ specifically, these
/// may already be gamma-uncorrected when derived from a color_temperature value.
/// For constant_brightness=false, additional gamma for the output can be applied after
/// this method since gamma commutes with simple multiplication. For constant_brightness=true,
/// the caller (LightState::current_values_as_cwww) must apply gamma to the individual
/// channel values BEFORE the balancing formula, because the nonlinear max/sum ratio does
/// not commute with gamma. See LightState::current_values_as_cwww() for the correct
/// implementation.
void as_cwww(float *cold_white, float *warm_white, bool constant_brightness = false) const {
if (this->color_mode_ & ColorCapability::COLD_WARM_WHITE) {
const float cw_level = this->cold_white_;
+41 -6
View File
@@ -223,12 +223,11 @@ void LightState::current_values_as_rgbw(float *red, float *green, float *blue, f
}
void LightState::current_values_as_rgbww(float *red, float *green, float *blue, float *cold_white, float *warm_white,
bool constant_brightness) {
this->current_values.as_rgbww(red, green, blue, cold_white, warm_white, constant_brightness);
this->current_values.as_rgb(red, green, blue);
*red = this->gamma_correct_lut(*red);
*green = this->gamma_correct_lut(*green);
*blue = this->gamma_correct_lut(*blue);
*cold_white = this->gamma_correct_lut(*cold_white);
*warm_white = this->gamma_correct_lut(*warm_white);
this->current_values_as_cwww(cold_white, warm_white, constant_brightness);
}
void LightState::current_values_as_rgbct(float *red, float *green, float *blue, float *color_temperature,
float *white_brightness) {
@@ -241,9 +240,45 @@ void LightState::current_values_as_rgbct(float *red, float *green, float *blue,
*white_brightness = this->gamma_correct_lut(*white_brightness);
}
void LightState::current_values_as_cwww(float *cold_white, float *warm_white, bool constant_brightness) {
this->current_values.as_cwww(cold_white, warm_white, constant_brightness);
*cold_white = this->gamma_correct_lut(*cold_white);
*warm_white = this->gamma_correct_lut(*warm_white);
if (!constant_brightness) {
// Without constant_brightness, gamma commutes with simple multiplication:
// gamma(white_level * cw) = gamma(white_level) * gamma(cw)
// (since gamma(a*b) = (a*b)^g = a^g * b^g = gamma(a) * gamma(b))
// so applying gamma after is mathematically equivalent and simpler.
this->current_values.as_cwww(cold_white, warm_white, false);
*cold_white = this->gamma_correct_lut(*cold_white);
*warm_white = this->gamma_correct_lut(*warm_white);
return;
}
// For constant_brightness mode, gamma MUST be applied to the individual
// channel values BEFORE the balancing formula (max/sum ratio), not after.
//
// Why: The cold_white_ and warm_white_ values stored in LightColorValues
// are gamma-uncorrected (see transform_parameters_() which applies
// gamma_uncorrect to the linear CW/WW fractions derived from color
// temperature). Applying gamma_correct here recovers the original linear
// fractions, which the constant_brightness formula then uses to distribute
// power evenly. The max/sum formula ensures cold+warm PWM output sums to
// a constant, keeping total power (and perceived brightness) the same
// across all color temperatures.
//
// Applying gamma AFTER the formula would be incorrect because gamma is
// nonlinear: gamma(a/b) != gamma(a)/gamma(b), so the carefully balanced
// ratio would be distorted, causing a severe brightness dip at mid-range
// color temperatures.
const auto &v = this->current_values;
if (!(v.get_color_mode() & ColorCapability::COLD_WARM_WHITE)) {
*cold_white = *warm_white = 0;
return;
}
const float cw_level = this->gamma_correct_lut(v.get_cold_white());
const float ww_level = this->gamma_correct_lut(v.get_warm_white());
const float white_level = this->gamma_correct_lut(v.get_state() * v.get_brightness());
const float sum = cw_level > 0 || ww_level > 0 ? cw_level + ww_level : 1; // Don't divide by zero.
*cold_white = white_level * std::max(cw_level, ww_level) * cw_level / sum;
*warm_white = white_level * std::max(cw_level, ww_level) * ww_level / sum;
}
void LightState::current_values_as_ct(float *color_temperature, float *white_brightness) {
auto traits = this->get_traits();
+44 -24
View File
@@ -56,6 +56,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
logger_ns = cg.esphome_ns.namespace("logger")
@@ -323,19 +324,34 @@ CONFIG_SCHEMA = cv.All(
)
@coroutine_with_priority(CoroPriority.DIAGNOSTICS)
async def to_code(config):
baud_rate = config[CONF_BAUD_RATE]
@coroutine_with_priority(CoroPriority.EARLY_INIT)
async def to_code(config: ConfigType) -> None:
baud_rate: int = config[CONF_BAUD_RATE]
level = config[CONF_LEVEL]
CORE.data.setdefault(CONF_LOGGER, {})[CONF_LEVEL] = level
initial_level = LOG_LEVELS[config.get(CONF_INITIAL_LEVEL, level)]
tx_buffer_size = config[CONF_TX_BUFFER_SIZE]
cg.add_define("ESPHOME_LOGGER_TX_BUFFER_SIZE", tx_buffer_size)
log = cg.new_Pvariable(
config[CONF_ID],
baud_rate,
)
if CORE.is_esp32:
# Determine task log buffer size and define USE_ESPHOME_TASK_LOG_BUFFER early
# so the constructor can allocate the buffer immediately, preventing a race
# where another task logs before the buffer is initialized.
task_log_buffer_size = 0
if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52:
task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE]
elif CORE.is_host:
task_log_buffer_size = 64 # Fixed 64 slots for host
if task_log_buffer_size > 0:
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
log = cg.new_Pvariable(
config[CONF_ID],
baud_rate,
task_log_buffer_size,
)
else:
log = cg.new_Pvariable(
config[CONF_ID],
baud_rate,
)
if CORE.is_esp32 or CORE.is_host:
cg.add(log.create_pthread_key())
# set_uart_selection() must be called before pre_setup() because
# pre_setup() switches on uart_ to decide which hardware to initialize
@@ -347,24 +363,28 @@ async def to_code(config):
HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]]
)
)
# pre_setup() must be called before init_log_buffer() because
# init_log_buffer() calls disable_loop() which may log at VV level,
# and global_logger must be set before any logging occurs.
# pre_setup() sets global_logger and must run before any other code
# that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV).
cg.add(log.pre_setup())
if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52:
task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE]
if task_log_buffer_size > 0:
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
cg.add(log.init_log_buffer(task_log_buffer_size))
if CORE.using_zephyr:
zephyr_add_prj_conf("MPSC_PBUF", True)
elif CORE.is_host:
cg.add(log.create_pthread_key())
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
cg.add(log.init_log_buffer(64)) # Fixed 64 slots for host
initial_level = LOG_LEVELS[config.get(CONF_INITIAL_LEVEL, level)]
cg.add(log.set_log_level(initial_level))
# Schedule the rest of logger setup at DIAGNOSTICS priority, after
# Application is constructed (CORE priority) but before most components.
CORE.add_job(_late_logger_init, config)
@coroutine_with_priority(CoroPriority.DIAGNOSTICS)
async def _late_logger_init(config: ConfigType) -> None:
"""Finish logger setup after Application is constructed."""
log = await cg.get_variable(config[CONF_ID])
level = config[CONF_LEVEL]
baud_rate: int = config[CONF_BAUD_RATE]
if CORE.using_zephyr:
task_log_buffer_size = config.get(CONF_TASK_LOG_BUFFER_SIZE, 0)
if task_log_buffer_size > 0:
zephyr_add_prj_conf("MPSC_PBUF", True)
# Enable runtime tag levels if logs are configured or explicitly enabled
logs_config = config[CONF_LOGS]
if logs_config or config[CONF_RUNTIME_TAG_LEVELS]:
+8 -12
View File
@@ -152,29 +152,25 @@ inline uint8_t Logger::level_for(const char *tag) {
return this->current_level_;
}
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
Logger::Logger(uint32_t baud_rate, size_t task_log_buffer_size) : baud_rate_(baud_rate) {
#else
Logger::Logger(uint32_t baud_rate) : baud_rate_(baud_rate) {
#endif
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
this->main_task_ = xTaskGetCurrentTaskHandle();
#elif defined(USE_ZEPHYR)
this->main_task_ = k_current_get();
#elif defined(USE_HOST)
this->main_thread_ = pthread_self();
this->main_thread_ = pthread_self();
#endif
}
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
void Logger::init_log_buffer(size_t total_buffer_size) {
// Host uses slot count instead of byte size
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed
this->log_buffer_ = new logger::TaskLogBuffer(total_buffer_size);
#if !(defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
// Start with loop disabled when using task buffer
// The loop will be enabled automatically when messages arrive
// Zephyr with USB CDC needs loop active to poll port readiness via cdc_loop_()
this->disable_loop_when_buffer_empty_();
this->log_buffer_ = new logger::TaskLogBuffer(task_log_buffer_size);
// Note: we don't disable loop here because the component isn't registered with App yet.
// The loop self-disables on its first iteration when it finds no messages to process.
#endif
}
#endif
#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
void Logger::loop() {
+3 -2
View File
@@ -143,9 +143,10 @@ enum UARTSelection : uint8_t {
*/
class Logger final : public Component {
public:
explicit Logger(uint32_t baud_rate);
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
void init_log_buffer(size_t total_buffer_size);
explicit Logger(uint32_t baud_rate, size_t task_log_buffer_size);
#else
explicit Logger(uint32_t baud_rate);
#endif
#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
void loop() override;
+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
+14 -3
View File
@@ -28,6 +28,10 @@ namespace esphome::mqtt {
static const char *const TAG = "mqtt";
// Maximum number of MQTT component resends per loop iteration.
// Limits work to avoid triggering the task watchdog on reconnect.
static constexpr uint8_t MAX_RESENDS_PER_LOOP = 8;
// Disconnect reason strings indexed by MQTTClientDisconnectReason enum (0-8)
PROGMEM_STRING_TABLE(MQTTDisconnectReasonStrings, "TCP disconnected", "Unacceptable Protocol Version",
"Identifier Rejected", "Server Unavailable", "Malformed Credentials", "Not Authorized",
@@ -396,9 +400,16 @@ void MQTTClientComponent::loop() {
this->resubscribe_subscriptions_();
// Process pending resends for all MQTT components centrally
// This is more efficient than each component polling in its own loop
for (MQTTComponent *component : this->children_) {
component->process_resend();
// Limit work per loop iteration to avoid triggering task WDT on reconnect
{
uint8_t resend_count = 0;
for (MQTTComponent *component : this->children_) {
if (component->is_resend_pending()) {
component->process_resend();
if (++resend_count >= MAX_RESENDS_PER_LOOP)
break;
}
}
}
}
break;
+3
View File
@@ -147,6 +147,9 @@ class MQTTComponent : public Component {
/// Internal method for the MQTT client base to schedule a resend of the state on reconnect.
void schedule_resend_state();
/// Check if a resend is pending (called by MQTTClientComponent to rate-limit work)
bool is_resend_pending() const { return this->resend_state_; }
/// Process pending resend if needed (called by MQTTClientComponent)
void process_resend();
@@ -11,6 +11,7 @@
#include <openthread/instance.h>
#include <openthread/thread.h>
#include <atomic>
#include <optional>
#include <vector>
@@ -28,6 +29,8 @@ class OpenThreadComponent : public Component {
float get_setup_priority() const override { return setup_priority::WIFI; }
bool is_connected() const { return this->connected_; }
/// Returns true once esp_openthread_init() has completed and the OT lock is usable.
bool is_lock_initialized() const { return this->lock_initialized_; }
network::IPAddresses get_ip_addresses();
std::optional<otIp6Address> get_omr_address();
void ot_main();
@@ -51,6 +54,7 @@ class OpenThreadComponent : public Component {
uint32_t poll_period_{0};
#endif
std::optional<int8_t> output_power_{};
std::atomic<bool> lock_initialized_{false};
bool teardown_started_{false};
bool teardown_complete_{false};
bool connected_{false};
@@ -8,6 +8,7 @@
#include "esp_openthread_lock.h"
#include "esp_task_wdt.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -81,6 +82,9 @@ void OpenThreadComponent::ot_main() {
// Initialize the OpenThread stack
// otLoggingSetLevel(OT_LOG_LEVEL_DEBG);
ESP_ERROR_CHECK(esp_openthread_init(&config));
// Mark lock as initialized so InstanceLock callers know it's safe to acquire.
// Must be set after esp_openthread_init() which creates the internal semaphore.
this->lock_initialized_ = true;
// Fetch OT instance once to avoid repeated call into OT stack
otInstance *instance = esp_openthread_get_instance();
@@ -180,7 +184,8 @@ void OpenThreadComponent::ot_main() {
esp_openthread_launch_mainloop();
// Clean up
// Clean up - reset lock flag before deinit destroys the semaphore
this->lock_initialized_ = false;
esp_openthread_deinit();
esp_openthread_netif_glue_deinit();
esp_netif_destroy(openthread_netif);
@@ -210,6 +215,9 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); }
std::optional<InstanceLock> InstanceLock::try_acquire(int delay) {
if (!global_openthread_component->is_lock_initialized()) {
return {};
}
if (esp_openthread_lock_acquire(delay)) {
return InstanceLock();
}
@@ -217,6 +225,18 @@ std::optional<InstanceLock> InstanceLock::try_acquire(int delay) {
}
InstanceLock InstanceLock::acquire() {
// Wait for the lock to be created by ot_main() before attempting to acquire it.
// esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init().
constexpr uint32_t lock_init_timeout_ms = 10000;
uint32_t start = millis();
while (!global_openthread_component->is_lock_initialized()) {
if (millis() - start > lock_init_timeout_ms) {
ESP_LOGE(TAG, "OpenThread lock not initialized after %" PRIu32 "ms, aborting", lock_init_timeout_ms);
abort();
}
delay(10);
esp_task_wdt_reset();
}
while (!esp_openthread_lock_acquire(100)) {
esp_task_wdt_reset();
}
+5 -8
View File
@@ -95,10 +95,6 @@ void PMSX003Component::loop() {
// Just go ahead and read stuff
break;
}
} else if (now - this->last_update_ < this->update_interval_) {
// Otherwise just leave the sensor powered up and come back when we hit the update
// time
return;
}
if (now - this->last_transmission_ >= 500) {
@@ -114,10 +110,11 @@ void PMSX003Component::loop() {
this->read_byte(&this->data_[this->data_index_]);
auto check = this->check_byte_();
if (!check.has_value()) {
// finished
this->parse_data_();
if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) {
this->parse_data_();
this->last_update_ = now;
}
this->data_index_ = 0;
this->last_update_ = now;
} else if (!*check) {
// wrong data
this->data_index_ = 0;
@@ -138,7 +135,7 @@ optional<bool> PMSX003Component::check_byte_() {
return true;
}
ESP_LOGW(TAG, "Start character %u mismatch: 0x%02X != 0x%02X", index + 1, byte, START_CHARACTER_1);
ESP_LOGW(TAG, "Start character %u mismatch: 0x%02X != 0x%02X", index + 1, byte, start_char);
return false;
}
+37
View File
@@ -5,6 +5,30 @@
namespace esphome {
namespace sdl {
int Sdl::get_width() {
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
case display::DISPLAY_ROTATION_270_DEGREES:
return this->get_height_internal();
case display::DISPLAY_ROTATION_0_DEGREES:
case display::DISPLAY_ROTATION_180_DEGREES:
default:
return this->get_width_internal();
}
}
int Sdl::get_height() {
switch (this->rotation_) {
case display::DISPLAY_ROTATION_0_DEGREES:
case display::DISPLAY_ROTATION_180_DEGREES:
return this->get_height_internal();
case display::DISPLAY_ROTATION_90_DEGREES:
case display::DISPLAY_ROTATION_270_DEGREES:
default:
return this->get_width_internal();
}
}
void Sdl::setup() {
SDL_Init(SDL_INIT_VIDEO);
this->window_ = SDL_CreateWindow(App.get_name().c_str(), this->pos_x_, this->pos_y_, this->width_, this->height_,
@@ -49,6 +73,19 @@ void Sdl::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
return;
if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) {
x = this->width_ - x - 1;
y = this->height_ - y - 1;
} else if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES) {
auto tmp = x;
x = this->width_ - y - 1;
y = tmp;
} else if (this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) {
auto tmp = y;
y = this->height_ - x - 1;
x = tmp;
}
SDL_Rect rect{x, y, 1, 1};
auto data = (display::ColorUtil::color_to_565(color, display::COLOR_ORDER_RGB));
SDL_UpdateTexture(this->texture_, &rect, &data, 2);
+2 -2
View File
@@ -33,8 +33,8 @@ class Sdl : public display::Display {
this->pos_x_ = pos_x;
this->pos_y_ = pos_y;
}
int get_width() override { return this->width_; }
int get_height() override { return this->height_; }
int get_width() override;
int get_height() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void dump_config() override { LOG_DISPLAY("", "SDL", this); }
void add_key_listener(int32_t keycode, std::function<void(bool)> &&callback) {
+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(
+23 -18
View File
@@ -1,4 +1,5 @@
#include "sht4x.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -9,14 +10,12 @@ static const char *const TAG = "sht4x";
static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0};
static const uint8_t SERIAL_NUMBER_COMMAND = 0x89;
void SHT4XComponent::start_heater_() {
uint8_t cmd[] = {this->heater_command_};
ESP_LOGD(TAG, "Heater turning on");
if (this->write(cmd, 1) != i2c::ERROR_OK) {
this->status_set_error(LOG_STR("Failed to turn on heater"));
}
}
// Conversion constants from SHT4x datasheet
static constexpr float TEMPERATURE_OFFSET = -45.0f;
static constexpr float TEMPERATURE_SPAN = 175.0f;
static constexpr float HUMIDITY_OFFSET = -6.0f;
static constexpr float HUMIDITY_SPAN = 125.0f;
static constexpr float RAW_MAX = 65535.0f;
void SHT4XComponent::read_serial_number_() {
uint16_t buffer[2];
@@ -39,8 +38,8 @@ void SHT4XComponent::setup() {
this->read_serial_number_();
if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) {
uint32_t heater_interval = static_cast<uint32_t>(static_cast<uint16_t>(this->heater_time_) / this->duty_cycle_);
ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval);
this->heater_interval_ = static_cast<uint32_t>(static_cast<uint16_t>(this->heater_time_) / this->duty_cycle_);
ESP_LOGD(TAG, "Heater interval: %" PRIu32, this->heater_interval_);
if (this->heater_power_ == SHT4X_HEATERPOWER_HIGH) {
if (this->heater_time_ == SHT4X_HEATERTIME_LONG) {
@@ -62,8 +61,6 @@ void SHT4XComponent::setup() {
}
}
ESP_LOGD(TAG, "Heater command: %x", this->heater_command_);
this->set_interval(heater_interval, std::bind(&SHT4XComponent::start_heater_, this));
}
}
@@ -106,19 +103,27 @@ void SHT4XComponent::update() {
// Evaluate and publish measurements
if (this->temp_sensor_ != nullptr) {
// Temp is contained in the first result word
float sensor_value_temp = buffer[0];
float temp = -45 + 175 * sensor_value_temp / 65535;
float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast<float>(buffer[0]) / RAW_MAX;
this->temp_sensor_->publish_state(temp);
}
if (this->humidity_sensor_ != nullptr) {
// Relative humidity is in the second result word
float sensor_value_rh = buffer[1];
float rh = -6 + 125 * sensor_value_rh / 65535;
float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast<float>(buffer[1]) / RAW_MAX;
this->humidity_sensor_->publish_state(rh);
}
// Fire heater after measurement to maximize cooldown time before the next reading.
// The heater command produces a measurement that we don't need (datasheet 4.9).
if (this->heater_interval_ > 0) {
uint32_t now = millis();
if (now - this->last_heater_millis_ >= this->heater_interval_) {
ESP_LOGD(TAG, "Heater turning on");
if (this->write_command(this->heater_command_)) {
this->last_heater_millis_ = now;
}
}
}
});
}
+2 -1
View File
@@ -35,9 +35,10 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri
SHT4XHEATERTIME heater_time_;
float duty_cycle_;
void start_heater_();
void read_serial_number_();
uint8_t heater_command_;
uint32_t heater_interval_{0};
uint32_t last_heater_millis_{0};
uint32_t serial_number_;
sensor::Sensor *temp_sensor_{nullptr};
+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_() {
+19 -4
View File
@@ -59,15 +59,20 @@ _DST_RULE_TYPE_MAP = {
def _load_tzdata(iana_key: str) -> bytes | None:
# From https://tzdata.readthedocs.io/en/latest/#examples
if not iana_key:
return None
try:
package_loc, resource = iana_key.rsplit("/", 1)
except ValueError:
return None
package = "tzdata.zoneinfo." + package_loc.replace("/", ".")
# Handle top-level timezone entries like "UTC", "GMT"
package = "tzdata.zoneinfo"
resource = iana_key
else:
package = "tzdata.zoneinfo." + package_loc.replace("/", ".")
try:
return (resources.files(package) / resource).read_bytes()
except (FileNotFoundError, ModuleNotFoundError):
except (FileNotFoundError, ModuleNotFoundError, IsADirectoryError):
return None
@@ -279,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};
+5
View File
@@ -30,12 +30,17 @@ enum UARTDirection {
const LogString *parity_to_str(UARTParityOptions parity);
/// Result of a flush() call.
// Some vendor SDKs (e.g., Realtek) define SUCCESS as a macro.
// Save and restore around the enum to avoid collisions with our scoped enum value.
#pragma push_macro("SUCCESS")
#undef SUCCESS
enum class FlushResult {
SUCCESS, ///< Confirmed: all bytes left the TX FIFO.
TIMEOUT, ///< Confirmed: timed out before TX completed.
FAILED, ///< Confirmed: driver or hardware error.
ASSUMED_SUCCESS, ///< Platform cannot report result; success is assumed.
};
#pragma pop_macro("SUCCESS")
class UARTComponent {
public:
@@ -7,7 +7,9 @@
#include "esphome/core/log.h"
#include "esphome/core/gpio.h"
#include "driver/gpio.h"
#include "esp_private/gpio.h"
#include "soc/gpio_num.h"
#include "soc/uart_pins.h"
#ifdef USE_UART_WAKE_LOOP_ON_RX
#include "esphome/core/application.h"
@@ -21,6 +23,20 @@ namespace esphome::uart {
static const char *const TAG = "uart.idf";
/// Check if a pin number matches one of the default UART0 GPIO pins.
/// These pins may have residual IOMUX state from the ROM bootloader that
/// must be cleared before UART reconfiguration.
///
/// ESP-IDF's uart_set_pin() has an asymmetry: when routing TX via GPIO matrix,
/// it calls gpio_func_sel(PIN_FUNC_GPIO) to clear IOMUX, but for RX it only
/// calls gpio_input_enable() which does NOT clear the IOMUX function select.
/// If a default UART0 TX pin (configured as TX via IOMUX during boot) is later
/// reassigned as RX via GPIO matrix, the old IOMUX TX function remains active,
/// causing TX data to loop back into RX on the same pin.
static constexpr bool is_default_uart0_pin(int8_t pin_num) {
return pin_num == U0TXD_GPIO_NUM || pin_num == U0RXD_GPIO_NUM;
}
uart_config_t IDFUARTComponent::get_config_() {
uart_parity_t parity = UART_PARITY_DISABLE;
if (this->parity_ == UART_CONFIG_PARITY_EVEN) {
@@ -131,6 +147,33 @@ 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;
// Clear residual IOMUX function on UART0 default pins left by the ROM bootloader.
// See is_default_uart0_pin() comment for details on the ESP-IDF uart_set_pin() bug.
if (is_default_uart0_pin(tx)) {
gpio_func_sel(static_cast<gpio_num_t>(tx), PIN_FUNC_GPIO);
}
if (is_default_uart0_pin(rx)) {
gpio_func_sel(static_cast<gpio_num_t>(rx), PIN_FUNC_GPIO);
}
auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
if (!pin) {
return;
@@ -146,10 +189,6 @@ void IDFUARTComponent::load_settings(bool dump_config) {
setup_pin_if_needed(this->tx_pin_);
}
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;
uint32_t invert = 0;
if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) {
invert |= UART_SIGNAL_TXD_INV;
@@ -189,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
@@ -299,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))
@@ -306,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;
@@ -6,11 +6,17 @@ namespace esphome::ultrasonic {
static const char *const TAG = "ultrasonic.sensor";
static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us of each other (noise filtering)
static constexpr uint32_t START_DELAY_US = 100; // Ignore edges within 100us of trigger (filters bleed-through)
static constexpr uint32_t START_TIMEOUT_US = 40000; // Maximum time to wait for echo pulse to start
void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
uint32_t now = micros();
if (arg->echo_pin_isr.digital_read()) {
// Ignore edges after measurement complete or too soon after trigger pulse
if (arg->echo_end || (now - arg->measurement_start_us) <= START_DELAY_US) {
return;
}
if (!arg->echo_start || (now - arg->echo_start_us) <= DEBOUNCE_US) {
arg->echo_start_us = now;
arg->echo_start = true;
} else {
@@ -21,15 +27,14 @@ void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
void IRAM_ATTR UltrasonicSensorComponent::send_trigger_pulse_() {
InterruptLock lock;
this->store_.echo_start_us = 0;
this->store_.echo_end_us = 0;
this->store_.echo_start = false;
this->store_.echo_end = false;
this->store_.measurement_start_us = micros();
this->trigger_pin_isr_.digital_write(true);
delayMicroseconds(this->pulse_time_us_);
this->trigger_pin_isr_.digital_write(false);
this->measurement_pending_ = true;
this->measurement_start_us_ = micros();
this->measurement_start_us_ = this->store_.measurement_start_us;
}
void UltrasonicSensorComponent::setup() {
@@ -37,7 +42,6 @@ void UltrasonicSensorComponent::setup() {
this->trigger_pin_->digital_write(false);
this->trigger_pin_isr_ = this->trigger_pin_->to_isr();
this->echo_pin_->setup();
this->store_.echo_pin_isr = this->echo_pin_->to_isr();
this->echo_pin_->attach_interrupt(UltrasonicSensorStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE);
}
@@ -77,17 +81,10 @@ void UltrasonicSensorComponent::loop() {
}
if (this->store_.echo_end) {
float result;
if (this->store_.echo_start) {
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
ESP_LOGV(TAG, "pulse start took %" PRIu32 "us, echo took %" PRIu32 "us",
this->store_.echo_start_us - this->measurement_start_us_, pulse_duration);
result = UltrasonicSensorComponent::us_to_m(pulse_duration);
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
} else {
ESP_LOGW(TAG, "'%s' - pulse end before pulse start, does the echo pin need to be inverted?", this->name_.c_str());
result = NAN;
}
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
ESP_LOGV(TAG, "Echo took %" PRIu32 "us", pulse_duration);
float result = UltrasonicSensorComponent::us_to_m(pulse_duration);
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
this->publish_state(result);
this->measurement_pending_ = false;
return;
@@ -11,8 +11,7 @@ namespace esphome::ultrasonic {
struct UltrasonicSensorStore {
static void gpio_intr(UltrasonicSensorStore *arg);
ISRInternalGPIOPin echo_pin_isr;
volatile uint32_t wait_start_us{0};
volatile uint32_t measurement_start_us{0};
volatile uint32_t echo_start_us{0};
volatile uint32_t echo_end_us{0};
volatile bool echo_start{false};
@@ -120,7 +120,10 @@ void AsyncWebServer::begin() {
if (this->server_) {
this->end();
}
// Default httpd stack is defined by ESP-IDF. Increase to accommodate SerializationBuffer's
// 640-byte stack buffer used by web_server JSON request handlers.
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = config.stack_size + 256;
config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Always enable LRU purging to handle socket exhaustion gracefully.
+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
@@ -92,13 +92,23 @@ bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
return ret;
}
bool WiFiComponent::wifi_apply_power_save_() {
// ESP8266 sleep types have confusing names — LIGHT_SLEEP_T is the MORE aggressive mode.
// SDK enum: NONE_SLEEP_T=0, LIGHT_SLEEP_T=1, MODEM_SLEEP_T=2
// https://github.com/esp8266/Arduino/blob/3.1.2/tools/sdk/include/user_interface.h#L447-L451
// Arduino ESP32 compat confirms: WIFI_PS_MIN_MODEM=MODEM_SLEEP, WIFI_PS_MAX_MODEM=LIGHT_SLEEP
// https://github.com/esp8266/Arduino/blob/3.1.2/libraries/ESP8266WiFi/src/ESP8266WiFiType.h#L53-L55
sleep_type_t power_save;
switch (this->power_save_) {
case WIFI_POWER_SAVE_LIGHT:
power_save = LIGHT_SLEEP_T;
// MODEM_SLEEP_T: only the WiFi modem sleeps between DTIM beacons, CPU stays active.
// Matches ESP32's WIFI_PS_MIN_MODEM.
power_save = MODEM_SLEEP_T;
break;
case WIFI_POWER_SAVE_HIGH:
power_save = MODEM_SLEEP_T;
// LIGHT_SLEEP_T: both WiFi modem AND CPU suspend between DTIM beacons.
// Most aggressive — prevents TCP processing during sleep. Matches ESP32's WIFI_PS_MAX_MODEM.
// See https://github.com/esphome/esphome/issues/14999
power_save = LIGHT_SLEEP_T;
break;
case WIFI_POWER_SAVE_NONE:
default:
@@ -654,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.0"
__version__ = "2026.3.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+3 -1
View File
@@ -587,7 +587,9 @@ async def _add_looping_components() -> None:
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config: ConfigType) -> None:
cg.add_global(cg.global_ns.namespace("esphome").using)
# using namespace esphome is hardcoded in writer.py to guarantee it
# precedes all variable declarations regardless of coroutine priority.
# These can be used by user lambdas, put them to default scope
cg.add_global(cg.RawExpression("using std::isnan"))
cg.add_global(cg.RawExpression("using std::min"))
+9
View File
@@ -76,6 +76,15 @@ class StringRef {
constexpr bool empty() const { return len_ == 0; }
constexpr const_reference operator[](size_type pos) const { return *(base_ + pos); }
/// Copy characters to destination buffer (std::string::copy-like, but returns 0 instead of throwing on out-of-range)
size_type copy(char *dest, size_type count, size_type pos = 0) const {
if (pos >= len_)
return 0;
size_type actual = (count > len_ - pos) ? len_ - pos : count;
std::memcpy(dest, base_ + pos, actual);
return actual;
}
std::string str() const { return std::string(base_, len_); }
const uint8_t *byte() const { return reinterpret_cast<const uint8_t *>(base_); }
+7 -11
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;
}
@@ -283,19 +283,15 @@ void ESPTime::recalc_timestamp_local() {
bool dst_valid = time::is_in_dst(utc_if_dst, tz);
bool std_valid = !time::is_in_dst(utc_if_std, tz);
if (dst_valid && std_valid) {
// Ambiguous time (repeated hour during fall-back) - prefer standard time
this->timestamp = utc_if_std;
} else if (dst_valid) {
if (dst_valid && !std_valid) {
// Only DST interpretation is valid
this->timestamp = utc_if_dst;
} else if (std_valid) {
// Only standard interpretation is valid
this->timestamp = utc_if_std;
} else {
// Invalid time (skipped hour during spring-forward)
// libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT
// Using std offset achieves this since the UTC result falls during DST
// All other cases use standard offset:
// - Both valid (ambiguous fall-back repeated hour): prefer standard time
// - Only standard valid: straightforward
// - Neither valid (spring-forward skipped hour): std offset normalizes
// forward to match libc mktime(), e.g. 02:30 CST -> 03:30 CDT
this->timestamp = utc_if_std;
}
#else
+15 -5
View File
@@ -1,5 +1,7 @@
#pragma once
#include "esphome/core/defines.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
@@ -77,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.
+8 -2
View File
@@ -63,7 +63,13 @@ class CoroPriority(enum.IntEnum):
resolution during code generation.
"""
# Platform initialization - must run first
# Early init - runs before platform init and before Application exists.
# Currently used only to connect logging so ESP_LOG* calls work
# immediately in all subsequent phases.
# Examples: logger (1100)
EARLY_INIT = 1100
# Platform initialization
# Examples: esp32, esp8266, rp2040
PLATFORM = 1000
@@ -83,7 +89,7 @@ class CoroPriority(enum.IntEnum):
CORE = 100
# Diagnostic and debugging systems
# Examples: logger (90)
# Examples: debug component (90)
DIAGNOSTICS = 90
# Status and monitoring systems
+3
View File
@@ -381,7 +381,10 @@ def write_cpp(code_s):
code_format = CPP_BASE_FORMAT
copy_src_tree()
# using namespace esphome must precede all variable declarations since
# codegen types assume this namespace is in scope (esphome_ns = global_ns).
global_s = '#include "esphome.h"\n'
global_s += "using namespace esphome;\n"
global_s += CORE.cpp_global_section
full_file = f"{code_format[0] + CPP_INCLUDE_BEGIN}\n{global_s}{CPP_INCLUDE_END}"
+1 -1
View File
@@ -12,7 +12,7 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.1
esphome-dashboard==20260210.0
aioesphomeapi==44.5.2
aioesphomeapi==44.6.2
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
@@ -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);
+1 -1
View File
@@ -22,7 +22,7 @@ void original_setup() {
void setup() {
// Log functions call global_logger->log_vprintf_() without a null check,
// so we must set up a Logger before any test that triggers logging.
static esphome::logger::Logger test_logger(0);
static esphome::logger::Logger test_logger(0, 64);
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
test_logger.pre_setup();
+4
View File
@@ -6,4 +6,8 @@ sensor:
humidity:
name: SHT4X Humidity
address: 0x44
precision: High
heater_max_duty: 0.02
heater_power: High
heater_time: Long
update_interval: 15s
+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,14 @@
uart:
- id: uart_id
tx_pin: PA23
rx_pin: PA18
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
switch:
- platform: uart
name: "UART Switch"
uart_id: uart_id
data: [0x01, 0x02, 0x03]
+1 -1
View File
@@ -15,7 +15,7 @@ void setup() {
static char name[] = "livingroom";
static char friendly_name[] = "LivingRoom";
App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1);
auto *log = new logger::Logger(115200); // NOLINT
auto *log = new logger::Logger(115200, 512); // NOLINT
log->pre_setup();
log->set_uart_selection(logger::UART_SELECTION_UART0);
App.register_component_(log);
@@ -0,0 +1,57 @@
esphome:
name: light-cb-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: cb_cold_white_output
type: float
write_action:
- logger.log:
format: "CB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: cb_warm_white_output
type: float
write_action:
- logger.log:
format: "CB_WW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_cold_white_output
type: float
write_action:
- logger.log:
format: "NCB_CW_OUTPUT:%.6f"
args: [state]
- platform: template
id: ncb_warm_white_output
type: float
write_action:
- logger.log:
format: "NCB_WW_OUTPUT:%.6f"
args: [state]
light:
- platform: cwww
name: "Test CB Light"
id: test_cb_light
cold_white: cb_cold_white_output
warm_white: cb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: true
gamma_correct: 2.8
- platform: cwww
name: "Test NCB Light"
id: test_ncb_light
cold_white: ncb_cold_white_output
warm_white: ncb_warm_white_output
cold_white_color_temperature: 6536 K
warm_white_color_temperature: 2000 K
constant_brightness: false
gamma_correct: 2.8
@@ -0,0 +1,188 @@
"""Integration test for constant_brightness with gamma correction.
Tests both constant_brightness: true and false cwww lights with gamma
correction in a single compilation to verify:
- constant_brightness: true maintains constant total CW+WW power output
- constant_brightness: false correctly varies total power across color temps
This is a regression test for https://github.com/esphome/esphome/issues/15040
where the gamma LUT refactor (#14123) broke constant_brightness by applying
gamma after the balancing formula instead of before it.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightInfo, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_constant_brightness(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test constant_brightness true and false behavior with gamma correction."""
# Track output values for both lights from log lines
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
latest: dict[str, float] = {
"cb_cw": 0.0,
"cb_ww": 0.0,
"ncb_cw": 0.0,
"ncb_ww": 0.0,
}
def on_log_line(line: str) -> None:
for pattern, key in [
(cb_cw_pattern, "cb_cw"),
(cb_ww_pattern, "cb_ww"),
(ncb_cw_pattern, "ncb_cw"),
(ncb_ww_pattern, "ncb_ww"),
]:
match = pattern.search(line)
if match:
latest[key] = float(match.group(1))
loop = asyncio.get_running_loop()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
lights = [e for e in entities if isinstance(e, LightInfo)]
cb_light = next(e for e in lights if e.object_id.endswith("cb_light"))
ncb_light = next(e for e in lights if e.object_id.endswith("ncb_light"))
# Use InitialStateHelper to wait for initial state broadcast
initial_state_helper = InitialStateHelper(entities)
# Track state changes per light key
state_futures: dict[int, asyncio.Future[EntityState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
async def send_and_wait(
light_key: int, timeout: float = 5.0, **kwargs: Any
) -> LightState:
"""Send a light command and wait for the state response."""
state_futures[light_key] = loop.create_future()
client.light_command(key=light_key, **kwargs)
try:
return await asyncio.wait_for(state_futures[light_key], timeout=timeout)
except TimeoutError:
pytest.fail(f"Timeout waiting for light state after command: {kwargs}")
# --- Test constant_brightness: true ---
# Turn on CB light at full brightness
await send_and_wait(
cb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
test_mireds = [
153.0, # Pure cold white
200.0, # Mostly cold
280.0, # Mixed
326.5, # Midpoint
400.0, # Mostly warm
500.0, # Pure warm white
]
cb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
cb_light.key, color_temperature=mireds, transition_length=0
)
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
# All totals should be approximately equal (constant brightness)
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
assert reference_total > 0, (
f"Reference total power is zero, CB light outputs not working. "
f"Values: {cb_totals}"
)
for mireds, cw, ww in cb_totals:
total = cw + ww
assert total == pytest.approx(reference_total, rel=0.05), (
f"constant_brightness: Total power at {mireds} mireds "
f"({total:.4f}) differs from reference ({reference_total:.4f}) "
f"by more than 5%. CW={cw:.4f}, WW={ww:.4f}. "
f"All values: {cb_totals}"
)
# --- Test constant_brightness: false ---
# Turn on NCB light at full brightness
await send_and_wait(
ncb_light.key,
state=True,
brightness=1.0,
color_temperature=153.0,
transition_length=0,
)
ncb_totals: list[tuple[float, float, float]] = []
for mireds in test_mireds:
await send_and_wait(
ncb_light.key, color_temperature=mireds, transition_length=0
)
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
midpoint = ncb_totals[3] # 326.5 mireds - midpoint
# At pure cold white, WW should be ~0
assert extreme_cw[2] == pytest.approx(0.0, abs=0.01), (
f"Pure cold white should have WW~0, got WW={extreme_cw[2]:.4f}"
)
# At pure warm white, CW should be ~0
assert extreme_ww[1] == pytest.approx(0.0, abs=0.01), (
f"Pure warm white should have CW~0, got CW={extreme_ww[1]:.4f}"
)
# At midpoint, both channels should be non-zero
assert midpoint[1] > 0.05, f"Midpoint CW should be >0.05, got {midpoint[1]:.4f}"
assert midpoint[2] > 0.05, f"Midpoint WW should be >0.05, got {midpoint[2]:.4f}"
# Total power at midpoint should be higher than at the extremes
midpoint_total = midpoint[1] + midpoint[2]
extreme_cw_total = extreme_cw[1] + extreme_cw[2]
extreme_ww_total = extreme_ww[1] + extreme_ww[2]
assert midpoint_total > extreme_cw_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure CW total "
f"({extreme_cw_total:.4f}). All values: {ncb_totals}"
)
assert midpoint_total > extreme_ww_total, (
f"Midpoint total ({midpoint_total:.4f}) should be > pure WW total "
f"({extreme_ww_total:.4f}). All values: {ncb_totals}"
)
@@ -0,0 +1,117 @@
"""Tests for the gamma LUT table generation."""
import pytest
from esphome.components.light import generate_gamma_table
def _simulate_gamma_correct_lut(table: list[int], value: float) -> float:
"""Simulate the C++ gamma_correct_lut interpolation from light_state.cpp."""
if value <= 0.0:
return 0.0
if value >= 1.0:
return 1.0
scaled = value * 255.0
idx = int(scaled)
if idx >= 255:
return table[255] / 65535.0
frac = scaled - idx
a = float(table[idx])
b = float(table[idx + 1])
return (a + frac * (b - a)) / 65535.0
def test_table_length() -> None:
"""Table must always have exactly 256 entries."""
table = generate_gamma_table(2.8)
assert len(table) == 256
def test_index_zero_is_zero() -> None:
"""Index 0 must be 0 so true off remains off."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[0] == 0, f"gamma={gamma}"
def test_index_255_is_max() -> None:
"""Index 255 must be 65535 (full on)."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[255] == 65535, f"gamma={gamma}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_nonzero_indices_are_nonzero(gamma: float) -> None:
"""All indices > 0 must produce non-zero values.
This prevents zero_means_zero breakage: non-zero input must always
produce non-zero output so FloatOutput applies min_power scaling.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
f"gamma={gamma}: table[{i}]={table[i]} < table[{i - 1}]={table[i - 1]}"
)
def test_linear_gamma() -> None:
"""With gamma=0 (linear), table should be evenly spaced."""
table = generate_gamma_table(0)
assert table[0] == 0
assert table[128] == round(128 / 255.0 * 65535)
assert table[255] == 65535
@pytest.mark.parametrize("brightness", [0.01, 0.005, 0.001, 1 / 255])
def test_small_brightness_nonzero_after_lut(brightness: float) -> None:
"""Small but non-zero brightness must produce non-zero output through the LUT.
Regression test for #15055: with zero_means_zero=true, a gamma-corrected
value of exactly 0.0 causes FloatOutput to skip min_power scaling, turning
the LED off instead of to minimum brightness.
"""
table = generate_gamma_table(2.8)
result = _simulate_gamma_correct_lut(table, brightness)
assert result > 0.0, (
f"brightness={brightness}: gamma LUT returned 0.0, would break zero_means_zero"
)
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_small_brightness_nonzero_all_gammas(gamma: float) -> None:
"""1% brightness must be non-zero for all common gamma values."""
table = generate_gamma_table(gamma)
result = _simulate_gamma_correct_lut(table, 0.01)
assert result > 0.0, f"gamma={gamma}: 1% brightness returned 0.0"
def test_lut_zero_returns_zero() -> None:
"""LUT with input 0.0 must return 0.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 0.0) == 0.0
def test_lut_one_returns_one() -> None:
"""LUT with input 1.0 must return 1.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 1.0) == 1.0
def test_lut_output_monotonically_nondecreasing() -> None:
"""LUT output must be monotonically non-decreasing across the full range."""
table = generate_gamma_table(2.8)
prev = 0.0
for i in range(1001):
value = i / 1000.0
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result
@@ -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