Merge remote-tracking branch 'upstream/dev' into HEAD

This commit is contained in:
J. Nick Koston
2026-09-17 08:48:03 -05:00
62 changed files with 1025 additions and 384 deletions
+8 -5
View File
@@ -90,9 +90,10 @@ def get_project_cmakelists(
"""
idf_target = variant_to_idf_target(get_esp32_variant())
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
# esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng;
# 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks
# total_size, hence the ELF fallback in espidf/size_summary.py; both
# go away together when 1.x support is dropped.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
@@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
+1 -1
View File
@@ -339,7 +339,7 @@ async def to_code(config: ConfigType) -> None:
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0")
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1")
data = _get_data()
@@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput {
void write_state(light::LightState *state) override {
bool binary;
state->current_values_as_binary(&binary);
if (binary) {
this->output_->turn_on();
} else {
this->output_->turn_off();
}
this->output_->set_state(binary);
}
protected:
@@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
// explicit kick the MTU would only be exchanged on the first GATT query,
// which never happens on a V3_WITH_CACHE connection.
// Both registration calls above return void (BTstack 075a078, arduino-pico
// 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by
// 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by
// the connect timeout in loop().
gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_);
}
+42
View File
@@ -111,6 +111,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_FLASH_CHIP = "flash_chip"
CONF_KEY_ID = "key_id"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_NVS_ENCRYPTION = "nvs_encryption"
@@ -464,6 +465,20 @@ ESP32_CHIP_REVISIONS = {
"3.1": "CONFIG_ESP32_REV_MIN_3_1",
}
# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM
# and only the one matching the flash ID is ever used
ESP32_FLASH_CHIPS = {
"gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP",
"issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP",
"mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP",
"winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP",
"boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP",
"th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP",
"mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP",
}
FLASH_CHIP_GENERIC = "generic"
FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only
# Socket limit configuration for ESP-IDF
# ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10
DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default
@@ -1533,6 +1548,25 @@ def final_validate(config) -> None:
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM],
)
)
if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None:
opi = flash_chip == FLASH_CHIP_OPI
if opi and config[CONF_VARIANT] != VARIANT_ESP32S3:
errs.append(
cv.Invalid(
f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP],
)
)
elif opi != (config.get(CONF_FLASH_MODE) == "opi"):
errs.append(
cv.Invalid(
f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'"
if opi
else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match "
f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP],
)
)
if (
config[CONF_VARIANT] != VARIANT_ESP32P4
and config.get(CONF_ENGINEERING_SAMPLE) is not None
@@ -1971,6 +2005,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
*ESP32_CHIP_REVISIONS, string=True
),
cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean,
cv.Optional(CONF_FLASH_CHIP): cv.one_of(
FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True
),
# DHCP server is needed for WiFi AP mode. When WiFi component is used,
# it will handle disabling DHCP server when AP is not configured.
# Default to false (disabled) when WiFi is not used.
@@ -2765,6 +2802,11 @@ async def to_code(config):
add_idf_sdkconfig_option(flag, rev == min_rev)
cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET")
# Keep only the flash vendor driver the board needs; the boot log names it
if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None:
for chip, flag in ESP32_FLASH_CHIPS.items():
add_idf_sdkconfig_option(flag, chip == flash_chip)
# Use SRAM1 region as IRAM on ESP32 (original) variant
# This provides an additional 40KB of IRAM by using SRAM1 memory that was previously
# reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later.
+9 -5
View File
@@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash";
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME);
void crash_handler_read_and_clear() {
// Validate the NOINIT record. Runs on every has_data() call; re-running is
// harmless and the magic is left alone so the record survives an OTA
// rollback reboot, crash_handler_clear() drops it once an API client has it.
static void read_crash_data() {
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
s_crash_data_valid = true;
// Clamp counts to prevent out-of-bounds reads from corrupt .noinit data
@@ -194,11 +197,12 @@ void crash_handler_read_and_clear() {
s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count;
#endif
}
// Don't clear magic here — crash data must survive OTA rollback reboots.
// Magic is cleared by crash_handler_clear() after an API client receives the data.
}
bool crash_handler_has_data() { return s_crash_data_valid; }
bool crash_handler_has_data() {
read_crash_data();
return s_crash_data_valid;
}
void crash_handler_clear() {
// Only clear the magic so data doesn't survive the next reboot.
@@ -426,7 +430,7 @@ static void log_foreign_addresses() {
// crashes again during boot, and allowing the CLI's process_stacktrace to match
// and decode each address individually.
void crash_handler_log() {
if (!s_crash_data_valid)
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
+1 -6
View File
@@ -4,11 +4,6 @@
namespace esphome::esp32 {
/// Read and validate crash data from NOINIT memory.
/// Does not clear the magic marker — call crash_handler_clear() after
/// the data has been delivered to an API client so it survives OTA rollback reboots.
void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
@@ -16,7 +11,7 @@ void crash_handler_log();
/// Call after the data has been delivered to an API client.
void crash_handler_clear();
/// Returns true if crash data was found this boot.
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
} // namespace esphome::esp32
-8
View File
@@ -1,9 +1,6 @@
#ifdef USE_ESP32
// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set
// before crash_handler.h's #ifdef-guarded namespace block is parsed.
#include "esphome/core/defines.h"
#include "crash_handler.h"
#include "esphome/core/hal.h"
#include <esp_clk_tree.h>
@@ -45,11 +42,6 @@ void arch_restart() {
}
void arch_init() {
#ifdef USE_ESP32_CRASH_HANDLER
// Read crash data from previous boot before anything else
esp32::crash_handler_read_and_clear();
#endif
// Enable the task watchdog only on the loop task (from which we're currently running)
esp_task_wdt_add(nullptr);
+5 -7
View File
@@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
continue; // skip drawing
}
break;
case TRANSPARENCY_ALPHA_CHANNEL: {
auto on = (float) gray / 255.0f;
auto off = 1.0f - on;
// blend color_on and color_off
color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off,
color_on.b * on + color_off.b * off, 0xFF);
case TRANSPARENCY_ALPHA_CHANNEL:
// gray is the alpha: blend from color_off to color_on, drawn opaque
color = Color(Color::blend_channel(color_off.r, color_on.r, gray),
Color::blend_channel(color_off.g, color_on.g, gray),
Color::blend_channel(color_off.b, color_on.b, gray), 0xFF);
break;
}
default:
break;
}
@@ -208,11 +208,7 @@ void ImprovBLEComponent::set_status_indicator_state_(bool state) {
if (this->status_indicator_state_ == state)
return;
this->status_indicator_state_ = state;
if (state) {
this->status_indicator_->turn_on();
} else {
this->status_indicator_->turn_off();
}
this->status_indicator_->set_state(state);
#endif
}
+2
View File
@@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape)
}
JsonBuilder::JsonBuilder() = default;
SerializationBuffer<> JsonBuilder::serialize() {
// ===========================================================================================
// CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING
+3
View File
@@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) {
/// Builder class for creating JSON documents without lambdas
class JsonBuilder {
public:
// Out of line: inlining the JsonDocument constructor duplicates it at every call site
JsonBuilder();
JsonObject root() {
if (!root_created_) {
root_ = doc_.to<JsonObject>();
@@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() {
return this->state_;
}
void Mcp4461Wiper::set_state(bool state) {
if (state) {
this->turn_on();
} else {
this->turn_off();
}
}
void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); }
void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); }
@@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented<Mcp4461Co
/// @brief Set level of wiper
/// @param[in] state - The desired float level in range 0-1.0
void set_level(float state);
/// @brief Enables/Disables current output using bool parameter
/// @param[in] state boolean var representing desired state (true=ON, false=OFF)
void set_state(bool state) override;
/// @brief Enables current output
void turn_on() override;
/// @brief Disables current output
@@ -385,7 +385,8 @@ void MixerSpeaker::loop() {
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
// Keep a start request that arrived while the task was stopping, otherwise it is lost for good
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START);
this->all_stopped_since_ms_ = 0;
}
@@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode;
using modbus::ModbusRegisterType;
#pragma GCC diagnostic pop
// Remove before 2026.10.0 — these helpers have moved to modbus::helpers
ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0")
inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); }
ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0")
inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) {
return modbus::helpers::modbus_register_read_function(reg_type);
}
ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0")
inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) {
return modbus::helpers::modbus_register_write_function(reg_type);
}
ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0")
inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); }
ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::byte_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::word_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::dword_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::qword_from_hex_str(value, pos);
}
template<typename T>
ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0")
T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return modbus::helpers::get_data<T>(data, buffer_offset);
}
// Span overloads of the deprecated helpers below: read lambdas receive their payload as a
// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a
// std::span<const uint8_t> (previously a const std::vector<uint8_t> &), and a span does not convert to
// a vector, so existing lambdas calling these by name need an overload that accepts one. These carry
// this release's deprecation window, since the span forms only exist from it.
// the 2026.8.0 deprecation window, since the span forms only exist from it.
// payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers
// type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here
// would only make that call ambiguous.
@@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span<const uint8_t> data) {
return modbus::helpers::bit_from_packed(coil, data);
}
template<typename N>
ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0")
N mask_and_shift_by_rightbit(N data, uint32_t mask) {
return modbus::helpers::mask_and_shift_by_rightbit(data, mask);
}
ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
inline void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueType value_type) {
modbus::helpers::number_to_payload(data, value, value_type);
}
ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0")
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask) {
return modbus::helpers::payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask)
.value_or(0);
}
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
std::vector<uint16_t> data;
modbus::helpers::float_to_payload(data, value, value_type);
return data;
}
class ModbusController;
/// How an item relates to the register range built just before it (same register type, address order).
/// The numeric order doubles as the comparator tiebreak for items at the same address (see
/// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and
@@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
}
}
ESP_LOGD(TAG, "Number new state : %.02f", result);
// this->sensor_->raw_state = result;
this->publish_state(result);
}
@@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span<const uint8_t> data) {
}
}
ESP_LOGD(TAG, "Sensor new state: %.02f", result);
// this->sensor_->raw_state = result;
this->publish_state(result);
}
@@ -8,11 +8,7 @@ static const char *const TAG = "output.switch";
void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); }
void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); }
void OutputSwitch::write_state(bool state) {
if (state) {
this->output_->turn_on();
} else {
this->output_->turn_off();
}
this->output_->set_state(state);
this->publish_state(state);
}
@@ -112,20 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema),
cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers,
cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance,
# pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses
cv.SplitDefault(
CONF_BUFFER_SIZE,
esp32=cv.UNDEFINED,
# the pulse ring needs a size; only RMT targets size themselves in setup()
**{
f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b"
f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b"
for variant in esp32_rmt.VARIANTS_NO_RMT
},
esp8266="1000b",
bk72xx="1000b",
ln882x="1000b",
rtl87xx="1000b",
rp2="1000b",
): cv.All(cv.validate_bytes, cv.int_range(min=64)),
esp8266="4000b",
bk72xx="4000b",
ln882x="4000b",
rtl87xx="4000b",
rp2="4000b",
): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)),
cv.Optional(CONF_FILTER, default="50us"): cv.All(
cv.positive_time_period_microseconds,
cv.Range(max=TimePeriod(microseconds=4294967295)),
@@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_
int32_t multiplier = ((int32_t) level << 1) - 1;
uint32_t buffer_write = arg->buffer_write;
arg->buffer[buffer_write++] = (int32_t) delta * multiplier;
if (buffer_write >= arg->buffer_size) {
if (buffer_write >= arg->buffer_entries) {
buffer_write = 0;
}
@@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() {
this->store_.idle_us = this->idle_us_;
this->store_.filter_us = this->filter_us_;
this->store_.pin = this->pin_->to_isr();
this->store_.buffer = new int32_t[this->buffer_size_];
this->store_.buffer_size = this->buffer_size_;
// rounded up so a size that is not a multiple of four never holds less than requested
this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t);
this->store_.buffer = new int32_t[this->store_.buffer_entries];
this->store_.prev_micros = micros();
this->store_.commit_micros = this->store_.prev_micros;
this->store_.prev_level = this->pin_->digital_read();
@@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() {
ESP_LOGCONFIG(
TAG,
"Remote Receiver:\n"
" Buffer Size: %" PRIu32 "\n"
" Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n"
" Tolerance: %" PRIu32 "%s\n"
" Filter out pulses shorter than: %" PRIu32 " us\n"
" Signal is done after %" PRIu32 " us of no changes",
this->buffer_size_, this->tolerance_,
this->buffer_size_, this->store_.buffer_entries, this->tolerance_,
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"),
this->filter_us_, this->idle_us_);
LOG_PIN(" Pin: ", this->pin_);
@@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() {
while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) {
reserve_size++;
temp_read++;
if (temp_read >= s.buffer_size) {
if (temp_read >= s.buffer_entries) {
temp_read = 0;
}
}
@@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() {
// read the buffer
for (uint32_t i = 0; i < reserve_size + 1; i++) {
this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]);
if (s.buffer_read >= s.buffer_size) {
if (s.buffer_read >= s.buffer_entries) {
s.buffer_read = 0;
}
}
@@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore {
uint32_t buffer_read{0};
volatile uint32_t commit_micros{0};
volatile uint32_t prev_micros{0};
uint32_t buffer_size{1000};
uint32_t buffer_entries{0};
uint32_t filter_us{10};
uint32_t idle_us{10000};
ISRInternalGPIOPin pin;
@@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase,
protected:
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void decode_rmt_(rmt_symbol_word_t *item, size_t item_count);
// log the failed RMT call and mark the component failed
void fail_(esp_err_t error, const LogString *reason);
rmt_channel_handle_t channel_{NULL};
uint32_t filter_symbols_{0};
uint32_t receive_symbols_{0};
bool with_dma_{false};
uint32_t carrier_frequency_{0};
uint8_t carrier_duty_percent_{100};
esp_err_t error_code_{ESP_OK};
std::string error_string_;
#endif
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32)
@@ -43,6 +43,11 @@ static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_r
return task_woken != pdFALSE;
}
void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) {
ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error));
this->mark_failed(reason);
}
void RemoteReceiverComponent::setup() {
rmt_rx_channel_config_t channel;
memset(&channel, 0, sizeof(channel));
@@ -55,13 +60,8 @@ void RemoteReceiverComponent::setup() {
channel.flags.with_dma = this->with_dma_;
esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
if (error == ESP_ERR_NOT_FOUND) {
this->error_string_ = "out of RMT symbol memory";
} else {
this->error_string_ = "in rmt_new_rx_channel";
}
this->mark_failed();
this->fail_(error,
error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel"));
return;
}
if (this->pin_->get_flags() & gpio::FLAG_PULLUP) {
@@ -71,9 +71,7 @@ void RemoteReceiverComponent::setup() {
}
error = rmt_enable(this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_enable";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_enable"));
return;
}
@@ -85,9 +83,7 @@ void RemoteReceiverComponent::setup() {
carrier.flags.polarity_active_low = this->pin_->is_inverted();
error = rmt_apply_carrier(this->channel_, &carrier);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_apply_carrier";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_apply_carrier"));
return;
}
}
@@ -97,9 +93,7 @@ void RemoteReceiverComponent::setup() {
callbacks.on_recv_done = rmt_callback;
error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_rx_register_event_callbacks";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks"));
return;
}
@@ -122,9 +116,7 @@ void RemoteReceiverComponent::setup() {
error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size,
&this->store_.config);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_receive";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_receive"));
return;
}
}
@@ -148,18 +140,11 @@ void RemoteReceiverComponent::dump_config() {
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"),
this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_);
LOG_PIN(" Pin: ", this->pin_);
if (this->is_failed()) {
ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_),
this->error_string_.c_str());
}
}
void RemoteReceiverComponent::loop() {
if (this->store_.error != ESP_OK) {
ESP_LOGE(TAG, "Receive error");
this->error_code_ = this->store_.error;
this->error_string_ = "in rmt_callback";
this->mark_failed();
this->fail_(this->store_.error, LOG_STR("in rmt_callback"));
}
if (this->store_.overflow) {
ESP_LOGW(TAG, "Buffer overflow");
@@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
#endif
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
// log the failed RMT call and mark the component failed
void fail_(esp_err_t error, const LogString *reason);
void configure_rmt_();
void wait_for_rmt_();
@@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
bool eot_level_{false};
rmt_channel_handle_t channel_{NULL};
rmt_encoder_handle_t encoder_{NULL};
esp_err_t error_code_{ESP_OK};
std::string error_string_;
bool inverted_{false};
bool non_blocking_{false};
#endif
@@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size
}
#endif
void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) {
ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error));
this->mark_failed(reason);
}
void RemoteTransmitterComponent::setup() {
this->inverted_ = this->pin_->is_inverted();
this->configure_rmt_();
@@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() {
if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) {
ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_);
}
if (this->is_failed()) {
ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_),
this->error_string_.c_str());
}
}
void RemoteTransmitterComponent::digital_write(bool value) {
@@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() {
#endif
error = rmt_new_tx_channel(&channel, &this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
if (error == ESP_ERR_NOT_FOUND) {
this->error_string_ = "out of RMT symbol memory";
} else {
this->error_string_ = "in rmt_new_tx_channel";
}
this->mark_failed();
this->fail_(error,
error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel"));
return;
}
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
@@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() {
encoder.min_chunk_size = 1;
error = rmt_new_simple_encoder(&encoder, &this->encoder_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_new_simple_encoder";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_new_simple_encoder"));
return;
}
#else
@@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() {
memset(&encoder, 0, sizeof(encoder));
error = rmt_new_copy_encoder(&encoder, &this->encoder_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_new_copy_encoder";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_new_copy_encoder"));
return;
}
#endif
error = rmt_enable(this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_enable";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_enable"));
return;
}
this->digital_write(open_drain || this->inverted_);
@@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() {
error = rmt_apply_carrier(this->channel_, &carrier);
}
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_apply_carrier";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_apply_carrier"));
return;
}
}
+6 -6
View File
@@ -197,20 +197,20 @@ def _parse_platform_version(value: Any) -> str:
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain
# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0"
# develop-branch commit carrying the arduino-pico 6.1.0 update and the board
# JSON files it adds; replace with a release tag when one is cut
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1"
def _arduino_check_versions(value: ConfigType) -> ConfigType:
value = value.copy()
lookups = {
"dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(6, 0, 0), None),
"dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(6, 1, 0), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}
+18
View File
@@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = {
"SS": 5,
"TX": 0,
},
"soldered_nula_node_rp2040": {
"MISO": 16,
"MOSI": 19,
"RX": 1,
"SCK": 18,
"SCL": 9,
"SCL1": 11,
"SDA": 8,
"SDA1": 10,
"SS": 17,
"TX": 0,
},
"soldered_nula_rp2350": {
"MISO": 2,
"MOSI": 3,
@@ -2127,6 +2139,12 @@ BOARDS = {
"mcu": "rp2040",
"max_pin": 29,
},
"soldered_nula_node_rp2040": {
"name": "Soldered Electronics NULA Node",
"mcu": "rp2040",
"max_pin": 29,
"wifi": True,
},
"soldered_nula_rp2350": {
"name": "Soldered Electronics NULA RP2350",
"mcu": "rp2350",
+17 -5
View File
@@ -55,8 +55,7 @@ namespace esphome::rp2 {
static const char *const TAG = "rp2.crash";
// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear().
// The valid field is explicitly cleared in crash_handler_read_and_clear() instead.
// Filled from the watchdog scratch registers on the first read.
static struct CrashData {
bool valid;
uint32_t pc;
@@ -64,11 +63,24 @@ static struct CrashData {
uint32_t sp;
uint32_t backtrace[MAX_BACKTRACE];
uint8_t backtrace_count;
} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() { return s_crash_data.valid; }
// Logger::pre_setup() logs the record before App.pre_setup() reaches
// arch_init(), so the first caller reads it and later calls are no-ops.
// The read clears the scratch registers, so it must not run twice, and
// arch_init() keeps its call so the read precedes watchdog_enable(), which
// overwrites scratch[4].
static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() {
crash_handler_read_and_clear();
return s_crash_data.valid;
}
void crash_handler_read_and_clear() {
if (s_crash_data_read)
return;
s_crash_data_read = true;
s_crash_data.valid = false;
uint32_t magic = watchdog_hw->scratch[0];
if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) {
@@ -97,7 +109,7 @@ void crash_handler_read_and_clear() {
// the device crashes again during boot, and allowing the CLI's process_stacktrace
// to match and decode each address individually.
void crash_handler_log() {
if (!s_crash_data.valid)
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
+2 -1
View File
@@ -9,12 +9,13 @@
namespace esphome::rp2 {
/// Read crash data from watchdog scratch registers and clear them.
/// Only the first call reads; later calls are no-ops.
void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
/// Returns true if crash data was found this boot.
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
} // namespace esphome::rp2
@@ -20,7 +20,7 @@
namespace esphome::rp2040_ble {
namespace {
// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or
// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or
// a changed ENABLE_* macro) shifting the struct layout must fail the build
// here, not overrun the pool blocks at runtime. Sizes differ per core
// architecture (measured from each archive's own storage symbols). GCC only:
+2 -8
View File
@@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) {
return StateClassStrings::get_log_str(static_cast<uint8_t>(state_class), 0);
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
Sensor::Sensor() : state(NAN), raw_state(NAN) {}
#pragma GCC diagnostic pop
Sensor::Sensor() : state(NAN) {}
int8_t Sensor::get_accuracy_decimals() {
if (this->sensor_flags_.has_accuracy_override)
@@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() {
}
void Sensor::publish_state(float state) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->raw_state = state;
#pragma GCC diagnostic pop
#ifdef USE_SENSOR_FILTER
this->raw_state_ = state;
this->raw_callback_.call(state);
#endif
+10 -14
View File
@@ -96,18 +96,20 @@ class Sensor : public EntityBase {
/// Getter-syntax for .state.
float get_state() const { return this->state; }
/// Getter-syntax for .raw_state
/// Get the last state received by publish_state(), before any filters were applied.
float get_raw_state() const {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return this->raw_state;
#pragma GCC diagnostic pop
#ifdef USE_SENSOR_FILTER
return this->raw_state_;
#else
return this->state; // No filters compiled in, raw == filtered
#endif
}
/** Publish a new state to the front-end.
*
* First, the new state will be assigned to the raw_value. Then it's passed through all filters
* until it finally lands in the .value member variable and a callback is issued.
* The value is passed through the filter chain (when filters are compiled in) before landing in
* the `state` member and triggering the state callback. The pre-filter value is available via
* get_raw_state().
*
* @param state The state as a floating point number.
*/
@@ -137,17 +139,11 @@ class Sensor : public EntityBase {
*/
float state;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
/// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0.
ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0")
float raw_state;
#pragma GCC diagnostic pop
void internal_send_state_to_frontend(float state);
protected:
#ifdef USE_SENSOR_FILTER
float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters.
LazyCallbackManager<void(float)> raw_callback_; ///< Storage for raw state callbacks.
#endif
LazyCallbackManager<void(float)> callback_; ///< Storage for filtered state callbacks.
+39 -29
View File
@@ -66,9 +66,12 @@ static const char *const TAG = "web_server";
// GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only)
// POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only)
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) {
// Every path returns this one object so it is built in place; fields are only set once the URL is known valid
UrlMatch match{};
// URL must start with '/' and have content after it
if (url_len < 2 || url_ptr[0] != '/')
return UrlMatch{};
return match;
const char *p = url_ptr + 1;
const char *end = url_ptr + url_len;
@@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
// Must have domain with trailing slash
if (!s2)
return UrlMatch{};
UrlMatch match{};
match.domain = make_ref(s1, s2);
match.valid = true;
if (only_domain || s2 >= end)
return match;
if (only_domain || s2 >= end) {
match.domain = make_ref(s1, s2);
match.valid = true;
return match;
}
// Parse remaining segments only when needed
const char *s3 = next_segment(s2);
const char *s4 = s3 ? next_segment(s3) : nullptr;
@@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
// Reject empty segments
if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty()))
return UrlMatch{};
return match;
// Interpret based on segment count
if (!s3) {
@@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
if (is_post) {
match.id = seg2;
match.method = seg3;
return match;
}
} else {
#ifdef USE_DEVICES
match.device_name = seg2;
match.id = seg3;
match.device_name = seg2;
match.id = seg3;
#else
return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES
return match; // 3-segment GET not supported without USE_DEVICES
#endif
}
} else {
// 3 segments after domain: /{domain}/{device}/{entity}/{action}
#ifdef USE_DEVICES
if (!is_post) {
return UrlMatch{}; // 4-segment GET not supported (action requires POST)
return match; // 4-segment GET not supported (action requires POST)
}
match.device_name = seg2;
match.id = seg3;
match.method = seg4;
#else
return UrlMatch{}; // Not supported without USE_DEVICES
// Not supported without USE_DEVICES
return match;
#endif
}
match.domain = make_ref(s1, s2);
match.valid = true;
return match;
}
@@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
// Kept out of the callers so the 64 bit division is emitted once
__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast<uint32_t>(millis_64() / 1000); }
json::SerializationBuffer<> WebServer::get_config_json() {
json::JsonBuilder builder;
JsonObject root = builder.root();
@@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() {
root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str();
char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX];
App.get_comment_string(comment_buffer);
root[ESPHOME_F("comment")] = comment_buffer;
root[ESPHOME_F("comment")] = static_cast<const char *>(comment_buffer);
#if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA)
root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal
#else
@@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() {
#endif
root[ESPHOME_F("log")] = this->expose_log_;
root[ESPHOME_F("lang")] = "en";
root[ESPHOME_F("uptime")] = static_cast<uint32_t>(millis_64() / 1000);
root[ESPHOME_F("uptime")] = uptime_seconds();
return builder.serialize();
}
@@ -382,7 +390,7 @@ void WebServer::setup() {
if (this->events_.empty())
return;
char buf[32];
auto uptime = static_cast<uint32_t>(millis_64() / 1000);
auto uptime = uptime_seconds();
size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime);
this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000);
});
@@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const
const size_t scheme_sep = origin.find("://");
if (scheme_sep != std::string::npos) {
const std::string host = get_request_header(request, "Host");
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
// Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here
const size_t authority = scheme_sep + 3;
if (!host.empty() && origin.size() - authority == host.size() &&
memcmp(origin.data() + authority, host.data(), host.size()) == 0)
return true;
}
@@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
// Helper functions to reduce code size by avoiding macro expansion
// Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name}
// Uses names (not object_id) to avoid UTF-8 collision issues
static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
const StringRef &name = obj->get_name();
size_t prefix_len = strlen(prefix);
size_t name_len = name.size();
@@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
#endif
memcpy(p, name.c_str(), name_len);
p[name_len] = '\0';
root[ESPHOME_F("id")] = id_buf;
root[ESPHOME_F("id")] = static_cast<const char *>(id_buf);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("domain")] = prefix;
@@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
// Keep as separate function even though only used once: reduces code size by ~48 bytes
// by allowing compiler to share code between template instantiations (bool, float, etc.)
template<typename T>
static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value,
JsonDetail start_config) {
static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) {
set_json_id(root, obj, prefix, start_config);
root[ESPHOME_F("value")] = value;
}
template<typename S, typename T>
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value,
JsonDetail start_config) {
set_json_value(root, obj, prefix, value, start_config);
root[ESPHOME_F("state")] = state;
@@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso
// Format: YYYY-MM-DD (max 10 chars + null)
char value[12];
buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day);
set_json_icon_state_value(root, obj, "date", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "date", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso
// Format: HH:MM:SS (8 chars + null)
char value[12];
buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second);
set_json_icon_state_value(root, obj, "time", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "time", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *
char value[24];
buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour,
obj->minute, obj->second);
set_json_icon_state_value(root, obj, "datetime", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "datetime", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J
JsonObject root = builder.root();
set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config);
obj->update_info.latest_version.c_str(), start_config);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version;
root[ESPHOME_F("title")] = obj->update_info.title;
+1 -1
View File
@@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
web_server_base::WebServerBase *base_;
#ifdef USE_ESP32
AsyncEventSource events_{"/events", this};
AsyncEventSource events_{StringRef::from_lit("/events"), this};
#elif USE_ARDUINO
DeferredUpdateEventSourceList events_;
#endif
@@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler {
using connect_handler_t = std::function<void(AsyncEventSourceClient *)>;
public:
AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {}
AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {}
~AsyncEventSource() override;
// NOLINTNEXTLINE(readability-identifier-naming)
@@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler {
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
std::string url_;
StringRef url_; // Must outlive this object (string literal)
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
std::vector<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
+32 -2
View File
@@ -11,6 +11,19 @@
#include <esp_chip_info.h>
#include <esp_ota_ops.h>
#include <esp_bootloader_desc.h>
#include <esp_flash.h>
#if __has_include(<esp_flash_chips/spi_flash_chip_generic.h>)
#include <esp_flash_chips/spi_flash_chip_generic.h> // ESP-IDF 6
#include <esp_flash_chips/spi_flash_chip_mxic.h>
#else
#include <spi_flash_chip_generic.h>
#include <spi_flash_chip_mxic.h>
#endif
// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all
#define ESPHOME_FLASH_VENDOR_DRIVERS \
(CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \
CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \
CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP)
#endif
#include "esphome/core/version.h"
#include "esphome/core/hal.h"
@@ -157,8 +170,25 @@ void Application::process_dump_config_() {
esp_chip_info(&chip_info);
ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100,
chip_info.revision % 100, chip_info.cores);
#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM))
static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced";
[[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced";
#if ESPHOME_FLASH_VENDOR_DRIVERS > 0
{
// Only the driver in use earns its IRAM; with several linked at least one is idle
const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv;
#if ESPHOME_FLASH_VENDOR_DRIVERS > 1
constexpr bool idle_driver = true;
#else
const bool idle_driver = flash_driver == &esp_flash_chip_generic;
#endif
if (idle_driver) {
const char *value = flash_driver->name;
#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP
if (flash_driver == &esp_flash_chip_mxic_opi)
value = "mxic_opi";
#endif
ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH);
}
}
#endif
#if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET)
{
+2 -2
View File
@@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu
class Application {
public:
#ifdef ESPHOME_NAME_ADD_MAC_SUFFIX
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
/// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC.
void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) {
arch_init();
@@ -87,7 +87,7 @@ class Application {
this->friendly_name_ = StringRef(friendly_name, friendly_name_len);
}
#else
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
/// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash.
void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) {
arch_init();
+5 -10
View File
@@ -6,18 +6,13 @@ namespace esphome {
constinit const Color Color::BLACK(0, 0, 0, 0);
constinit const Color Color::WHITE(255, 255, 255, 255);
Color Color::gradient(const Color &to_color, uint8_t amnt) {
uint8_t inv = 255 - amnt;
Color new_color;
new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255;
new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255;
new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255;
new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255;
return new_color;
Color Color::gradient(const Color &to_color, uint8_t amnt) const {
return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt),
blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt));
}
Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); }
Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); }
Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); }
Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); }
} // namespace esphome
+9 -3
View File
@@ -174,9 +174,15 @@ struct Color {
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
}
Color gradient(const Color &to_color, uint8_t amnt);
Color fade_to_white(uint8_t amnt);
Color fade_to_black(uint8_t amnt);
/// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a
/// per pixel loop can blend without a call; gradient() itself stays out of
/// line so the light effects and fade_to_*() share one copy.
static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE {
return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255;
}
Color gradient(const Color &to_color, uint8_t amnt) const;
Color fade_to_white(uint8_t amnt) const;
Color fade_to_black(uint8_t amnt) const;
Color lighten(uint8_t delta) { return *this + delta; }
Color darken(uint8_t delta) { return *this - delta; }
+1 -1
View File
@@ -538,7 +538,7 @@
// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias
// for external custom components that may still test for it.
#ifdef USE_RP2
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0)
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0)
#define USE_RP2_CRASH_HANDLER
#define USE_HTTP_REQUEST_RESPONSE
#define USE_I2C
+83 -18
View File
@@ -9,16 +9,19 @@ byte-identical to PlatformIO's output:
Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes)
The format matches ``script/ci_memory_impact_extract.py`` so CI memory
analysis works unchanged on native ESP-IDF builds. RAM total is the
DRAM region size from the linker map; Flash total is taken from
analysis works unchanged on native ESP-IDF builds. RAM usage comes from
the DRAM (or unified DIRAM) region of the linker map. Flash used is the
exact image size matching the ``Total image size`` line: json2
``total_size`` when present, otherwise derived from the ELF (see
``_image_size_from_elf``). Flash total is taken from
``partitions.csv`` using PlatformIO's rule (first app partition whose
subtype is ``factory`` or ``ota_0``; see
``platform-espressif32/builder/main.py::_update_max_upload_size``).
Structured size data is produced at link time by a CMake POST_BUILD
custom command (see ``build_gen/espidf.py``) which writes
``esp_idf_size.json`` next to the ELF. We read that file here rather
than re-running ``esp_idf_size`` from Python.
``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary)
next to the ELF; we read that rather than re-running ``esp_idf_size``.
"""
from __future__ import annotations
@@ -27,6 +30,7 @@ import csv
import json
import logging
from pathlib import Path
import struct
from esphome.build_helpers.size_summary import print_size_line
@@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int:
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
def _image_size_from_elf(elf: Path) -> int:
"""Sum the allocated PROGBITS section sizes from an ELF32 file.
Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte;
esptool's ``ELFFile`` filters sections differently and would not.
Raises ``ValueError`` for anything but a well-formed ELF32 LE file.
"""
with elf.open("rb") as f:
header = f.read(52) # ELF32 header
if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01":
raise ValueError(f"{elf} is not a 32-bit little-endian ELF")
(e_shoff,) = struct.unpack_from("<I", header, 0x20) # e_shoff
e_shentsize, e_shnum = struct.unpack_from("<HH", header, 0x2E)
if e_shentsize < 40: # sizeof(Elf32_Shdr)
raise ValueError(f"{elf} has an invalid section header size")
f.seek(e_shoff)
table = f.read(e_shnum * e_shentsize)
if len(table) < e_shnum * e_shentsize:
raise ValueError(f"{elf} has a truncated section header table")
total = 0
for off in range(0, e_shnum * e_shentsize, e_shentsize):
sh_type, sh_flags = struct.unpack_from("<II", table, off + 4)
(sh_size,) = struct.unpack_from("<I", table, off + 20)
if sh_type == 1 and sh_flags & 0x2: # SHT_PROGBITS with SHF_ALLOC
total += sh_size
if total == 0:
# A used-0-bytes Flash line would read as a real measurement
raise ValueError(f"{elf} has no allocated PROGBITS sections")
return total
def print_summary(size_json: Path, partitions_csv: Path, firmware_elf: Path) -> None:
"""Print PlatformIO-shaped RAM and Flash one-liners.
Failures are non-fatal: the build has already succeeded, we just couldn't
summarize. Logs the cause at debug level.
summarize. Anomalies (missing region, unreadable ELF) warn; expected
optional inputs (no size json, no partitions.csv) log at debug.
"""
if not size_json.is_file():
_LOGGER.debug("Skipping size summary: %s not found", size_json)
@@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Skipping size summary: %s", e)
return
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
if ram_total and ram_used is not None:
print_size_line("RAM", ram_used, ram_total)
image_size = data.get("image_size")
if image_size is None or partitions_csv is None:
if not isinstance(data, dict):
_LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json)
return
layout = data.get("layout")
regions = {
entry.get("name"): entry
for entry in (layout if isinstance(layout, list) else [])
if isinstance(entry, dict)
}
# Every chip has a DRAM or DIRAM region, so a warning here usually
# means the esp_idf_size json schema changed
ram_region = regions.get("DRAM") or regions.get("DIRAM")
if ram_region is None:
_LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json)
elif (
isinstance(ram_total := ram_region.get("total"), int)
and ram_total > 0
and isinstance(ram_used := ram_region.get("used"), int)
):
print_size_line("RAM", ram_used, ram_total)
else:
_LOGGER.warning(
"Skipping RAM summary: unusable region %s in %s", ram_region, size_json
)
# esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in
# json2; older 1.x omits it, so derive the same figure from the ELF.
flash_used = data.get("total_size")
if not (isinstance(flash_used, int) and flash_used > 0):
_LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf)
try:
flash_used = _image_size_from_elf(firmware_elf)
except (OSError, ValueError) as e:
# The ELF must be present and well formed after a successful build
_LOGGER.warning("Skipping Flash summary: %s", e)
return
try:
app_size = _find_app_partition_size(partitions_csv)
except ValueError as e:
except (OSError, ValueError) as e:
_LOGGER.debug("Skipping Flash summary: %s", e)
return
print_size_line("Flash", image_size, app_size)
if app_size <= 0:
_LOGGER.debug("Skipping Flash summary: app partition size is 0")
return
print_size_line("Flash", flash_used, app_size)
+12 -3
View File
@@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int:
if rc == 0:
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
partitions = CORE.relative_build_path("partitions.csv")
print_summary(size_json, partitions if partitions.is_file() else None)
print_summary(size_json, partitions, get_built_elf_path())
return rc
@@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path:
return build_dir / "firmware.ota.bin"
def get_built_elf_path() -> Path:
"""Path to the ELF idf.py writes directly, ``<build>/<name>.elf``.
Exists as soon as the build finishes, unlike the ``firmware.elf``
copy that ``create_elf_copy`` makes later.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / f"{CORE.name}.elf"
def get_elf_path() -> Path:
"""Get the path to the firmware ELF file.
@@ -706,8 +716,7 @@ def create_elf_copy() -> bool:
"download ELF" link requests the literal filename ``firmware.elf``
(PlatformIO convention), so copy it to that name.
"""
build_dir = CORE.relative_build_path("build")
src_elf = build_dir / f"{CORE.name}.elf"
src_elf = get_built_elf_path()
dst_elf = get_elf_path()
if not src_elf.is_file():
+1 -1
View File
@@ -4,7 +4,7 @@ dependencies:
esphome/dlms_parser:
version: 1.1.0
esphome/esp-audio-libs:
version: 4.0.0
version: 4.0.1
esphome/esp-micro-speech-features:
version: 1.2.3
esphome/micro-decoder:
+2 -2
View File
@@ -203,11 +203,11 @@ extra_scripts =
extends = common:arduino
board_build.filesystem_size = 0.5m
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#5d4561a05e3b212660ac6fdd3fbfb328d1988aa1
platform_packages =
; The framework-arduinopico package is no longer published to the PlatformIO
; registry, so install the framework straight from the GitHub release
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/6.1.0/rp2040-6.1.0.zip
framework = arduino
lib_deps =
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
flash_chip: gd
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
flash_chip: generic
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
flash_mode: opi
framework:
type: esp-idf
advanced:
flash_chip: mxic_opi
+73
View File
@@ -10,6 +10,7 @@ from typing import Any
import pytest
from esphome.components.esp32 import (
ESP32_FLASH_CHIPS,
KEY_FATFS_REQUIRED,
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED,
KEY_MBEDTLS_TLS_SERVER_REQUIRED,
@@ -252,6 +253,41 @@ def test_esp32_rejects_unsupported_cli_toolchain(
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
id="nvs_encryption_key_id_out_of_range",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "mxic_opi"},
},
},
r"'flash_chip: mxic_opi' is only supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_mxic_opi_only_on_s3",
),
pytest.param(
{
"variant": "esp32s3",
"flash_mode": "opi",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "gd"},
},
},
r"'flash_chip: gd' does not match 'flash_mode: opi'; octal flash uses mxic_opi @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_must_match_opi_mode",
),
pytest.param(
{
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"flash_chip": "mxic_opi"},
},
},
r"'flash_chip: mxic_opi' requires 'flash_mode: opi' @ data\['framework'\]\['advanced'\]\['flash_chip'\]",
id="flash_chip_mxic_opi_requires_opi_mode",
),
pytest.param(
{
"variant": "esp32",
@@ -719,6 +755,43 @@ def test_flash_mode_sets_sdkconfig_and_pio_option(
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
@pytest.mark.parametrize(
("config_file", "enabled"),
[
pytest.param("flash_chip_gd.yaml", "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", id="gd"),
pytest.param("flash_chip_generic.yaml", None, id="generic"),
pytest.param(
"flash_chip_mxic_opi_s3.yaml",
"CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP",
id="mxic_opi_s3",
),
],
)
def test_flash_chip_keeps_one_vendor_driver(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
enabled: str | None,
) -> None:
"""flash_chip enables only the chosen vendor driver."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
vendors = {
k: v for k, v in sdkconfig.items() if k.startswith("CONFIG_SPI_FLASH_SUPPORT_")
}
assert vendors == {flag: flag == enabled for flag in ESP32_FLASH_CHIPS.values()}
def test_flash_chip_unset_keeps_idf_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without flash_chip every vendor driver stays at its ESP-IDF default."""
generate_main(component_config_path("flash_mode_default.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert not any(key.startswith("CONFIG_SPI_FLASH_SUPPORT_") for key in sdkconfig)
def test_flash_mode_opi_enables_octal_flash(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,9 @@
esphome:
name: test
bk72xx:
board: generic-bk7252
remote_receiver:
- id: rcvr
pin: P6
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: esp32-c61-devkitc1
variant: esp32c61
framework:
type: esp-idf
remote_receiver:
- id: rcvr
pin: GPIO4
@@ -0,0 +1,9 @@
esphome:
name: test
ln882x:
board: generic-ln882h
remote_receiver:
- id: rcvr
pin: PA4
@@ -0,0 +1,9 @@
esphome:
name: test
rp2:
board: rpipicow
remote_receiver:
- id: rcvr
pin: GPIO4
@@ -0,0 +1,9 @@
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
remote_receiver:
- id: rcvr
pin: PA12
@@ -1,8 +1,16 @@
"""buffer_size reaches the receiver when set, and always on the pulse ring targets."""
"""buffer_size is bytes on the pulse ring targets and only reaches RMT targets when set."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import remote_receiver
from esphome.components.esp8266 import gpio as esp8266_gpio # noqa: F401 registers the pin schema
from esphome.config_validation import Invalid
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_explicit_buffer_size_is_passed_through(
generate_main: Callable[[str | Path], str],
@@ -12,17 +20,29 @@ def test_explicit_buffer_size_is_passed_through(
assert "rcvr->set_buffer_size(2000);" in main_cpp
def test_pulse_ring_target_keeps_a_default(
@pytest.mark.parametrize(
"target", ["esp8266", "rp2", "bk72xx", "rtl87xx", "ln882x", "esp32_c2", "esp32_c61"]
)
def test_pulse_ring_default_holds_1000_pulses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
target: str,
) -> None:
main_cpp = generate_main(component_config_path("receiver_esp8266.yaml"))
assert "rcvr->set_buffer_size(1000);" in main_cpp
main_cpp = generate_main(component_config_path(f"receiver_{target}.yaml"))
assert "rcvr->set_buffer_size(4000);" in main_cpp
def test_esp32_variant_without_rmt_keeps_a_default(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@pytest.mark.parametrize(
("value", "expected"),
[("32b", None), ("64b", 64), ("65b", 65), ("65535b", 65535), ("65536b", None)],
)
def test_buffer_size_range(
set_core_config: SetCoreConfigCallable, value: str, expected: int | None
) -> None:
main_cpp = generate_main(component_config_path("receiver_esp32_c2.yaml"))
assert "rcvr->set_buffer_size(1000);" in main_cpp
set_core_config(PlatformFramework.ESP8266_ARDUINO)
config = {"pin": "GPIO4", "buffer_size": value}
if expected is None:
with pytest.raises(Invalid):
remote_receiver.CONFIG_SCHEMA(config)
else:
assert remote_receiver.CONFIG_SCHEMA(config)["buffer_size"] == expected
@@ -22,6 +22,7 @@ esp32:
disable_regi2c_in_iram: true
disable_fatfs: true
sram1_as_iram: true
flash_chip: gd
watchdog_timeout: 7s
wifi:
@@ -9,6 +9,7 @@ esp32:
type: esp-idf
advanced:
execute_from_psram: true
flash_chip: gd
disable_libc_locks_in_iram: true # Test default RAM optimization enabled
disable_debug_stubs: true
disable_ocd_aware: true
@@ -0,0 +1,53 @@
esphome:
name: test-sensor-raw-state
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists
sensor:
# No filters on this sensor: get_raw_state() must equal state
- platform: template
name: "No Filter Sensor"
id: no_filter_sensor
accuracy_decimals: 1
# Filtered sensor: get_raw_state() must be the pre-filter value
- platform: template
name: "With Filter Sensor"
id: with_filter_sensor
accuracy_decimals: 1
filters:
- multiply: 2.0
button:
- platform: template
name: "Test No Filter Button"
id: test_no_filter_button
on_press:
- sensor.template.publish:
id: no_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "NO_FILTER: state=%.1f raw_state=%.1f"
args:
- id(no_filter_sensor).state
- id(no_filter_sensor).get_raw_state()
- platform: template
name: "Test With Filter Button"
id: test_with_filter_button
on_press:
- sensor.template.publish:
id: with_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "WITH_FILTER: state=%.1f raw_state=%.1f"
args:
- id(with_filter_sensor).state
- id(with_filter_sensor).get_raw_state()
@@ -0,0 +1,31 @@
esphome:
name: test-sensor-raw-state-no-filter
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and
# get_raw_state() falls back to state
sensor:
- platform: template
name: "No Filter Sensor"
id: no_filter_sensor
accuracy_decimals: 1
button:
- platform: template
name: "Test No Filter Button"
id: test_no_filter_button
on_press:
- sensor.template.publish:
id: no_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "NO_FILTER: state=%.1f raw_state=%.1f"
args:
- id(no_filter_sensor).state
- id(no_filter_sensor).get_raw_state()
+108
View File
@@ -0,0 +1,108 @@
"""Integration tests for Sensor::get_raw_state().
Raw state storage only exists when filters are compiled in (USE_SENSOR_FILTER).
Without it, get_raw_state() returns state, so both build configurations are covered:
one fixture with a filtered sensor and one with no filters at all.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import APIClient, EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)")
WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)")
async def _press_and_read(
client: APIClient,
entities: list[EntityInfo],
button_object_id: str,
future: asyncio.Future[tuple[float, float]],
label: str,
) -> tuple[float, float]:
button = next(
(e for e in entities if button_object_id in e.object_id.lower()), None
)
assert button is not None, f"{button_object_id} not found"
client.button_command(button.key)
try:
return await asyncio.wait_for(future, timeout=5.0)
except TimeoutError:
pytest.fail(f"Timeout waiting for {label} log message")
@pytest.mark.asyncio
async def test_sensor_raw_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With filters compiled in, raw state is stored separately from state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
if not with_filter_future.done() and (
match := WITH_FILTER_PATTERN.search(line)
):
with_filter_future.set_result(
(float(match.group(1)), float(match.group(2)))
)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5
state, raw_state = await _press_and_read(
client,
entities,
"test_with_filter_button",
with_filter_future,
"WITH_FILTER",
)
assert state == 43.0
assert raw_state == 21.5
@pytest.mark.asyncio
async def test_sensor_raw_state_no_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Without filters compiled in, get_raw_state() returns state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5
+12
View File
@@ -163,6 +163,18 @@ def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
assert has_discovered_components()
def test_get_project_cmakelists_size_command_uses_json2() -> None:
"""The POST_BUILD size command uses the cheap json2 format, with --ng
only on the 1.x tool bundled with IDF < 6."""
content = _render()
assert "-m esp_idf_size --ng --format=json2" in content
CORE.data[KEY_ESP32][KEY_IDF_VERSION] = cv.Version(6, 0, 0)
content = _render()
assert "--ng" not in content
assert "--format=json2" in content
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
"""A cached list replaces project_description.json and is still filtered
by EXCLUDE_COMPONENTS."""
+37
View File
@@ -638,6 +638,43 @@ def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
mock_run.assert_called_once_with("build", "size", jobs=1)
def test_run_compile_passes_size_summary_paths(setup_core: Path) -> None:
"""print_summary receives the size json, partitions.csv, and the built
ELF from get_built_elf_path, which must stay in lockstep with the
project() name in the generated CMakeLists."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0),
patch.object(toolchain, "print_summary") as mock_summary,
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_summary.assert_called_once_with(
CORE.relative_build_path("build", "esp_idf_size.json"),
CORE.relative_build_path("partitions.csv"),
CORE.relative_build_path("build", f"{CORE.name}.elf"),
)
def test_create_elf_copy(setup_core: Path) -> None:
"""The built <name>.elf is copied to the firmware.elf dashboard name."""
_setup_build(setup_core)
src = toolchain.get_built_elf_path()
src.parent.mkdir(parents=True, exist_ok=True)
src.write_bytes(b"elf")
assert toolchain.create_elf_copy() is True
assert toolchain.get_elf_path().read_bytes() == b"elf"
def test_create_elf_copy_missing_source(setup_core: Path) -> None:
"""A missing built ELF is a warning and False, not a crash."""
_setup_build(setup_core)
assert toolchain.create_elf_copy() is False
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
_setup_build(setup_core)
+236 -62
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import json
from pathlib import Path
import struct
from unittest.mock import patch
import pytest
@@ -17,64 +19,106 @@ def _write_size_json(tmp_path: Path, data: dict) -> Path:
return out
def _write_partitions(tmp_path: Path) -> Path:
"""Drop a partitions.csv with a 0x1C0000 (1835008 byte) app slot."""
out = tmp_path / "partitions.csv"
out.write_text(
"# name, type, subtype, offset, size, flags\n"
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
)
return out
def _elf_bytes(sections: list[tuple[int, int, int]], shentsize: int = 40) -> bytes:
"""Build a minimal ELF32 LE whose section headers carry the given
(sh_type, sh_flags, sh_size) triples."""
out = bytearray(52)
out[0:4] = b"\x7fELF"
out[4] = out[5] = 1 # 32-bit, little-endian
struct.pack_into("<I", out, 0x20, 52) # e_shoff
struct.pack_into("<HH", out, 0x2E, shentsize, len(sections))
for sh_type, sh_flags, sh_size in sections:
shdr = bytearray(40)
struct.pack_into("<II", shdr, 4, sh_type, sh_flags)
struct.pack_into("<I", shdr, 20, sh_size)
out += shdr
return bytes(out)
def _esp32_size_data() -> dict:
"""Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM)."""
"""Synthetic json2 for the original ESP32 (split IRAM/DRAM), in the
esp-idf-size >= 2.1 shape that carries ``total_size``."""
return {
"image_size": 827455,
"memory_types": {
"DRAM": {
"size": 180736,
"version": "1.1",
"total_size": 827455,
"layout": [
{
"name": "DRAM",
"total": 180736,
"used": 47332,
"sections": {
".dram0.bss": {"abbrev_name": ".bss", "size": 30616},
".dram0.data": {"abbrev_name": ".data", "size": 16716},
"free": 133404,
"parts": {
".bss": {"size": 30616},
".data": {"size": 16716},
},
},
"IRAM": {
"size": 131072,
{
"name": "IRAM",
"total": 131072,
"used": 80351,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 79323},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
"free": 50721,
"parts": {
".text": {"size": 79323},
".vectors": {"size": 1028},
},
},
},
],
}
def _s3_size_data() -> dict:
"""Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM)."""
"""Synthetic json2 for ESP32-S3 (unified DIRAM), in the esp-idf-size 1.x
shape without ``total_size``."""
return {
"image_size": 724215,
"memory_types": {
"DIRAM": {
"size": 341760,
"version": "1.1",
"layout": [
{
"name": "DIRAM",
"total": 341760,
"used": 104999,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 58051},
".dram0.bss": {"abbrev_name": ".bss", "size": 27088},
".dram0.data": {"abbrev_name": ".data", "size": 19708},
".noinit": {"abbrev_name": ".noinit", "size": 152},
"free": 236761,
"parts": {
".text": {"size": 58051},
".bss": {"size": 27088},
".data": {"size": 19708},
".noinit": {"size": 152},
},
},
"IRAM": {
"size": 16384,
{
"name": "IRAM",
"total": 16384,
"used": 16384,
"sections": {
".iram0.text": {"abbrev_name": ".text", "size": 15356},
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
"free": 0,
"parts": {
".text": {"size": 15356},
".vectors": {"size": 1028},
},
},
},
],
}
def _print_summary_ram_only(tmp_path: Path, size_json: Path) -> None:
"""Call print_summary with no partitions.csv or ELF on disk."""
print_summary(size_json, tmp_path / "partitions.csv", tmp_path / "firmware.elf")
def test_print_summary_esp32_uses_dram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged."""
"""Original ESP32: RAM = DRAM.used / DRAM.total."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
print_summary(size_json, partitions_csv=None)
_print_summary_ram_only(tmp_path, size_json)
out = capsys.readouterr().out
assert "RAM:" in out
assert "used 47332 bytes from 180736 bytes" in out
@@ -83,63 +127,193 @@ def test_print_summary_esp32_uses_dram(
def test_print_summary_s3_falls_back_to_diram(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage."""
"""ESP32-S3 with no DRAM entry falls back to DIRAM and reports raw region usage."""
size_json = _write_size_json(tmp_path, _s3_size_data())
print_summary(size_json, partitions_csv=None)
_print_summary_ram_only(tmp_path, size_json)
out = capsys.readouterr().out
assert "used 104999 bytes from 341760 bytes" in out
def test_print_summary_skips_when_diram_total_collapses(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A zero-size region drops the RAM line rather than divide by zero."""
size_json = _write_size_json(
tmp_path,
{
"memory_types": {
"DIRAM": {
"size": 0,
"used": 0,
"sections": {},
},
},
"version": "1.1",
"layout": [{"name": "DIRAM", "total": 0, "used": 0}],
},
)
print_summary(size_json, partitions_csv=None)
_print_summary_ram_only(tmp_path, size_json)
out = capsys.readouterr().out
assert "RAM:" not in out
assert "unusable region" in caplog.text
def test_print_summary_handles_missing_json(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Missing size json is non-fatal and prints nothing."""
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
_print_summary_ram_only(tmp_path, tmp_path / "does_not_exist.json")
assert capsys.readouterr().out == ""
def test_print_summary_handles_no_memory_types(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
def test_print_summary_handles_no_layout(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A size json without ``memory_types`` still doesn't crash."""
size_json = _write_size_json(tmp_path, {"image_size": 0})
print_summary(size_json, partitions_csv=None)
"""A size json without ``layout`` warns so schema drift is visible."""
size_json = _write_size_json(tmp_path, {"version": "1.1"})
_print_summary_ram_only(tmp_path, size_json)
assert capsys.readouterr().out == ""
def test_print_summary_flash_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A partition table with an app row yields the Flash line in the exact
padded shape script/ci_memory_impact_extract.py greps."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size, flags\n"
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
assert any(
r.levelname == "WARNING" and "no DRAM/DIRAM region" in r.message
for r in caplog.records
)
print_summary(size_json, partitions)
def test_print_summary_flash_line_prefers_total_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""With ``total_size`` in the json, that figure wins without reading the
ELF, in the exact shape script/ci_memory_impact_extract.py greps."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = _write_partitions(tmp_path)
print_summary(size_json, partitions, tmp_path / "firmware.elf")
out = capsys.readouterr().out
assert "Flash: " in out
assert "(used 827455 bytes from 1835008 bytes)" in out
def test_print_summary_flash_line_derives_from_elf(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A 1.x json without ``total_size`` sums the ELF's loadable PROGBITS
sections; NOBITS and non-alloc sections are excluded."""
size_json = _write_size_json(tmp_path, _s3_size_data())
partitions = _write_partitions(tmp_path)
firmware_elf = tmp_path / "firmware.elf"
firmware_elf.write_bytes(
_elf_bytes(
[
(1, 0x6, 700000), # PROGBITS, alloc+exec: counted
(1, 0x2, 24215), # PROGBITS, alloc: counted
(8, 0x2, 50000), # NOBITS (.bss): excluded
(1, 0x0, 12345), # PROGBITS, no alloc (.debug_*): excluded
]
)
)
print_summary(size_json, partitions, firmware_elf)
out = capsys.readouterr().out
assert "(used 724215 bytes from 1835008 bytes)" in out
@pytest.mark.parametrize(
"data",
[
pytest.param([1, 2], id="top_level_list"),
pytest.param({"version": "1.1", "layout": None}, id="layout_null"),
pytest.param({"version": "1.1", "layout": 7}, id="layout_scalar"),
],
)
def test_print_summary_handles_unexpected_shapes(
data: object, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A foreign-schema size json degrades to a warning, never a traceback."""
size_json = _write_size_json(tmp_path, data)
_print_summary_ram_only(tmp_path, size_json)
assert capsys.readouterr().out == ""
def test_print_summary_skips_flash_on_zero_app_partition(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero-size app partition skips the Flash line rather than printing
a from-0-bytes figure CI would record."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = tmp_path / "partitions.csv"
partitions.write_text(
"# name, type, subtype, offset, size, flags\napp0, app, ota_0, 0x10000, 0x0,\n"
)
print_summary(size_json, partitions, tmp_path / "firmware.elf")
out = capsys.readouterr().out
assert "Flash:" not in out
def test_print_summary_skips_flash_on_unreadable_partitions(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""An unreadable partitions.csv is non-fatal (chmod tricks don't work
for root in CI containers, so simulate the OSError instead)."""
size_json = _write_size_json(tmp_path, _esp32_size_data())
partitions = _write_partitions(tmp_path)
with patch(
"esphome.espidf.size_summary._find_app_partition_size",
side_effect=PermissionError("denied"),
):
print_summary(size_json, partitions, tmp_path / "firmware.elf")
assert "Flash:" not in capsys.readouterr().out
def test_print_summary_flash_falls_back_on_bad_total_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A zero or non-int total_size falls back to the ELF instead of
printing a used-0-bytes line CI would read as a real measurement."""
data = _s3_size_data()
data["total_size"] = 0
size_json = _write_size_json(tmp_path, data)
partitions = _write_partitions(tmp_path)
firmware_elf = tmp_path / "firmware.elf"
firmware_elf.write_bytes(_elf_bytes([(1, 0x2, 4096)]))
print_summary(size_json, partitions, firmware_elf)
out = capsys.readouterr().out
assert "(used 4096 bytes from 1835008 bytes)" in out
_GOOD_ELF = _elf_bytes([(1, 0x2, 1024)])
@pytest.mark.parametrize(
("elf_bytes", "with_partitions"),
[
pytest.param(None, True, id="missing_elf"),
pytest.param(b"junk", True, id="not_an_elf"),
pytest.param(
_elf_bytes([(1, 0x2, 1024)], shentsize=0), True, id="bad_shentsize"
),
pytest.param(_GOOD_ELF[:60], True, id="truncated_table"),
pytest.param(_elf_bytes([]), True, id="no_sections"),
pytest.param(_elf_bytes([(8, 0x2, 50000)]), True, id="no_progbits"),
pytest.param(_GOOD_ELF, False, id="missing_partitions"),
],
)
def test_print_summary_skips_flash_on_bad_input(
elf_bytes: bytes | None,
with_partitions: bool,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unusable ELF or missing partitions.csv skips the Flash line, not the RAM line."""
size_json = _write_size_json(tmp_path, _s3_size_data())
firmware_elf = tmp_path / "firmware.elf"
if elf_bytes is not None:
firmware_elf.write_bytes(elf_bytes)
if with_partitions:
_write_partitions(tmp_path)
print_summary(size_json, tmp_path / "partitions.csv", firmware_elf)
out = capsys.readouterr().out
assert "RAM:" in out
assert "Flash:" not in out
# ELF problems warn (anomaly after a successful build); a missing
# partitions.csv stays at debug
warned = any(
r.levelname == "WARNING" and "Skipping Flash summary" in r.message
for r in caplog.records
)
assert warned == with_partitions