diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/skills b/.github/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 9b34d523d20..aba498c3652 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core esphome/components/improv_ble/* @jesserockz @@ -477,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 2ef89cf595b..7689fc93b0e 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -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}} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 749eaeb3929..3064ff09b18 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -720,12 +720,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 14a08188949..b882aaa6b75 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -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() diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c8..b8de7932cd6 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -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: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd1..eec2c8c3186 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -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_); } diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0a..555f0030a5b 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb600..66fadf12ece 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d1..c1389721496 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -758,6 +758,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +781,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +811,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c919140494..d564ea67bd5 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f7269175..3ecf6d1cf15 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88c..d65d844b9e6 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3214f932bfb..3b3418d911f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -299,7 +299,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index d027c9a1c61..748be9ae4cf 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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 @@ -1519,6 +1534,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -1526,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 @@ -1964,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. @@ -2609,6 +2653,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -2725,6 +2776,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True @@ -2749,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. diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 6f65243aaa0..b72a2777c7a 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -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(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 ***"); diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index c5e7d145ece..314be80314c 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -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 diff --git a/esphome/components/esp32/hal.cpp b/esphome/components/esp32/hal.cpp index f6199d557f3..199cb89f516 100644 --- a/esphome/components/esp32/hal.cpp +++ b/esphome/components/esp32/hal.cpp @@ -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 @@ -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); diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 4eb5d1745be..d9b375dd20c 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -169,7 +169,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +215,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738dd..c319852bff9 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index e4c4d7e331b..56509b9fabb 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -367,14 +367,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. # See throw_stubs.h for details. Must be prepended before , so this diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index f5eb878260c..bcf2a2271cb 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_PASSWORD, ) # web_server and prometheus keep the shared listener up; the captive - # portal's copy only exists on the fallback AP and is the recovery path + # portal's copy only exists on the fallback AP and is the recovery path. + # web_server `ota: false` gates /update behind the captive portal on + # every listener + web_server_conf = full_conf.get(CONF_WEB_SERVER) + plaintext_update_reachable = ( + web_server_conf.get(CONF_OTA) is not False + if web_server_conf is not None + else "prometheus" in full_conf + ) if ( - (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + plaintext_update_reachable and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) and any( CONF_ENCRYPTION in conf diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a4..d231b3d77a5 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d9..d36928a83af 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0e..ec4e459be78 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 0c1140da0c6..cb82b09f33a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -80,17 +80,6 @@ void I2SAudioSpeakerBase::loop() { } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -325,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -342,6 +325,24 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; + } + xQueueReset(this->i2s_event_queue_); + + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 5812cc211b2..b443166ea1b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -134,6 +132,21 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the /// speaker task only. /// @param data Pointer to audio sample data (modified in place) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d63..b4b6173458b 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; diff --git a/esphome/components/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 00000000000..07f3b4e31ca --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 00000000000..62a4586ebc3 --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 00000000000..0d96b015247 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 00000000000..5d7a7386120 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683abc..bfe311be284 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -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; } diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f200..fd9e92c21d3 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index bbc1589abf0..0a20beb33c8 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -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 } diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 179c2e5f63d..237f1c3c8bd 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f9..1b1eefe59b3 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -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 diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b8..130e1503321 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -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(); diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce9..d711a2d2d29 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df3311..aa34ad9d392 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d2..d38eed4d096 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -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_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec51..1052369a744 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0150cc25442..b6612038b6d 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index c11044c2882..3f83da7f803 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -259,7 +259,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 2552451bd7c..550e1998bb5 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -604,7 +604,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3746c019a0..26619f35a76 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -155,7 +155,7 @@ async def to_code(config: ConfigType) -> None: { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 7d33b6c49f8..41b7123269a 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -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; } diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 0a34ed037d5..fe937587261 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -8,13 +8,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - CONF_CONTINUOUS, - CONF_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -285,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Optional( CONF_TURNAROUND_TIME, default="600ms" ): cv.positive_time_period_milliseconds, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -297,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 1623c099a34..298cd9f5278 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 741d4f6f00d..d21b3194358 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -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 -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(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 (previously a const std::vector &), 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 data) { return modbus::helpers::bit_from_packed(coil, data); } -template -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 &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 &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(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 float_to_payload(float value, SensorValueType value_type) { - std::vector 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 diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517a..223aa12bec2 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd04..2035f2220a3 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index f2aae201f33..855a7b28c30 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,11 +16,7 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 5e84291b168..b66c0b9e4e0 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -66,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -86,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a86398..a21cbc0f6b9 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,21 +6,9 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +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); } diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb174..aa035be347d 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 27b6eb9fc82..bf8707ff1e8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -169,6 +169,12 @@ def request_protocol(name: str) -> None: cg.add_define(protocol_define(name)) +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + # Only the protocol sources a configuration uses are compiled FILTER_SOURCE_FILES = filter_source_files_from_defines( {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} @@ -182,7 +188,7 @@ def register_binary_sensor( def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: async def new_func(var: MockObj, config: ConfigType) -> None: - request_protocol(name) + _request_protocol_if_in_tree(name) await coroutine(func)(var, config) return registerer(new_func) @@ -200,7 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -259,7 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): - request_protocol(name) + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6eaecf7ab00..866e108131e 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -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)), diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765b..b3e4649096b 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -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; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index e59a8b25573..6f93979b183 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -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) diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 4eebbbb16f1..64392aa7eeb 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -14,25 +15,37 @@ static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + 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() { @@ -47,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) { @@ -63,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; } @@ -77,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; } } @@ -89,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; } @@ -114,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; } } @@ -140,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"); diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 4db4e80a60e..99e1ce9504d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -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 diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 3c9a12d472f..6d27be8d472 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -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; } } diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index dae7df26c32..a1bbf6a3d66 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -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), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b01..a9ce11c33dc 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -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", diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index a0fea216371..9bcdc8bee4e 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -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 ***"); diff --git a/esphome/components/rp2/crash_handler.h b/esphome/components/rp2/crash_handler.h index 8c43d9fd3b0..3aec80b63b2 100644 --- a/esphome/components/rp2/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -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 diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 8af57924a2b..699555f623f 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -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: diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e0..f2f25741f33 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021a..a764b74581f 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fda4d4f954c..49bee109361 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( @@ -11,6 +11,7 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -281,10 +282,15 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f421..59ead1bb539 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e831..c3fb1fe1cbd 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2cb2b909951..58ec57c7681 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -62,14 +62,26 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + this->update_mdns_service_(); +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -96,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -233,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c66c7db3ccb..b00fdc436eb 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -10,6 +10,10 @@ #include "esphome/core/preferences.h" #include "esphome/core/version.h" +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif + #include #include #include @@ -93,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -101,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -111,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -126,6 +130,17 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -135,6 +150,10 @@ class SendspinHub final : public Component, void set_model(const char *model) { this->model_ = model; } void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -204,6 +223,11 @@ class SendspinHub final : public Component, /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -282,11 +306,19 @@ class SendspinHub final : public Component, bool task_stack_in_psram_{false}; + // Requested client state, applied from loop(). Empty until the switch restores its state. + std::optional enabled_; + // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. const char *manufacturer_{"ESPHome"}; const char *model_{nullptr}; // nullptr reports the device name instead const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; + bool mdns_advertised_{false}; // Last state requested from mdns +#endif }; /// @brief Base class for all sendspin subcomponents. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 00000000000..63f5f7ad28b --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) + +CONFIG_SCHEMA = cv.All( + switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 00000000000..0bf029d4c77 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 00000000000..253d952b220 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b1..bee5d7c6d33 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(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 diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af9851..20288fa88e0 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -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 raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 90eb19d73df..e1808889f4b 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -33,9 +31,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +39,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -103,15 +98,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +137,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208bb..cdec1581266 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce58..47b200c2de6 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 8413c7b4936..57e4f222bce 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830e..5888ce5e29b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index edd753d3d2b..729db370531 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -42,11 +42,7 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1683492da76..8b0dfe166fb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -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(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(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(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(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(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 -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 -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(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(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(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; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d60b39278aa..7aa4ac24a3b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -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 diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c5648..743d296d73a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; 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 sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. diff --git a/esphome/const.py b/esphome/const.py index fd95df41965..5ffbf8c49ac 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -352,7 +352,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 38d3503c2c3..50d1c619595 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#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) { diff --git a/esphome/core/application.h b/esphome/core/application.h index f1cf6fcca02..8ed4c09096a 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -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(); diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc7714720..ba8a594340f 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -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 diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623df..c7fd522e1a5 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -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; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index b2b5267b112..fb76f90bcf3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -400,6 +400,7 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS @@ -536,7 +537,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 diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 2be3634c693..d98363dd67e 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -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(" 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) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 986f9dfb8bd..f695bdb7ab4 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -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, ``/.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(): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b3cd5ee09b5..d12a27221b9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -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: @@ -98,7 +98,7 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.7.2 + version: 0.8.0 lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/platformio.ini b/platformio.ini index 722109adec4..37504384cbc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 = diff --git a/requirements_test.txt b/requirements_test.txt index 010c8243e7e..81208d7cb71 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ pylint==4.0.8 flake8==7.3.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.7 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.2 # .github/workflows/ci.yml reads this pin +prek==0.5.3 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py # Unit tests diff --git a/script/ci-custom.py b/script/ci-custom.py index e2b7cd8d376..286dda85b9e 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -247,6 +247,9 @@ def lint_ext_check(fname): "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md", + # Symlinks to the shared .agents/skills directory + ".claude/skills", + ".github/skills", # Symlink to the real wifi scan_list.h so the test stub cannot drift "tests/integration/fixtures/external_components/wifi/scan_list.h", ] @@ -710,7 +713,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1017 +CONST_PY_MAX_CONF = 1016 @lint_content_check(include=["esphome/const.py"]) diff --git a/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml new file mode 100644 index 00000000000..22ee1e480e6 --- /dev/null +++ b/tests/component_tests/esp32/config/file_macro_idf_5_0.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + version: 5.0.6 diff --git a/tests/component_tests/esp32/config/flash_chip_gd.yaml b/tests/component_tests/esp32/config/flash_chip_gd.yaml new file mode 100644 index 00000000000..6d564135c00 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_gd.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: gd diff --git a/tests/component_tests/esp32/config/flash_chip_generic.yaml b/tests/component_tests/esp32/config/flash_chip_generic.yaml new file mode 100644 index 00000000000..8c7bcf61664 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_generic.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + advanced: + flash_chip: generic diff --git a/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml new file mode 100644 index 00000000000..1531e749f20 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_chip_mxic_opi_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf + advanced: + flash_chip: mxic_opi diff --git a/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml new file mode 100644 index 00000000000..82262f63493 --- /dev/null +++ b/tests/component_tests/esp32/config/flash_mode_opi_s3.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + flash_mode: opi + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 2dd2a50c83c..a42d244ac8f 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -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,51 @@ 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", + "board": "esp32dev", + "flash_mode": "opi", + "framework": {"type": "esp-idf"}, + }, + r"'flash_mode: opi' is only supported on ESP32S3 @ data\['flash_mode'\]", + id="flash_mode_opi_only_on_s3", + ), ], ) def test_esp32_configuration_errors( @@ -658,6 +704,27 @@ def test_platformio_arduino_enables_reproducible_build( assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("reproducible_build.yaml", True), + ("reproducible_build_arduino.yaml", True), + ("file_macro_idf_5_0.yaml", False), + ], +) +def test_file_macro_is_basename_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: bool, +) -> None: + """__FILE__ becomes the basename on GCC 12 toolchains; IDF 5.0 (GCC 11) is skipped.""" + generate_main(component_config_path(config_file)) + + assert ("-D__FILE__=__FILE_NAME__" in CORE.build_flags) is expected + assert ("-Wno-builtin-macro-redefined" in CORE.build_flags) is expected + + def test_native_idf_enables_reproducible_build( component_config_path: Callable[[str], Path], ) -> None: @@ -683,10 +750,59 @@ def test_flash_mode_sets_sdkconfig_and_pio_option( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is False assert CORE.platformio_options.get("board_build.flash_mode") == "qio" 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], +) -> None: + """flash_mode: opi needs the octal flash switch or ESP-IDF ignores the mode.""" + generate_main(component_config_path("flash_mode_opi_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_OPI") is True + assert sdkconfig.get("CONFIG_ESPTOOLPY_OCT_FLASH") is True + + def test_flash_mode_unset_leaves_defaults( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d3092294dc1..235ad902dbc 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -319,6 +319,32 @@ def test_encryption_with_captive_portal_does_not_warn( fv.full_config.reset(token) +@pytest.mark.parametrize("extra", [{}, {"prometheus": {}}]) +def test_encryption_with_web_server_ota_disabled_does_not_warn( + caplog: pytest.LogCaptureFixture, extra: dict[str, Any] +) -> None: + """web_server `ota: false` only serves /update while the captive portal is + active, on every listener, so there is no plaintext endpoint to warn about.""" + full_conf = { + "web_server": {CONF_OTA: False}, + **extra, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}), + {CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)}, + ], + } + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + ota_esphome_final_validate({}) + assert not any( + "OTA encryption does not cover" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + def test_password_with_api_key_warns(caplog: pytest.LogCaptureFixture) -> None: """A static api key makes the device offer encryption and the CLI take it, so the password is dead weight; the config validates with a warning.""" diff --git a/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml new file mode 100644 index 00000000000..c9c95ed05c3 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_bk72xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7252 + +remote_receiver: + - id: rcvr + pin: P6 diff --git a/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml new file mode 100644 index 00000000000..e8930d4e17e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_esp32_c61.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: esp32-c61-devkitc1 + variant: esp32c61 + framework: + type: esp-idf + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml new file mode 100644 index 00000000000..8767b546e62 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_ln882x.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h + +remote_receiver: + - id: rcvr + pin: PA4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rp2.yaml b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml new file mode 100644 index 00000000000..cfc66786ba2 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rp2.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rp2: + board: rpipicow + +remote_receiver: + - id: rcvr + pin: GPIO4 diff --git a/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml new file mode 100644 index 00000000000..113bece34c5 --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_rtl87xx.yaml @@ -0,0 +1,9 @@ +esphome: + name: test + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +remote_receiver: + - id: rcvr + pin: PA12 diff --git a/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml new file mode 100644 index 00000000000..e094e5bd52e --- /dev/null +++ b/tests/component_tests/remote_receiver/config/receiver_with_external_protocol.yaml @@ -0,0 +1,36 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +external_components: + - source: + type: local + path: ../external_components + +fake_protocol: + +remote_receiver: + - id: rcvr + pin: GPIO4 + dump: + - fake + - nec + on_fake: + then: + - remote_transmitter.transmit_fake: + on_nec: + then: + - logger.log: nec + +remote_transmitter: + pin: GPIO5 + carrier_duty_percent: 50% + +binary_sensor: + - platform: remote_receiver + name: Fake Input + fake: diff --git a/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py new file mode 100644 index 00000000000..971497aa794 --- /dev/null +++ b/tests/component_tests/remote_receiver/external_components/fake_protocol/__init__.py @@ -0,0 +1,39 @@ +"""External component registering a protocol that has no source file in remote_base.""" + +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.types import ConfigType + +DEPENDENCIES = ["remote_base"] + +ns = cg.esphome_ns.namespace("fake_protocol") +FakeData = ns.struct("FakeData") +FakeBinarySensor = ns.class_( + "FakeBinarySensor", remote_base.RemoteReceiverBinarySensorBase +) +FakeTrigger = ns.class_("FakeTrigger", remote_base.RemoteReceiverTrigger) +FakeAction = ns.class_("FakeAction", remote_base.RemoteTransmitterActionBase) +FakeDumper = ns.class_("FakeDumper", remote_base.RemoteReceiverDumperBase) + +CONFIG_SCHEMA = cv.Schema({}) + + +@remote_base.register_binary_sensor("fake", FakeBinarySensor, {}) +def fake_binary_sensor(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_trigger("fake", FakeTrigger, FakeData) +def fake_trigger(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_dumper("fake", FakeDumper) +def fake_dumper(var: cg.MockObj, config: ConfigType) -> None: + pass + + +@remote_base.register_action("fake", FakeAction, {}) +async def fake_action(var: cg.MockObj, config: ConfigType, args: list) -> None: + pass diff --git a/tests/component_tests/remote_receiver/test_buffer_size.py b/tests/component_tests/remote_receiver/test_buffer_size.py index 9bfd12d9f55..cc4ea49ccb8 100644 --- a/tests/component_tests/remote_receiver/test_buffer_size.py +++ b/tests/component_tests/remote_receiver/test_buffer_size.py @@ -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 diff --git a/tests/component_tests/remote_receiver/test_slot_counts.py b/tests/component_tests/remote_receiver/test_slot_counts.py index 4d69e6d923b..ee79e9a06d5 100644 --- a/tests/component_tests/remote_receiver/test_slot_counts.py +++ b/tests/component_tests/remote_receiver/test_slot_counts.py @@ -1,13 +1,16 @@ """Listener and dumper StaticVector sizes come from codegen slot counts.""" -from collections.abc import Callable +from collections.abc import Callable, Generator from pathlib import Path +import sys import pytest +from esphome import loader from esphome.automation import ACTION_REGISTRY from esphome.components import remote_base import esphome.config_validation as cv +from esphome.core import CORE from ..helpers import get_define_value @@ -74,6 +77,47 @@ def test_every_registry_name_maps_to_a_protocol_source() -> None: assert remote_base._protocol_stem(name) in remote_base._PROTOCOL_STEMS, name +@pytest.fixture +def restore_protocol_registries() -> Generator[None]: + """Loading an external protocol component adds to module-level registries; undo that. + + The loader caches the component too, so drop it or a second load would skip the + decorators and leave the restored registries without the external names. + """ + registries = ( + remote_base.BINARY_SENSOR_REGISTRY, + remote_base.TRIGGER_REGISTRY, + remote_base.DUMPER_REGISTRY, + ACTION_REGISTRY, + ) + saved = [dict(registry) for registry in registries] + yield + for registry, entries in zip(registries, saved, strict=True): + registry.clear() + registry.update(entries) + loader._COMPONENT_CACHE.pop("fake_protocol", None) + sys.modules.pop("esphome.components.fake_protocol", None) + + +@pytest.mark.usefixtures("restore_protocol_registries") +def test_external_protocols_register_without_a_remote_base_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An external protocol goes through all four decorators without a source file here, so no define is emitted.""" + main_cpp = generate_main( + component_config_path("receiver_with_external_protocol.yaml") + ) + defines = {define.name for define in CORE.defines} + assert "USE_REMOTE_PROTOCOL_NEC" in defines + assert "USE_REMOTE_PROTOCOL_FAKE" not in defines + for cls in ("FakeBinarySensor", "FakeTrigger", "FakeDumper", "FakeAction"): + assert f"fake_protocol::{cls}" in main_cpp, cls + # fake and nec dumpers; on_fake and on_nec triggers plus the fake binary sensor + assert get_define_value("REMOTE_BASE_DUMPER_COUNT") == "2" + assert get_define_value("REMOTE_BASE_LISTENER_COUNT") == "3" + + def test_request_protocol_rejects_unknown_names() -> None: """A misspelled protocol would otherwise surface only as a link error.""" with pytest.raises(ValueError, match="Unknown remote protocol 'toshiba'"): diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index 7f31fe59c6f..5c7cb1d61b8 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -22,6 +22,7 @@ esp32: disable_regi2c_in_iram: true disable_fatfs: true sram1_as_iram: true + flash_chip: gd watchdog_timeout: 7s wifi: diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index b9a3b804a8d..5bdf94e8e1d 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -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 diff --git a/tests/components/icnt86/common.yaml b/tests/components/icnt86/common.yaml new file mode 100644 index 00000000000..1537bb8b762 --- /dev/null +++ b/tests/components/icnt86/common.yaml @@ -0,0 +1,24 @@ +touchscreen: + - platform: icnt86 + i2c_id: i2c_bus + interrupt_pin: ${interrupt_pin_touch} + reset_pin: ${reset_pin_touch} + display: epaper + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] + +display: + - platform: waveshare_epaper + id: epaper + rotation: 90 + cs_pin: ${cs_pin_display} + dc_pin: ${dc_pin_display} + busy_pin: ${busy_pin_display} + reset_pin: ${reset_pin_display} + model: 2.90inv2-r2 + pages: + - id: icnt86_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/icnt86/test.esp32-idf.yaml b/tests/components/icnt86/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0b882292a5 --- /dev/null +++ b/tests/components/icnt86/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + interrupt_pin_touch: GPIO4 + reset_pin_touch: GPIO32 + cs_pin_display: GPIO33 + dc_pin_display: GPIO21 + busy_pin_display: GPIO27 + reset_pin_display: GPIO14 + clk_pin: GPIO25 + mosi_pin: GPIO26 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icnt86: !include common.yaml diff --git a/tests/components/mixer/common.yaml b/tests/components/mixer/common.yaml index 55e96df4c27..489475c794f 100644 --- a/tests/components/mixer/common.yaml +++ b/tests/components/mixer/common.yaml @@ -3,7 +3,7 @@ esphome: then: - mixer_speaker.apply_ducking: id: source_speaker_1_id - decibel_reduction: 10 + decibel_reduction: 255 duration: 1s speaker: diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml new file mode 100644 index 00000000000..d332cb0dde0 --- /dev/null +++ b/tests/components/sendspin/common-switch.yaml @@ -0,0 +1,6 @@ +packages: + sendspin: !include common.yaml + +switch: + - platform: sendspin + name: "Sendspin Enabled" diff --git a/tests/components/sendspin/test-switch.esp32-idf.yaml b/tests/components/sendspin/test-switch.esp32-idf.yaml new file mode 100644 index 00000000000..d32c14c054a --- /dev/null +++ b/tests/components/sendspin/test-switch.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-switch.yaml diff --git a/tests/integration/fixtures/sensor_raw_state.yaml b/tests/integration/fixtures/sensor_raw_state.yaml new file mode 100644 index 00000000000..9c19032028f --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state.yaml @@ -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() diff --git a/tests/integration/fixtures/sensor_raw_state_no_filter.yaml b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml new file mode 100644 index 00000000000..fec912691f2 --- /dev/null +++ b/tests/integration/fixtures/sensor_raw_state_no_filter.yaml @@ -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() diff --git a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml index 69edd614d7a..977cdd359be 100644 --- a/tests/integration/fixtures/uart_mock_modbus_mesh.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_mesh.yaml @@ -17,10 +17,10 @@ uart: baud_rate: 115200 port: /dev/null -# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only -# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second -# server hub. auto_start everywhere: the controller polls at boot, so the -# forwarding must already be live or early requests generate warnings. +# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers +# backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6 +# on the second server hub. auto_start everywhere: the controller polls at +# boot, so the forwarding must already be live or early requests generate warnings. # Every test presses Start Scenario, so all merged actions fire in every test. uart_mock: - id: virtual_uart_server @@ -64,6 +64,54 @@ globals: - id: stored_1 type: uint16_t initial_value: "0" + - id: stored_u_word + type: uint16_t + initial_value: "99" + - id: stored_u_word_s + type: uint16_t + initial_value: "4660" + - id: stored_s_word + type: int16_t + initial_value: "-99" + - id: stored_s_word_s + type: int16_t + initial_value: "-2" + - id: stored_u_dword + type: uint32_t + initial_value: "16909060" + - id: stored_s_dword + type: int32_t + initial_value: "-16909060" + - id: stored_u_dword_r + type: uint32_t + initial_value: "67305985" + - id: stored_s_dword_r + type: int32_t + initial_value: "-67305985" + - id: stored_u_qword + type: uint64_t + initial_value: "72623859790382856" + - id: stored_s_qword + type: int64_t + initial_value: "-72623859790382856" + - id: stored_u_qword_r + type: uint64_t + initial_value: "578437695752307201" + - id: stored_s_qword_r + type: int64_t + initial_value: "-578437695752307201" + - id: stored_fp32 + type: float + initial_value: "3.14" + - id: stored_fp32_r + type: float + initial_value: "2.5" + - id: stored_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" modbus: - uart_id: virtual_uart_server @@ -90,6 +138,10 @@ modbus_controller: modbus_id: virtual_modbus_client id: modbus_controller_3 update_interval: 1s + - address: 6 + modbus_id: virtual_modbus_client + id: modbus_controller_6 + update_interval: 1s modbus_server: - address: 1 @@ -97,46 +149,60 @@ modbus_server: registers: - address: 0x01 value_type: U_WORD - read_lambda: return 99; + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; - address: 0x02 value_type: U_WORD_S - read_lambda: return 4660; + read_lambda: return id(stored_u_word_s); + write_lambda: id(stored_u_word_s) = x; return true; - address: 0x03 value_type: S_WORD - read_lambda: return -99; + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; - address: 0x04 value_type: S_WORD_S - read_lambda: return -2; + read_lambda: return id(stored_s_word_s); + write_lambda: id(stored_s_word_s) = x; return true; - address: 0x05 value_type: U_DWORD - read_lambda: return 16909060; + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; - address: 0x08 value_type: S_DWORD - read_lambda: return -16909060; + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; - address: 0x0B value_type: U_DWORD_R - read_lambda: return 67305985; + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; - address: 0x0E value_type: S_DWORD_R - read_lambda: return -67305985; + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; - address: 0x11 value_type: U_QWORD - read_lambda: return 72623859790382856; + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; - address: 0x16 value_type: S_QWORD - read_lambda: return -72623859790382856; + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; - address: 0x1B value_type: U_QWORD_R - read_lambda: return 578437695752307201; + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; - address: 0x20 value_type: S_QWORD_R - read_lambda: return -578437695752307201; + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; - address: 0x25 value_type: FP32 - read_lambda: return 3.14; + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; - address: 0x28 value_type: FP32_R - read_lambda: return 3.14; + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; - address: 5 modbus_id: virtual_modbus_server registers: @@ -165,6 +231,19 @@ modbus_server: - address: 0x01 value_type: U_WORD read_lambda: return 929; + - address: 6 + modbus_id: virtual_modbus_server_2 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; sensor: - platform: modbus_controller @@ -280,6 +359,183 @@ sensor: name: "client_read_1" id: client_read_1 +# The number schema caps min/max at 16777215 (float32 integer precision), so +# the large dword/qword baselines cannot be written back through these numbers. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word_s" + address: 0x02 + register_type: holding + value_type: U_WORD_S + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word_s" + address: 0x04 + register_type: holding + value_type: S_WORD_S + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02); +# the server serves both from one shared table, so the two views must agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_6 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml deleted file mode 100644 index cb6fc6f0740..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml +++ /dev/null @@ -1,147 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-bits - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_bit_2 - type: bool - initial_value: "false" - - id: stored_bit_3 - type: bool - initial_value: "true" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 1s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - bits: - - address: 0x00 - read_lambda: return true; - - address: 0x01 - read_lambda: return false; - - address: 0x02 - read_lambda: return id(stored_bit_2); - write_lambda: id(stored_bit_2) = x; return true; - - address: 0x03 - read_lambda: return id(stored_bit_3); - write_lambda: id(stored_bit_3) = x; return true; - -# The same four bits are read both as coils (FC 0x01) and as discrete inputs -# (FC 0x02): the server serves both from one shared bit table, so the two -# views must always agree. -binary_sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_0" - address: 0x00 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_1" - address: 0x01 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_coil_3" - address: 0x03 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_0" - address: 0x00 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_1" - address: 0x01 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_2" - address: 0x02 - register_type: discrete_input - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "bit_di_3" - address: 0x03 - register_type: discrete_input - -# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the -# multiple-coils write (FC 0x0F) so both server write paths are exercised. -switch: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_2" - address: 0x02 - register_type: coil - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_bit_3" - address: 0x03 - register_type: coil - use_write_multiple: true - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml deleted file mode 100644 index 5ade49bd48c..00000000000 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ /dev/null @@ -1,371 +0,0 @@ -esphome: - name: uart-mock-modbus-srv-write - -host: -api: -logger: - level: VERBOSE - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - -# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] -# The actual UART bus used is the uart_mock component below -uart: - baud_rate: 115200 - port: /dev/null - -uart_mock: - - id: virtual_uart_server - baud_rate: 9600 - # auto_start must be true for loopback fixtures: the modbus controller - # polls on its update_interval immediately at boot, so the uart_mock - # forwarding must already be active or early requests are lost and - # generate modbus warnings. - auto_start: true - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_controller - data: !lambda return data; - - id: virtual_uart_controller - baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above - debug: - on_tx: - - then: - - uart_mock.inject_rx: - id: virtual_uart_server - data: !lambda return data; - -globals: - - id: stored_u_word - type: uint16_t - initial_value: "11" - - id: stored_u_word_s - type: uint16_t - initial_value: "4660" - - id: stored_s_word - type: int16_t - initial_value: "-11" - - id: stored_s_word_s - type: int16_t - initial_value: "-2" - - id: stored_u_dword - type: uint32_t - initial_value: "1001" - - id: stored_s_dword - type: int32_t - initial_value: "-1001" - - id: stored_u_dword_r - type: uint32_t - initial_value: "3003" - - id: stored_s_dword_r - type: int32_t - initial_value: "-3003" - - id: stored_u_qword - type: uint64_t - initial_value: "5005" - - id: stored_s_qword - type: int64_t - initial_value: "-5005" - - id: stored_u_qword_r - type: uint64_t - initial_value: "7007" - - id: stored_s_qword_r - type: int64_t - initial_value: "-7007" - - id: stored_fp32 - type: float - initial_value: "1.5" - - id: stored_fp32_r - type: float - initial_value: "2.5" - -modbus: - - uart_id: virtual_uart_server - id: virtual_modbus_server - role: server - - uart_id: virtual_uart_controller - id: virtual_modbus_controller - role: client - turnaround_time: 10ms - -modbus_controller: - - address: 1 - modbus_id: virtual_modbus_controller - update_interval: 2s - id: modbus_controller_1 - -modbus_server: - - address: 1 - modbus_id: virtual_modbus_server - id: modbus_server_1 - registers: - - address: 0x01 - value_type: U_WORD - read_lambda: return id(stored_u_word); - write_lambda: id(stored_u_word) = x; return true; - - address: 0x02 - value_type: U_WORD_S - read_lambda: return id(stored_u_word_s); - write_lambda: id(stored_u_word_s) = x; return true; - - address: 0x03 - value_type: S_WORD - read_lambda: return id(stored_s_word); - write_lambda: id(stored_s_word) = x; return true; - - address: 0x04 - value_type: S_WORD_S - read_lambda: return id(stored_s_word_s); - write_lambda: id(stored_s_word_s) = x; return true; - - address: 0x05 - value_type: U_DWORD - read_lambda: return id(stored_u_dword); - write_lambda: id(stored_u_dword) = x; return true; - - address: 0x08 - value_type: S_DWORD - read_lambda: return id(stored_s_dword); - write_lambda: id(stored_s_dword) = x; return true; - - address: 0x0B - value_type: U_DWORD_R - read_lambda: return id(stored_u_dword_r); - write_lambda: id(stored_u_dword_r) = x; return true; - - address: 0x0E - value_type: S_DWORD_R - read_lambda: return id(stored_s_dword_r); - write_lambda: id(stored_s_dword_r) = x; return true; - - address: 0x11 - value_type: U_QWORD - read_lambda: return id(stored_u_qword); - write_lambda: id(stored_u_qword) = x; return true; - - address: 0x16 - value_type: S_QWORD - read_lambda: return id(stored_s_qword); - write_lambda: id(stored_s_qword) = x; return true; - - address: 0x1B - value_type: U_QWORD_R - read_lambda: return id(stored_u_qword_r); - write_lambda: id(stored_u_qword_r) = x; return true; - - address: 0x20 - value_type: S_QWORD_R - read_lambda: return id(stored_s_qword_r); - write_lambda: id(stored_s_qword_r) = x; return true; - - address: 0x25 - value_type: FP32 - read_lambda: return id(stored_fp32); - write_lambda: id(stored_fp32) = x; return true; - - address: 0x28 - value_type: FP32_R - read_lambda: return id(stored_fp32_r); - write_lambda: id(stored_fp32_r) = x; return true; - -sensor: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "reg_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - -number: - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word" - address: 0x01 - register_type: holding - value_type: U_WORD - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_word_s" - address: 0x02 - register_type: holding - value_type: U_WORD_S - min_value: 0 - max_value: 65535 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word" - address: 0x03 - register_type: holding - value_type: S_WORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_word_s" - address: 0x04 - register_type: holding - value_type: S_WORD_S - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword" - address: 0x05 - register_type: holding - value_type: U_DWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword" - address: 0x08 - register_type: holding - value_type: S_DWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_dword_r" - address: 0x0B - register_type: holding - value_type: U_DWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_dword_r" - address: 0x0E - register_type: holding - value_type: S_DWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword" - address: 0x11 - register_type: holding - value_type: U_QWORD - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword" - address: 0x16 - register_type: holding - value_type: S_QWORD - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_u_qword_r" - address: 0x1B - register_type: holding - value_type: U_QWORD_R - min_value: 0 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_s_qword_r" - address: 0x20 - register_type: holding - value_type: S_QWORD_R - min_value: -16777215 - max_value: 16777215 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32" - address: 0x25 - register_type: holding - value_type: FP32 - min_value: -16777215 - max_value: 16777215 - step: 0.01 - - platform: modbus_controller - modbus_controller_id: modbus_controller_1 - name: "write_fp32_r" - address: 0x28 - register_type: holding - value_type: FP32_R - min_value: -16777215 - max_value: 16777215 - step: 0.01 - -button: - - platform: template - name: "Start Scenario" - id: start_scenario_btn - # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_sensor_raw_state.py b/tests/integration/test_sensor_raw_state.py new file mode 100644 index 00000000000..a178ebf7d4c --- /dev/null +++ b/tests/integration/test_sensor_raw_state.py @@ -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 diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 232e1fb6543..36aa9a9668c 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -19,23 +19,40 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState import pytest -from .state_utils import SensorTracker, find_entity, wait_for_state +from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction -@dataclass -class RegisterTestCase: - """Test parameters for a single modbus register write/read round-trip.""" +def _swap16(value: int) -> int: + """Byte-swapped view of a 16-bit register as the raw U_WORD wire value.""" + return ((value & 0xFF) << 8) | (value >> 8) - initial_value: object - write_number_name: str - write_value: float - post_write_value: object + +# Raw U_WORD view of reg_u_word_s's initial 0x1234 +MESH_RAW_U_WORD_S = _swap16(4660) + +# Initial values of the mesh fixture's address 1 registers; the +# server_controller test reads them and the write test uses them as baseline. +MESH_INITIAL_VALUES: dict[str, object] = { + "reg_u_word": 99, + "reg_u_word_s": 4660, + "reg_s_word": -99, + "reg_s_word_s": -2, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(2.5), +} # --------------------------------------------------------------------------- @@ -310,23 +327,7 @@ async def test_uart_mock_modbus_server_controller( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - expected_values = { - "reg_u_word": 99, - "reg_u_word_s": 4660, - "reg_u_word_s_raw": 13330, - "reg_s_word": -99, - "reg_s_word_s": -2, - "reg_u_dword": 16909060, - "reg_s_dword": -16909060, - "reg_u_dword_r": pytest.approx(67305985), - "reg_s_dword_r": pytest.approx(-67305985), - "reg_u_qword": pytest.approx(72623859790382856), - "reg_s_qword": pytest.approx(-72623859790382856), - "reg_u_qword_r": pytest.approx(578437695752307201), - "reg_s_qword_r": pytest.approx(-578437695752307201), - "reg_fp32": pytest.approx(3.14), - "reg_fp32_r": pytest.approx(3.14), - } + expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} tracker = SensorTracker(list(expected_values.keys())) futures = tracker.expect_all(expected_values) @@ -334,14 +335,12 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_write( yaml_config: str, @@ -357,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write( line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - register_test_cases: dict[str, RegisterTestCase] = { - "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), - "reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), - "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), - "reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), - "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), - "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), - "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), - "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), - "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), - "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), - "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), - "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), - "reg_fp32": RegisterTestCase( - pytest.approx(1.5, abs=0.01), - "write_fp32", - 3.14, - pytest.approx(3.14, abs=0.01), - ), - "reg_fp32_r": RegisterTestCase( - pytest.approx(2.5, abs=0.01), - "write_fp32_r", - 6.28, - pytest.approx(6.28, abs=0.01), - ), + # Per read-back sensor: the number entity to write through and the value; + # floats read back within tolerance, everything else exactly + register_writes: dict[str, tuple[str, int | float]] = { + "reg_u_word": ("write_u_word", 42), + "reg_u_word_s": ("write_u_word_s", 17185), + "reg_s_word": ("write_s_word", -42), + "reg_s_word_s": ("write_s_word_s", -257), + "reg_u_dword": ("write_u_dword", 2002), + "reg_s_dword": ("write_s_dword", -2002), + "reg_u_dword_r": ("write_u_dword_r", 4004), + "reg_s_dword_r": ("write_s_dword_r", -4004), + "reg_u_qword": ("write_u_qword", 6006), + "reg_s_qword": ("write_s_qword", -6006), + "reg_u_qword_r": ("write_u_qword_r", 8008), + "reg_s_qword_r": ("write_s_qword_r", -8008), + "reg_fp32": ("write_fp32", 6.28), + "reg_fp32_r": ("write_fp32_r", 9.42), } - tracker = SensorTracker(list(register_test_cases.keys())) + tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"]) + # The raw U_WORD view of 0x02 pins the byte swap on the write path: the + # round trip through write_u_word_s applies the swap an even number of + # times, so only the raw sensor can catch a symmetrically dropped swap. # Phase 1: expect initial baseline values initial_futures = tracker.expect_all( - {name: case.initial_value for name, case in register_test_cases.items()} + MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S} ) # Phase 2: expect post-write values (registered now so on_state can match them) written_futures = tracker.expect_all( - {name: case.post_write_value for name, case in register_test_cases.items()} + { + name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value + for name, (_, value) in register_writes.items() + } + | {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])} ) async with ( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the baseline can already be in the - # states the device sends on connect; matching it there saves waiting for - # the next poll entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -410,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write( # connection is working before issuing writes await tracker.await_all(initial_futures, timeout=4.0) - # Issue write commands for all register types - for case in register_test_cases.values(): - entity = find_entity(entities, case.write_number_name, NumberInfo) - assert entity is not None, ( - f"{case.write_number_name} number entity not found" - ) - client.number_command(entity.key, case.write_value) + # Issue write commands for all register types; exact object_id match, + # since several write_* names are prefixes of a sibling + numbers = { + e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo) + } + for number_name, value in register_writes.values(): + entity = numbers.get(number_name) + assert entity is not None, f"{number_name} number entity not found" + client.number_command(entity.key, value) # Wait for sensors to reflect the written values (round-trip write+read) await tracker.await_all(written_futures, timeout=4.0) _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.shared_yaml("uart_mock_modbus_mesh") @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_bits( yaml_config: str, @@ -468,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot and binary sensors drop repeats, so the - # baseline can arrive only in the states the device sends on connect entities = await tracker.setup_and_start_scenario( client, match_initial_states=True ) @@ -480,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits( # Flip both writable bits: 0x02 false -> true, 0x03 true -> false for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): - entity = find_entity(entities, switch_name, SwitchInfo) - assert entity is not None, f"{switch_name} switch entity not found" + entity = require_entity(entities, switch_name, SwitchInfo) client.switch_command(entity.key, value) # Wait for both read views to reflect the written values @@ -508,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - # The controller polls from boot, so the first values can already be in - # the states the device sends on connect; matching them there saves - # waiting for the next poll await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 079f10ddb91..2848d7202df 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -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.""" diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 9deb27d83cb..bb2aab17a24 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -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 .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) diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 0c0852a191e..245184f2d03 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -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(" 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