Merge pull request #15739 from esphome/bump-2026.4.0b3

2026.4.0b3
This commit is contained in:
Jesse Hills
2026-04-15 13:10:09 +12:00
committed by GitHub
42 changed files with 660 additions and 94 deletions
+1 -1
View File
@@ -1 +1 @@
d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
+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.4.0b2
PROJECT_NUMBER = 2026.4.0b3
# 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
+8 -1
View File
@@ -750,8 +750,15 @@ def upload_using_esptool(
platformio_api.FlashImage(
path=idedata.firmware_bin_path, offset=firmware_offset
),
*idedata.extra_flash_images,
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
_LOGGER.warning(
"Skipping missing flash image declared by platform: %s",
image.path,
)
continue
flash_images.append(image)
mcu = "esp8266"
if CORE.is_esp32:
+14 -1
View File
@@ -2,7 +2,11 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
require_adc_oneshot_iram,
)
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -24,6 +28,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from . import (
ATTENUATION_MODES,
@@ -65,6 +70,13 @@ def validate_config(config):
return config
def _require_adc_iram(config: ConfigType) -> ConfigType:
"""Register ADC oneshot IRAM requirement during config validation."""
if CORE.is_esp32:
require_adc_oneshot_iram()
return config
ADCSensor = adc_ns.class_(
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
)
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s")),
validate_config,
_require_adc_iram,
)
CONF_ADC_CHANNEL_ID = "adc_channel_id"
+5 -2
View File
@@ -671,6 +671,7 @@ message SensorStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
option (no_delay) = true;
option (speed_optimized) = true;
fixed32 key = 1 [(force) = true];
float state = 2;
@@ -777,9 +778,10 @@ message SubscribeLogsResponse {
option (source) = SOURCE_SERVER;
option (log) = false;
option (no_delay) = false;
option (speed_optimized) = true;
LogLevel level = 1;
bytes message = 3;
LogLevel level = 1 [(force) = true];
bytes message = 3 [(force) = true];
}
// ==================== NOISE ENCRYPTION ====================
@@ -1638,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (no_delay) = true;
option (speed_optimized) = true;
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
}
+1
View File
@@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions {
optional bool no_delay = 1040 [default=false];
optional string base_class = 1041;
optional bool inline_encode = 1042 [default=false];
optional bool speed_optimized = 1043 [default=false];
}
extend google.protobuf.FieldOptions {
+24 -10
View File
@@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
#endif
return size;
}
uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state);
@@ -755,7 +757,9 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
#endif
return pos;
}
uint32_t SensorStateResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SensorStateResponse::calculate_size() const {
uint32_t size = 0;
size += 5;
size += ProtoSize::calc_float(1, this->state);
@@ -912,16 +916,22 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t
}
return true;
}
uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level), true);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26);
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_);
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_);
return pos;
}
uint32_t SubscribeLogsResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SubscribeLogsResponse::calculate_size() const {
uint32_t size = 0;
size += this->level ? 2 : 0;
size += ProtoSize::calc_length(1, this->message_len_);
size += 2;
size += ProtoSize::calc_length_force(1, this->message_len_);
return size;
}
#ifdef USE_API_NOISE
@@ -2328,7 +2338,9 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
}
return true;
}
uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
for (uint16_t i = 0; i < this->advertisements_len; i++) {
auto &sub_msg = this->advertisements[i];
@@ -2350,7 +2362,9 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P
}
return pos;
}
uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const {
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
BluetoothLERawAdvertisementsResponse::calculate_size() const {
uint32_t size = 0;
for (uint16_t i = 0; i < this->advertisements_len; i++) {
auto &sub_msg = this->advertisements[i];
+2 -2
View File
@@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent,
#endif
float get_reference_voltage(uint8_t phase) {
#ifdef USE_NUMBER
return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
#else
return 120.0; // Default voltage
#endif
}
float get_reference_current(uint8_t phase) {
#ifdef USE_NUMBER
return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
#else
return 5.0f; // Default current
#endif
+28 -4
View File
@@ -676,7 +676,7 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"dev": cv.Version(3, 3, 8),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(55, 3, 38),
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
@@ -724,7 +724,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(5, 5, 4): cv.Version(55, 3, 38),
cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
@@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# The platform-espressif32 version
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
"recommended": cv.Version(55, 3, 38),
"latest": cv.Version(55, 3, 38),
"recommended": cv.Version(55, 3, 38, "1"),
"latest": cv.Version(55, 3, 38, "1"),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}
@@ -1058,6 +1058,7 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
CONF_DISABLE_FATFS = "disable_fatfs"
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
# VFS requirement tracking
# Components that need VFS features can call require_vfs_*() functions
@@ -1071,6 +1072,7 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
def require_vfs_select() -> None:
@@ -1168,6 +1170,17 @@ def require_fatfs() -> None:
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
def require_adc_oneshot_iram() -> None:
"""Mark that ADC oneshot IRAM safety is required by a component.
Call this from components that use the ADC oneshot driver. When flash cache is
disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management),
the ADC oneshot read function must be in IRAM to avoid crashes.
This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM.
"""
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
# Match operator followed by version-like string (digit or *)
@@ -1268,6 +1281,7 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
@@ -2068,6 +2082,16 @@ async def to_code(config):
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
# Place ADC oneshot control functions in IRAM for cache safety
# When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread,
# power management, etc.), ADC reads will crash if these functions are in flash.
# Components using ADC call require_adc_oneshot_iram() to force this.
if (
CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False)
or advanced[CONF_ADC_ONESHOT_IN_IRAM]
):
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
+6 -1
View File
@@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
# Use the global's value_type alias as the lambda return type so
# TemplatableFn stores a direct function pointer instead of going through
# the deprecated converting trampoline when the value expression deduces
# to a different type (e.g. int literal assigned to a float global).
value_type = cg.RawExpression(f"{full_id.type}::value_type")
templ = await cg.templatable(
config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True
config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression
)
cg.add(var.set_value(templ))
return var
@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
)
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
def _validate_esp32_variant(config):
+57 -6
View File
@@ -58,6 +58,12 @@ void AddressableLightTransformer::start() {
// our transition will handle brightness, disable brightness in correction.
this->light_.correction_.set_local_brightness(255);
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
// Uniformity scan is deferred to the first apply() call. start() can run before the underlying
// LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE
// triggering a transition), and reading through ESPColorView would deref a null buffer.
this->uniform_start_scanned_ = false;
this->uniform_start_is_uniform_ = false;
}
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
@@ -97,12 +103,57 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
// non-linear when applying small deltas.
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
// frame buffer is valid. When every LED already has the same color (the common case: plain
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
// round to stored 0, freezing progress).
if (!this->uniform_start_scanned_) {
this->uniform_start_scanned_ = true;
if (this->light_.size() > 0) {
Color first = this->light_[0].get();
bool uniform = true;
for (int32_t i = 1; i < this->light_.size(); i++) {
if (this->light_[i].get() != first) {
uniform = false;
break;
}
}
if (uniform) {
this->uniform_start_color_ = first;
this->uniform_start_is_uniform_ = true;
}
}
}
if (this->uniform_start_is_uniform_) {
// All LEDs started at the same color: compute the interpolated value once and write it to
// every LED. No read-back, so each LED's stored byte advances through every gamma threshold
// as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values.
//
// Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be
// overwritten on the next apply() here. The fallback path below would have respected them
// via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we
// support, so this is acceptable.
// lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress).
const Color &start = this->uniform_start_color_;
int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));
uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining);
uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining);
uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining);
uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining);
for (auto led : this->light_) {
led.set_rgbw(r, g, b, w);
}
} else {
int32_t scale =
int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
}
}
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();
@@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer {
AddressableLight &light_;
float last_transition_progress_{0.0f};
Color target_color_{};
Color uniform_start_color_{};
bool uniform_start_scanned_{false};
bool uniform_start_is_uniform_{false};
};
} // namespace esphome::light
@@ -452,7 +452,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1")
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
+2 -1
View File
@@ -28,7 +28,8 @@ void AirConditioner::on_status_change() {
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
// Read existing presets (set by codegen), append frost protection, write back
const auto &existing = this->get_traits().get_supported_custom_presets();
auto traits = this->get_traits();
const auto &existing = traits.get_supported_custom_presets();
bool found = false;
for (const char *p : existing) {
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {
+4 -4
View File
@@ -234,9 +234,9 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL,
this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_,
this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
HAS_HARDWARE_ROTATION);
}
@@ -305,7 +305,7 @@ class MipiSpi : public display::Display,
this->write_command_(BRIGHTNESS, this->brightness_.value());
// calculate new madctl value from base value adjusted for rotation
uint8_t madctl = MADCTL; // lower 8 bits only
uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only
constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0;
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;
+74 -34
View File
@@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) {
}
#ifdef USE_NEXTION_COMMAND_SPACING
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
const uint32_t now = App.get_loop_component_start_time();
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) {
ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str());
return false;
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) {
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
this->write_array(to_send, sizeof(to_send));
#ifdef USE_NEXTION_COMMAND_SPACING
// Mark sent immediately after writing to UART. The pacer enforces inter-command
// spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_
// at zero indefinitely, making can_send() always return true and spacing a no-op.
// ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally.
if (!this->connection_state_.ignore_is_setup_) {
this->command_pacer_.mark_sent(now);
}
#endif // USE_NEXTION_COMMAND_SPACING
return true;
}
@@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
return false;
if (this->send_command_(command)) {
this->add_no_result_to_queue_("command");
return true;
}
return false;
this->add_no_result_to_queue_with_command_("command", command);
return true;
}
bool Nextion::send_command_printf(const char *format, ...) {
@@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) {
return false;
}
if (this->send_command_(buffer)) {
this->add_no_result_to_queue_("command_printf");
return true;
}
return false;
this->add_no_result_to_queue_with_command_("command_printf", buffer);
return true;
}
#ifdef NEXTION_PROTOCOL_LOG
@@ -349,25 +354,43 @@ void Nextion::loop() {
}
#ifdef USE_NEXTION_COMMAND_SPACING
// Try to send any pending commands if spacing allows
this->process_pending_in_queue_();
#ifdef USE_NEXTION_WAVEFORM
if (!this->waveform_queue_.empty()) {
this->check_pending_waveform_();
}
#endif // USE_NEXTION_WAVEFORM
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_COMMAND_SPACING
void Nextion::process_pending_in_queue_() {
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
return;
}
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
size_t commands_sent = 0;
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
// Check if first item in queue has a pending command
auto *front_item = this->nextion_queue_.front();
if (front_item && !front_item->pending_command.empty()) {
if (this->send_command_(front_item->pending_command)) {
// Command sent successfully, clear the pending command
front_item->pending_command.clear();
ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str());
for (auto *item : this->nextion_queue_) {
if (item == nullptr || item->pending_command.empty()) {
continue; // Already sent, waiting for ACK — skip, don't stop
}
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
if (++commands_sent > this->max_commands_per_loop_) {
ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring");
break;
}
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
const uint32_t now = App.get_loop_component_start_time();
if (!this->command_pacer_.can_send(now)) {
break; // Spacing not elapsed, stop for this loop iteration
}
if (!this->send_command_(item->pending_command)) {
break; // Unexpected send failure, stop
}
item->pending_command.clear();
ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str());
}
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() {
this->setup_callback_.call();
}
}
#ifdef USE_NEXTION_COMMAND_SPACING
this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent
ESP_LOGN(TAG, "Command spacing: marked command sent");
#endif
break;
case 0x02: // invalid Component ID or name was used
ESP_LOGW(TAG, "Invalid component ID/name");
@@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
}
/**
* @brief
* @brief Send a command and enqueue it for response tracking.
*
* @param variable_name Variable name for the queue
* @param command
* Callers are responsible for checking is_sleeping() before calling this
* method. The sleep guard is deliberately absent here because some callers
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
* sleep-safe and must bypass it.
*
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
* the command is saved in the queue entry for retry rather than dropped.
*
* @param variable_name Name of the variable or component associated with the command.
* @param command The raw command string to send.
*/
void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty())
@@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
std::string command = "get " + component->get_variable_name_to_send();
#ifdef USE_NEXTION_COMMAND_SPACING
// Always enqueue first so the response handler is present when the command
// is eventually sent. Store the command for retry if spacing blocked it;
// process_pending_in_queue_() will transmit it when the pacer allows.
nextion_queue->pending_command = command;
this->nextion_queue_.push_back(nextion_queue);
if (this->send_command_(command)) {
nextion_queue->pending_command.clear();
}
#else // USE_NEXTION_COMMAND_SPACING
if (this->send_command_(command)) {
this->nextion_queue_.push_back(nextion_queue);
} else {
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
}
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_WAVEFORM
@@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() {
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
component->get_wave_channel_id(), buffer_to_send);
if (!this->send_command_(command)) {
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->waveform_queue_.pop();
}
// If spacing or setup state blocks the send, leave the entry at the front
// of waveform_queue_ for retry on the next loop iteration via
// check_pending_waveform_(). Only pop on a successful send.
this->send_command_(command);
}
#endif // USE_NEXTION_WAVEFORM
+10 -5
View File
@@ -55,15 +55,20 @@ class NextionCommandPacer {
uint8_t get_spacing() const { return spacing_ms_; }
/**
* @brief Check if enough time has passed to send next command
* @return true if enough time has passed since last command
* @brief Check if enough time has passed to send the next command.
* @param now Current timestamp in milliseconds (use App.get_loop_component_start_time()
* for consistency with the rest of the queue timing).
* @return true if the spacing interval has elapsed since the last command was sent.
*/
bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; }
bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; }
/**
* @brief Mark a command as sent, updating the timing
* @brief Record the transmit timestamp for the most recently sent command.
* @param now Current timestamp in milliseconds, as returned by
* App.get_loop_component_start_time(). Must use the same clock
* source as can_send() to avoid unsigned underflow.
*/
void mark_sent() { last_command_time_ = millis(); }
void mark_sent(uint32_t now) { last_command_time_ = now; }
private:
uint8_t spacing_ms_;
+8 -5
View File
@@ -321,12 +321,15 @@ def _walk_packages(
return config
packages = config[CONF_PACKAGES]
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
with cv.prepend_path(CONF_PACKAGES):
if isinstance(packages, yaml_util.IncludeFile):
# If the packages key is an IncludeFile, resolve it first before processing.
packages, _ = resolve_include(packages, [], context, strict_undefined=False)
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
if not isinstance(packages, dict):
_walk_package_list(packages, callback, context)
elif (result := _walk_package_dict(packages, callback, context)) is not None:
+1 -1
View File
@@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration
my_integration_time_regval = integration_time;
this->integration_time_auto_ = false;
}
this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f;
this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f;
ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_);
}
void TCS34725Component::set_gain(TCS34725Gain gain) {
@@ -114,7 +114,25 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
if (index == 0 && !this->ota_backend_) {
// First byte of a new upload: index==0 with actual data. (web_server_idf
// fires a separate start-marker call with data==nullptr/len==0 before the
// first real chunk; gate on len>0 so we only trigger once per upload.)
if (index == 0 && len > 0) {
// If a previous upload was interrupted (e.g. client closed the tab, TCP
// reset) the backend from that session may still be open. Tear it down
// so flash state doesn't get concatenated with the new image (which can
// produce a technically-valid-sized but corrupted firmware that bricks
// the device once it reboots).
if (this->ota_backend_) {
ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session");
this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER
// Notify listeners that the previous session was aborted before the new one starts.
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
#endif
this->ota_backend_.reset();
}
// Initialize OTA on first call
this->ota_init_(filename.c_str());
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.4.0b2"
__version__ = "2026.4.0b3"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+2 -1
View File
@@ -113,7 +113,8 @@ def _generate_source_table_code(
entries = ", ".join(var_names)
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{")
lines.append(f' if (index == 0 || index > {count}) return LOG_STR("<unknown>");')
cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}"
lines.append(f' if ({cond}) return LOG_STR("<unknown>");')
lines.append(" return reinterpret_cast<const LogString *>(")
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
lines.append("}")
-3
View File
@@ -65,9 +65,6 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ.setdefault("UV_HTTP_RETRIES", "10")
cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
if not CORE.verbose:
kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES
return run_external_process(*cmd, **kwargs)
+30
View File
@@ -105,6 +105,36 @@ def main() -> int:
patch_structhash()
patch_file_downloader()
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
#
# 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and
# PlatformIO's own progress-bar code check ``stream.isatty()`` to
# decide whether to emit TTY-format output (``\r`` cursor moves, ANSI
# colors, fancy progress bars). With the wrapper in place they always
# emit TTY format, even when our real stdout is a pipe to the parent
# process. Downstream consumers (local terminals and the Home
# Assistant dashboard log viewer) render the TTY control sequences
# correctly, so the user sees real progress bars.
#
# 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in
# this subprocess, so noisy PlatformIO output is dropped before it
# ever leaves the runner. This replaces the parent-side filtering
# that was lost when we switched from in-process to subprocess — the
# parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and
# bypasses its ``write()`` path entirely.
#
# Filtering is disabled when the user passed -v / --verbose to
# ``esphome compile``, preserving the previous in-process behavior where
# verbose mode let all PlatformIO output through unfiltered.
from esphome.platformio_api import FILTER_PLATFORMIO_LINES
from esphome.util import RedirectText
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:])
filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES
sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines)
sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines)
import platformio.__main__
return platformio.__main__.main() or 0
+2 -2
View File
@@ -133,7 +133,7 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
; This are common settings for the ESP32 (all variants) using Arduino.
[common:esp32-arduino]
extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
@@ -169,7 +169,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script
; This are common settings for the ESP32 (all variants) using IDF.
[common:esp32-idf]
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
+1 -1
View File
@@ -12,7 +12,7 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.2
esphome-dashboard==20260408.1
aioesphomeapi==44.13.3
aioesphomeapi==44.15.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
+16 -4
View File
@@ -1028,7 +1028,8 @@ class BytesType(TypeInfo):
)
def get_size_calculation(self, name: str, force: bool = False) -> str:
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
calc_fn = "calc_length_force" if force else "calc_length"
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
def get_estimated_size(self) -> int:
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes
@@ -1109,7 +1110,8 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
)
def get_size_calculation(self, name: str, force: bool = False) -> str:
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);"
calc_fn = "calc_length_force" if force else "calc_length"
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);"
class PointerToStringBufferType(PointerToBufferTypeBase):
@@ -2679,6 +2681,16 @@ def build_message_type(
and get_opt(desc, inline_opt, False)
)
# Check if this message wants speed-optimized encode/calculate_size.
# When set, __attribute__((optimize("O2"))) is added to the definitions
# so GCC inlines the small ProtoEncode helpers even under -Os.
is_speed_optimized = get_opt(desc, pb.speed_optimized, False)
speed_attr = (
'__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n'
if is_speed_optimized
else ""
)
# Only generate encode method if this message needs encoding and has fields
if needs_encode and encode and not is_inline_only:
# Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls
@@ -2688,7 +2700,7 @@ def build_message_type(
)
for line in encode
]
o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
o += " uint8_t *__restrict__ pos = buffer.get_pos();\n"
o += indent("\n".join(encode_debug)) + "\n"
o += " return pos;\n"
@@ -2702,7 +2714,7 @@ def build_message_type(
# Add calculate_size method only if this message needs encoding and has fields
if needs_encode and size_calc and not is_inline_only:
o = f"uint32_t {desc.name}::calculate_size() const {{\n"
o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n"
o += " uint32_t size = 0;\n"
o += indent("\n".join(size_calc)) + "\n"
o += " return size;\n"
@@ -1106,6 +1106,51 @@ def test_packages_invalid_type_raises() -> None:
do_packages_pass(config)
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a list, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = ([package_content], None)
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a dict, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = ({"network": package_content}, None)
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_invalid_type_raises(
mock_resolve_include,
) -> None:
"""When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised."""
include_file = MagicMock(spec=IncludeFile)
mock_resolve_include.return_value = ("not_a_dict_or_list", None)
config = {CONF_PACKAGES: include_file}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
) as exc_info:
do_packages_pass(config)
assert exc_info.value.path == [CONF_PACKAGES]
@pytest.mark.parametrize(
"invalid_package",
[
+8
View File
@@ -4,6 +4,14 @@ esphome:
- globals.set:
id: glob_int
value: "10"
# Set a float global with an integer literal - must emit the correct
# return type so TemplatableFn stores a direct function pointer.
- globals.set:
id: glob_float
value: "102"
- globals.set:
id: glob_float
value: !lambda "return 42;"
globals:
- id: glob_int
@@ -0,0 +1,29 @@
esphome:
name: addr-light-transition
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
light:
- platform: mock_addressable_light
output_id: strip_output
id: strip
name: "Test Strip"
num_leds: 4
gamma_correct: 2.8
default_transition_length: 0s
sensor:
- platform: template
name: "led0_red_raw"
id: led0_red_raw
update_interval: 10ms
accuracy_decimals: 0
lambda: |-
return (float) id(strip_output).get_raw_red(0);
@@ -0,0 +1 @@
CODEOWNERS = ["@esphome/tests"]
@@ -0,0 +1,23 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID
from esphome.types import ConfigType
mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light")
MockAddressableLight = mock_addressable_light_ns.class_(
"MockAddressableLight", light.AddressableLight
)
CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight),
cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int,
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS])
await light.register_light(var, config)
await cg.register_component(var, config)
@@ -0,0 +1,52 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include "esphome/components/light/addressable_light.h"
#include "esphome/core/component.h"
namespace esphome::mock_addressable_light {
// In-memory addressable light for host-mode integration tests. Exposes the raw
// per-LED byte buffer (post-gamma-correction, as the hardware would see it)
// so tests can observe transition behavior without real hardware.
class MockAddressableLight : public light::AddressableLight {
public:
explicit MockAddressableLight(uint16_t num_leds)
: num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {}
void setup() override {}
void write_state(light::LightState *state) override {}
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override {
for (uint16_t i = 0; i < this->num_leds_; i++)
this->effect_data_[i] = 0;
}
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::RGB});
return traits;
}
// Accessors for tests: return the raw stored byte (post gamma correction),
// which is what actual LED hardware would receive.
uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; }
uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; }
uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; }
uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; }
protected:
light::ESPColorView get_view_internal(int32_t index) const override {
size_t pos = index * 4;
return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2,
this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_};
}
uint16_t num_leds_;
std::unique_ptr<uint8_t[]> buf_;
std::unique_ptr<uint8_t[]> effect_data_;
};
} // namespace esphome::mock_addressable_light
@@ -0,0 +1,119 @@
"""Integration test for addressable light transitions with gamma correction.
Regression test for a bug where a long turn-on transition on an addressable
light with gamma correction (e.g. gamma_correct: 2.8) produced no visible
output for ~90% of the transition duration, then jumped to the target in the
final ~10%. Root cause: the transition algorithm read each LED's current value
back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value
below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until
progress was high enough for a single step to produce a large-enough pre-gamma
value to clear the gamma threshold.
The fix interpolates against a cached start color when all LEDs started at the
same value (the common case for plain turn_on/turn_off), avoiding the round-trip.
This test uses a host-only mock addressable light that exposes the raw stored
byte of each LED, so we can observe the transition directly.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import LightInfo, SensorInfo, SensorState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_addressable_light_transition(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With gamma 2.8, the stored raw byte must rise visibly well before the end."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
light = require_entity(entities, "test_strip", LightInfo)
sensor = require_entity(entities, "led0_red_raw", SensorInfo)
# Track the raw-byte sensor. It polls every 10ms in the fixture, and
# ESPHome sensors publish on every change, so we collect a time series.
# Samples are stored as absolute (loop_time, value); we rebase to the
# command-issue time after the run so pre-command samples are strictly
# negative and reliably excluded.
loop = asyncio.get_running_loop()
samples: list[tuple[float, float]] = []
def on_state(state: object) -> None:
if not isinstance(state, SensorState) or state.key != sensor.key:
return
samples.append((loop.time(), state.state))
# InitialStateHelper swallows the first state ESPHome sends per entity
# on subscribe, so on_state only sees real post-subscribe updates.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
# Start transition: off -> full white over 1 second. This is the
# scenario from the bug report, compressed in time.
transition_s = 1.0
command_time = loop.time()
client.light_command(
key=light.key,
state=True,
rgb=(1.0, 1.0, 1.0),
brightness=1.0,
transition_length=transition_s,
)
# Let the full transition run, plus margin for the final sample.
await asyncio.sleep(transition_s + 0.2)
# Rebase to command-issue time. Pre-command samples have t < 0 and are
# excluded; everything else is in seconds since the command was issued.
post_command = [
(t - command_time, v) for (t, v) in samples if t >= command_time
]
assert post_command, "no sensor samples received after command was issued"
# Assertion 1: the transition is not stalled. With the bug, the raw
# byte stays at 0 until ~90% of the transition duration. With the fix,
# it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76
# clears the gamma threshold at progress ~0.30). Require the first
# nonzero sample to land well before 50% of the transition duration,
# measured from the command-issue time. The 50% bound (rather than
# 70%) leaves headroom for assertion 2's mid-window check.
first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None)
assert first_nonzero is not None, (
"raw byte never rose above 0 during the transition — the fade stalled"
)
assert first_nonzero[0] < transition_s * 0.5, (
f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s "
f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling"
)
# Assertion 2: by mid-late transition, the raw byte should have reached
# a substantial fraction of its final value. Bound the window to
# [50%, 90%] of the transition so the post-transition settled value
# (which always reaches 255) can't satisfy this assertion — that would
# let "stays at 0 then jumps at 99%" regressions slip through.
mid_window = [
v
for (t, v) in post_command
if transition_s * 0.5 <= t <= transition_s * 0.9
]
assert mid_window, "no samples captured in mid-transition window"
assert max(mid_window) >= 100, (
f"raw byte peaked at only {max(mid_window)} between 50%90% of "
"transition (expected >= 100 for white target at gamma 2.8)"
)
# Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255.
final_samples = [v for (_, v) in post_command[-5:]]
assert max(final_samples) >= 250, (
f"final raw byte was {max(final_samples)}, expected >= 250"
)
@@ -0,0 +1,3 @@
wifi:
password: pkg_password
ssid: main_ssid
@@ -0,0 +1,4 @@
packages: !include 13-packages_list.yaml
wifi:
ssid: main_ssid
@@ -0,0 +1,2 @@
- wifi:
password: pkg_password
@@ -0,0 +1,3 @@
wifi:
password: pkg_password
ssid: main_ssid
@@ -0,0 +1,4 @@
packages: !include 14-packages_dict.yaml
wifi:
ssid: main_ssid
@@ -0,0 +1,3 @@
network:
wifi:
password: pkg_password
+42
View File
@@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion(
assert partitions_path.endswith("partitions.bin")
def test_upload_using_esptool_skips_missing_extra_flash_images(
tmp_path: Path,
mock_run_external_command_main: Mock,
mock_get_idedata: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A non-existent path in extra_flash_images must be filtered out with a
warning, and must not appear in the esptool command line. Only the valid
images are flashed. Regression test for
https://github.com/esphome/esphome/issues/15634.
"""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin"
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = [
platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
platformio_api.FlashImage(path=missing_path, offset="0x2d0000"),
]
mock_get_idedata.return_value = mock_idedata
(tmp_path / "firmware.bin").touch()
(tmp_path / "bootloader.bin").touch()
# Intentionally do NOT create missing_path
config = {CONF_ESPHOME: {"platformio_options": {}}}
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
assert result == 0
assert "Skipping missing flash image" in caplog.text
assert str(missing_path) in caplog.text
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
assert str(missing_path) not in cmd_list
assert "0x2d0000" not in cmd_list
def test_upload_using_esptool_with_file_path(
tmp_path: Path,
mock_run_external_command_main: Mock,