mirror of
https://github.com/esphome/esphome.git
synced 2026-06-01 01:19:45 +08:00
+1
-1
@@ -1 +1 @@
|
|||||||
d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf
|
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
|||||||
# could be handy for archiving the generated documentation or if some version
|
# could be handy for archiving the generated documentation or if some version
|
||||||
# control system is used.
|
# 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
|
# 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
|
# for a project that appears at the top of each page and should give viewer a
|
||||||
|
|||||||
+8
-1
@@ -750,8 +750,15 @@ def upload_using_esptool(
|
|||||||
platformio_api.FlashImage(
|
platformio_api.FlashImage(
|
||||||
path=idedata.firmware_bin_path, offset=firmware_offset
|
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"
|
mcu = "esp8266"
|
||||||
if CORE.is_esp32:
|
if CORE.is_esp32:
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import logging
|
|||||||
|
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
from esphome.components import sensor, voltage_sampler
|
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.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||||
from esphome.components.zephyr import (
|
from esphome.components.zephyr import (
|
||||||
zephyr_add_overlay,
|
zephyr_add_overlay,
|
||||||
@@ -24,6 +28,7 @@ from esphome.const import (
|
|||||||
PlatformFramework,
|
PlatformFramework,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE
|
from esphome.core import CORE
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
ATTENUATION_MODES,
|
ATTENUATION_MODES,
|
||||||
@@ -65,6 +70,13 @@ def validate_config(config):
|
|||||||
return 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 = adc_ns.class_(
|
||||||
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
|
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
|
||||||
)
|
)
|
||||||
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
)
|
)
|
||||||
.extend(cv.polling_component_schema("60s")),
|
.extend(cv.polling_component_schema("60s")),
|
||||||
validate_config,
|
validate_config,
|
||||||
|
_require_adc_iram,
|
||||||
)
|
)
|
||||||
|
|
||||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||||
|
|||||||
@@ -671,6 +671,7 @@ message SensorStateResponse {
|
|||||||
option (source) = SOURCE_SERVER;
|
option (source) = SOURCE_SERVER;
|
||||||
option (ifdef) = "USE_SENSOR";
|
option (ifdef) = "USE_SENSOR";
|
||||||
option (no_delay) = true;
|
option (no_delay) = true;
|
||||||
|
option (speed_optimized) = true;
|
||||||
|
|
||||||
fixed32 key = 1 [(force) = true];
|
fixed32 key = 1 [(force) = true];
|
||||||
float state = 2;
|
float state = 2;
|
||||||
@@ -777,9 +778,10 @@ message SubscribeLogsResponse {
|
|||||||
option (source) = SOURCE_SERVER;
|
option (source) = SOURCE_SERVER;
|
||||||
option (log) = false;
|
option (log) = false;
|
||||||
option (no_delay) = false;
|
option (no_delay) = false;
|
||||||
|
option (speed_optimized) = true;
|
||||||
|
|
||||||
LogLevel level = 1;
|
LogLevel level = 1 [(force) = true];
|
||||||
bytes message = 3;
|
bytes message = 3 [(force) = true];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== NOISE ENCRYPTION ====================
|
// ==================== NOISE ENCRYPTION ====================
|
||||||
@@ -1638,6 +1640,7 @@ message BluetoothLERawAdvertisementsResponse {
|
|||||||
option (source) = SOURCE_SERVER;
|
option (source) = SOURCE_SERVER;
|
||||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||||
option (no_delay) = true;
|
option (no_delay) = true;
|
||||||
|
option (speed_optimized) = true;
|
||||||
|
|
||||||
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
|
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions {
|
|||||||
optional bool no_delay = 1040 [default=false];
|
optional bool no_delay = 1040 [default=false];
|
||||||
optional string base_class = 1041;
|
optional string base_class = 1041;
|
||||||
optional bool inline_encode = 1042 [default=false];
|
optional bool inline_encode = 1042 [default=false];
|
||||||
|
optional bool speed_optimized = 1043 [default=false];
|
||||||
}
|
}
|
||||||
|
|
||||||
extend google.protobuf.FieldOptions {
|
extend google.protobuf.FieldOptions {
|
||||||
|
|||||||
@@ -745,7 +745,9 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
|
|||||||
#endif
|
#endif
|
||||||
return size;
|
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();
|
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||||
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
|
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
|
||||||
ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state);
|
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
|
#endif
|
||||||
return pos;
|
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;
|
uint32_t size = 0;
|
||||||
size += 5;
|
size += 5;
|
||||||
size += ProtoSize::calc_float(1, this->state);
|
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;
|
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();
|
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level));
|
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level), true);
|
||||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_);
|
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;
|
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;
|
uint32_t size = 0;
|
||||||
size += this->level ? 2 : 0;
|
size += 2;
|
||||||
size += ProtoSize::calc_length(1, this->message_len_);
|
size += ProtoSize::calc_length_force(1, this->message_len_);
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
@@ -2328,7 +2338,9 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
|
|||||||
}
|
}
|
||||||
return true;
|
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();
|
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||||
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
||||||
auto &sub_msg = this->advertisements[i];
|
auto &sub_msg = this->advertisements[i];
|
||||||
@@ -2350,7 +2362,9 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P
|
|||||||
}
|
}
|
||||||
return pos;
|
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;
|
uint32_t size = 0;
|
||||||
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
for (uint16_t i = 0; i < this->advertisements_len; i++) {
|
||||||
auto &sub_msg = this->advertisements[i];
|
auto &sub_msg = this->advertisements[i];
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent,
|
|||||||
#endif
|
#endif
|
||||||
float get_reference_voltage(uint8_t phase) {
|
float get_reference_voltage(uint8_t phase) {
|
||||||
#ifdef USE_NUMBER
|
#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
|
#else
|
||||||
return 120.0; // Default voltage
|
return 120.0; // Default voltage
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
float get_reference_current(uint8_t phase) {
|
float get_reference_current(uint8_t phase) {
|
||||||
#ifdef USE_NUMBER
|
#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
|
#else
|
||||||
return 5.0f; // Default current
|
return 5.0f; // Default current
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -676,7 +676,7 @@ ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
|
|||||||
"dev": cv.Version(3, 3, 8),
|
"dev": cv.Version(3, 3, 8),
|
||||||
}
|
}
|
||||||
ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
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, 7): cv.Version(55, 3, 37),
|
||||||
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
|
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
|
||||||
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
|
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
|
||||||
@@ -724,7 +724,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
|||||||
cv.Version(
|
cv.Version(
|
||||||
6, 0, 0
|
6, 0, 0
|
||||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
): "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, "1"): cv.Version(55, 3, 37),
|
||||||
cv.Version(5, 5, 3): 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),
|
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
|
||||||
@@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
|||||||
# The platform-espressif32 version
|
# The platform-espressif32 version
|
||||||
# - https://github.com/pioarduino/platform-espressif32/releases
|
# - https://github.com/pioarduino/platform-espressif32/releases
|
||||||
PLATFORM_VERSION_LOOKUP = {
|
PLATFORM_VERSION_LOOKUP = {
|
||||||
"recommended": cv.Version(55, 3, 38),
|
"recommended": cv.Version(55, 3, 38, "1"),
|
||||||
"latest": cv.Version(55, 3, 38),
|
"latest": cv.Version(55, 3, 38, "1"),
|
||||||
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
|
"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_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
|
||||||
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
|
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
|
||||||
CONF_DISABLE_FATFS = "disable_fatfs"
|
CONF_DISABLE_FATFS = "disable_fatfs"
|
||||||
|
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
|
||||||
|
|
||||||
# VFS requirement tracking
|
# VFS requirement tracking
|
||||||
# Components that need VFS features can call require_vfs_*() functions
|
# 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_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
|
||||||
KEY_FATFS_REQUIRED = "fatfs_required"
|
KEY_FATFS_REQUIRED = "fatfs_required"
|
||||||
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
|
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
|
||||||
|
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
|
||||||
|
|
||||||
|
|
||||||
def require_vfs_select() -> None:
|
def require_vfs_select() -> None:
|
||||||
@@ -1168,6 +1170,17 @@ def require_fatfs() -> None:
|
|||||||
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
|
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:
|
def _parse_idf_component(value: str) -> ConfigType:
|
||||||
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
|
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
|
||||||
# Match operator followed by version-like string (digit or *)
|
# 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_PEER_CERT, default=True): cv.boolean,
|
||||||
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, 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_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,
|
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]:
|
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
|
||||||
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
|
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
|
# Disable FATFS support
|
||||||
# Components that need FATFS (SD card, etc.) can call require_fatfs()
|
# Components that need FATFS (SD card, etc.) can call require_fatfs()
|
||||||
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
|
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
|
||||||
|
|||||||
@@ -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])
|
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
|
||||||
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
|
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
|
||||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
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(
|
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))
|
cg.add(var.set_value(templ))
|
||||||
return var
|
return var
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
|
|||||||
)
|
)
|
||||||
|
|
||||||
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
|
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):
|
def _validate_esp32_variant(config):
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ void AddressableLightTransformer::start() {
|
|||||||
// our transition will handle brightness, disable brightness in correction.
|
// our transition will handle brightness, disable brightness in correction.
|
||||||
this->light_.correction_.set_local_brightness(255);
|
this->light_.correction_.set_local_brightness(255);
|
||||||
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
|
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) {
|
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.
|
// non-linear when applying small deltas.
|
||||||
|
|
||||||
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
|
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));
|
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
|
||||||
for (auto led : this->light_) {
|
// frame buffer is valid. When every LED already has the same color (the common case: plain
|
||||||
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
|
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
|
||||||
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
|
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
|
||||||
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
|
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
|
||||||
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
|
// 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->last_transition_progress_ = smoothed_progress;
|
||||||
this->light_.schedule_show();
|
this->light_.schedule_show();
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer {
|
|||||||
AddressableLight &light_;
|
AddressableLight &light_;
|
||||||
float last_transition_progress_{0.0f};
|
float last_transition_progress_{0.0f};
|
||||||
Color target_color_{};
|
Color target_color_{};
|
||||||
|
Color uniform_start_color_{};
|
||||||
|
bool uniform_start_scanned_{false};
|
||||||
|
bool uniform_start_is_uniform_{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace esphome::light
|
} // 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")
|
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)
|
# 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_STATIC_MEMORY")
|
||||||
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
|
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ void AirConditioner::on_status_change() {
|
|||||||
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
|
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
|
||||||
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
|
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
|
||||||
// Read existing presets (set by codegen), append frost protection, write back
|
// 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;
|
bool found = false;
|
||||||
for (const char *p : existing) {
|
for (const char *p : existing) {
|
||||||
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {
|
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {
|
||||||
|
|||||||
@@ -234,9 +234,9 @@ class MipiSpi : public display::Display,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void dump_config() override {
|
void dump_config() override {
|
||||||
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL,
|
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
|
||||||
this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_,
|
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
|
||||||
this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
|
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
|
||||||
HAS_HARDWARE_ROTATION);
|
HAS_HARDWARE_ROTATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ class MipiSpi : public display::Display,
|
|||||||
this->write_command_(BRIGHTNESS, this->brightness_.value());
|
this->write_command_(BRIGHTNESS, this->brightness_.value());
|
||||||
|
|
||||||
// calculate new madctl value from base value adjusted for rotation
|
// 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 bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0;
|
||||||
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
|
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
|
||||||
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;
|
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||||
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
|
const uint32_t now = App.get_loop_component_start_time();
|
||||||
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
#endif // USE_NEXTION_COMMAND_SPACING
|
#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};
|
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
|
||||||
this->write_array(to_send, sizeof(to_send));
|
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;
|
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())
|
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (this->send_command_(command)) {
|
this->add_no_result_to_queue_with_command_("command", command);
|
||||||
this->add_no_result_to_queue_("command");
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Nextion::send_command_printf(const char *format, ...) {
|
bool Nextion::send_command_printf(const char *format, ...) {
|
||||||
@@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this->send_command_(buffer)) {
|
this->add_no_result_to_queue_with_command_("command_printf", buffer);
|
||||||
this->add_no_result_to_queue_("command_printf");
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef NEXTION_PROTOCOL_LOG
|
#ifdef NEXTION_PROTOCOL_LOG
|
||||||
@@ -349,25 +354,43 @@ void Nextion::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||||
// Try to send any pending commands if spacing allows
|
|
||||||
this->process_pending_in_queue_();
|
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
|
#endif // USE_NEXTION_COMMAND_SPACING
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||||
void Nextion::process_pending_in_queue_() {
|
void Nextion::process_pending_in_queue_() {
|
||||||
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
|
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||||
return;
|
size_t commands_sent = 0;
|
||||||
}
|
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||||
|
|
||||||
// Check if first item in queue has a pending command
|
for (auto *item : this->nextion_queue_) {
|
||||||
auto *front_item = this->nextion_queue_.front();
|
if (item == nullptr || item->pending_command.empty()) {
|
||||||
if (front_item && !front_item->pending_command.empty()) {
|
continue; // Already sent, waiting for ACK — skip, don't stop
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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
|
#endif // USE_NEXTION_COMMAND_SPACING
|
||||||
@@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() {
|
|||||||
this->setup_callback_.call();
|
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;
|
break;
|
||||||
case 0x02: // invalid Component ID or name was used
|
case 0x02: // invalid Component ID or name was used
|
||||||
ESP_LOGW(TAG, "Invalid component ID/name");
|
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
|
* Callers are responsible for checking is_sleeping() before calling this
|
||||||
* @param command
|
* 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) {
|
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())
|
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();
|
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)) {
|
if (this->send_command_(command)) {
|
||||||
this->nextion_queue_.push_back(nextion_queue);
|
this->nextion_queue_.push_back(nextion_queue);
|
||||||
|
} else {
|
||||||
|
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
|
||||||
}
|
}
|
||||||
|
#endif // USE_NEXTION_COMMAND_SPACING
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef USE_NEXTION_WAVEFORM
|
#ifdef USE_NEXTION_WAVEFORM
|
||||||
@@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() {
|
|||||||
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
|
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(),
|
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
|
||||||
component->get_wave_channel_id(), buffer_to_send);
|
component->get_wave_channel_id(), buffer_to_send);
|
||||||
if (!this->send_command_(command)) {
|
// If spacing or setup state blocks the send, leave the entry at the front
|
||||||
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
|
// of waveform_queue_ for retry on the next loop iteration via
|
||||||
this->waveform_queue_.pop();
|
// check_pending_waveform_(). Only pop on a successful send.
|
||||||
}
|
this->send_command_(command);
|
||||||
}
|
}
|
||||||
#endif // USE_NEXTION_WAVEFORM
|
#endif // USE_NEXTION_WAVEFORM
|
||||||
|
|
||||||
|
|||||||
@@ -55,15 +55,20 @@ class NextionCommandPacer {
|
|||||||
uint8_t get_spacing() const { return spacing_ms_; }
|
uint8_t get_spacing() const { return spacing_ms_; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Check if enough time has passed to send next command
|
* @brief Check if enough time has passed to send the next command.
|
||||||
* @return true if enough time has passed since last 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:
|
private:
|
||||||
uint8_t spacing_ms_;
|
uint8_t spacing_ms_;
|
||||||
|
|||||||
@@ -321,12 +321,15 @@ def _walk_packages(
|
|||||||
return config
|
return config
|
||||||
packages = config[CONF_PACKAGES]
|
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):
|
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):
|
if not isinstance(packages, dict):
|
||||||
_walk_package_list(packages, callback, context)
|
_walk_package_list(packages, callback, context)
|
||||||
elif (result := _walk_package_dict(packages, callback, context)) is not None:
|
elif (result := _walk_package_dict(packages, callback, context)) is not None:
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration
|
|||||||
my_integration_time_regval = integration_time;
|
my_integration_time_regval = integration_time;
|
||||||
this->integration_time_auto_ = false;
|
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_);
|
ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_);
|
||||||
}
|
}
|
||||||
void TCS34725Component::set_gain(TCS34725Gain gain) {
|
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) {
|
uint8_t *data, size_t len, bool final) {
|
||||||
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
|
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
|
// Initialize OTA on first call
|
||||||
this->ota_init_(filename.c_str());
|
this->ota_init_(filename.c_str());
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
|||||||
|
|
||||||
from esphome.enum import StrEnum
|
from esphome.enum import StrEnum
|
||||||
|
|
||||||
__version__ = "2026.4.0b2"
|
__version__ = "2026.4.0b3"
|
||||||
|
|
||||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ def _generate_source_table_code(
|
|||||||
entries = ", ".join(var_names)
|
entries = ", ".join(var_names)
|
||||||
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
|
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
|
||||||
lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{")
|
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(" return reinterpret_cast<const LogString *>(")
|
||||||
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
|
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
|
||||||
lines.append("}")
|
lines.append("}")
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
|||||||
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
||||||
cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
|
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)
|
return run_external_process(*cmd, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,36 @@ def main() -> int:
|
|||||||
patch_structhash()
|
patch_structhash()
|
||||||
patch_file_downloader()
|
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__
|
import platformio.__main__
|
||||||
|
|
||||||
return platformio.__main__.main() or 0
|
return platformio.__main__.main() or 0
|
||||||
|
|||||||
+2
-2
@@ -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.
|
; This are common settings for the ESP32 (all variants) using Arduino.
|
||||||
[common:esp32-arduino]
|
[common:esp32-arduino]
|
||||||
extends = common: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 =
|
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-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
|
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.
|
; This are common settings for the ESP32 (all variants) using IDF.
|
||||||
[common:esp32-idf]
|
[common:esp32-idf]
|
||||||
extends = common: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 =
|
platform_packages =
|
||||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ platformio==6.1.19
|
|||||||
esptool==5.2.0
|
esptool==5.2.0
|
||||||
click==8.3.2
|
click==8.3.2
|
||||||
esphome-dashboard==20260408.1
|
esphome-dashboard==20260408.1
|
||||||
aioesphomeapi==44.13.3
|
aioesphomeapi==44.15.0
|
||||||
zeroconf==0.148.0
|
zeroconf==0.148.0
|
||||||
puremagic==1.30
|
puremagic==1.30
|
||||||
ruamel.yaml==0.19.1 # dashboard_import
|
ruamel.yaml==0.19.1 # dashboard_import
|
||||||
|
|||||||
@@ -1028,7 +1028,8 @@ class BytesType(TypeInfo):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_size_calculation(self, name: str, force: bool = False) -> str:
|
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:
|
def get_estimated_size(self) -> int:
|
||||||
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes
|
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:
|
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):
|
class PointerToStringBufferType(PointerToBufferTypeBase):
|
||||||
@@ -2679,6 +2681,16 @@ def build_message_type(
|
|||||||
and get_opt(desc, inline_opt, False)
|
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
|
# Only generate encode method if this message needs encoding and has fields
|
||||||
if needs_encode and encode and not is_inline_only:
|
if needs_encode and encode and not is_inline_only:
|
||||||
# Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls
|
# Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls
|
||||||
@@ -2688,7 +2700,7 @@ def build_message_type(
|
|||||||
)
|
)
|
||||||
for line in encode
|
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 += " uint8_t *__restrict__ pos = buffer.get_pos();\n"
|
||||||
o += indent("\n".join(encode_debug)) + "\n"
|
o += indent("\n".join(encode_debug)) + "\n"
|
||||||
o += " return pos;\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
|
# Add calculate_size method only if this message needs encoding and has fields
|
||||||
if needs_encode and size_calc and not is_inline_only:
|
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 += " uint32_t size = 0;\n"
|
||||||
o += indent("\n".join(size_calc)) + "\n"
|
o += indent("\n".join(size_calc)) + "\n"
|
||||||
o += " return size;\n"
|
o += " return size;\n"
|
||||||
|
|||||||
@@ -1106,6 +1106,51 @@ def test_packages_invalid_type_raises() -> None:
|
|||||||
do_packages_pass(config)
|
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(
|
@pytest.mark.parametrize(
|
||||||
"invalid_package",
|
"invalid_package",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ esphome:
|
|||||||
- globals.set:
|
- globals.set:
|
||||||
id: glob_int
|
id: glob_int
|
||||||
value: "10"
|
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:
|
globals:
|
||||||
- id: glob_int
|
- 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)
|
||||||
+52
@@ -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
|
||||||
@@ -1231,6 +1231,48 @@ def test_upload_using_esptool_path_conversion(
|
|||||||
assert partitions_path.endswith("partitions.bin")
|
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(
|
def test_upload_using_esptool_with_file_path(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
mock_run_external_command_main: Mock,
|
mock_run_external_command_main: Mock,
|
||||||
|
|||||||
Reference in New Issue
Block a user