diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 9661c2ca02..0204b50264 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d272a88e8ca28ae9340a9a03295a566432a52cb696501908f57764475bf7ca65 +c0335c9688ce9defb4a7d4446b93460547e22df055668bacd7d963c770f0c65f diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 8c830d99c7..97fbf7aa9e 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot diff --git a/esphome/__main__.py b/esphome/__main__.py index 09d2855eb1..55297e8d9b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -43,6 +43,7 @@ from esphome.const import ( CONF_SUBSTITUTIONS, CONF_TOPIC, ENV_NOGITIGNORE, + KEY_NATIVE_IDF, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, @@ -116,6 +117,7 @@ class ArgsProtocol(Protocol): configuration: str name: str upload_speed: str | None + native_idf: bool def choose_prompt(options, purpose: str = None): @@ -500,12 +502,15 @@ def wrap_to_code(name, comp): return wrapped -def write_cpp(config: ConfigType) -> int: +def write_cpp(config: ConfigType, native_idf: bool = False) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() + # Store native_idf flag so esp32 component can check it + CORE.data[KEY_NATIVE_IDF] = native_idf + generate_cpp_contents(config) - return write_cpp_file() + return write_cpp_file(native_idf=native_idf) def generate_cpp_contents(config: ConfigType) -> None: @@ -519,32 +524,54 @@ def generate_cpp_contents(config: ConfigType) -> None: CORE.flush_tasks() -def write_cpp_file() -> int: +def write_cpp_file(native_idf: bool = False) -> int: code_s = indent(CORE.cpp_main_section) writer.write_cpp(code_s) - from esphome.build_gen import platformio + if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": + from esphome.build_gen import espidf - platformio.write_project() + espidf.write_project() + else: + from esphome.build_gen import platformio + + platformio.write_project() return 0 def compile_program(args: ArgsProtocol, config: ConfigType) -> int: - from esphome import platformio_api + native_idf = getattr(args, "native_idf", False) # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) - rc = platformio_api.run_compile(config, CORE.verbose) - if rc != 0: - return rc + + if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf": + from esphome import espidf_api + + rc = espidf_api.run_compile(config, CORE.verbose) + if rc != 0: + return rc + + # Create factory.bin and ota.bin + espidf_api.create_factory_bin() + espidf_api.create_ota_bin() + else: + from esphome import platformio_api + + rc = platformio_api.run_compile(config, CORE.verbose) + if rc != 0: + return rc + + idedata = platformio_api.get_idedata(config) + if idedata is None: + return 1 # Check if firmware was rebuilt and emit build_info + create manifest _check_and_emit_build_info() - idedata = platformio_api.get_idedata(config) - return 0 if idedata is not None else 1 + return 0 def _check_and_emit_build_info() -> None: @@ -801,7 +828,8 @@ def command_vscode(args: ArgsProtocol) -> int | None: def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: - exit_code = write_cpp(config) + native_idf = getattr(args, "native_idf", False) + exit_code = write_cpp(config, native_idf=native_idf) if exit_code != 0: return exit_code if args.only_generate: @@ -856,7 +884,8 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: - exit_code = write_cpp(config) + native_idf = getattr(args, "native_idf", False) + exit_code = write_cpp(config, native_idf=native_idf) if exit_code != 0: return exit_code exit_code = compile_program(args, config) @@ -1310,6 +1339,11 @@ def parse_args(argv): help="Only generate source code, do not compile.", action="store_true", ) + parser_compile.add_argument( + "--native-idf", + help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", + action="store_true", + ) parser_upload = subparsers.add_parser( "upload", @@ -1391,6 +1425,11 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) + parser_run.add_argument( + "--native-idf", + help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).", + action="store_true", + ) parser_clean = subparsers.add_parser( "clean-mqtt", diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py new file mode 100644 index 0000000000..f45efb82c1 --- /dev/null +++ b/esphome/build_gen/espidf.py @@ -0,0 +1,139 @@ +"""ESP-IDF direct build generator for ESPHome.""" + +import json +from pathlib import Path + +from esphome.components.esp32 import get_esp32_variant +from esphome.core import CORE +from esphome.helpers import mkdir_p, write_file_if_changed + + +def get_available_components() -> list[str] | None: + """Get list of available ESP-IDF components from project_description.json. + + Returns only internal ESP-IDF components, excluding external/managed + components (from idf_component.yml). + """ + project_desc = Path(CORE.build_path) / "build" / "project_description.json" + if not project_desc.exists(): + return None + + try: + with open(project_desc, encoding="utf-8") as f: + data = json.load(f) + + component_info = data.get("build_component_info", {}) + + result = [] + for name, info in component_info.items(): + # Exclude our own src component + if name == "src": + continue + + # Exclude managed/external components + comp_dir = info.get("dir", "") + if "managed_components" in comp_dir: + continue + + result.append(name) + + return result + except (json.JSONDecodeError, OSError): + return None + + +def has_discovered_components() -> bool: + """Check if we have discovered components from a previous configure.""" + return get_available_components() is not None + + +def get_project_cmakelists() -> str: + """Generate the top-level CMakeLists.txt for ESP-IDF project.""" + # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) + variant = get_esp32_variant() + idf_target = variant.lower().replace("-", "") + + return f"""\ +# Auto-generated by ESPHome +cmake_minimum_required(VERSION 3.16) + +set(IDF_TARGET {idf_target}) +set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) + +include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +project({CORE.name}) +""" + + +def get_component_cmakelists(minimal: bool = False) -> str: + """Generate the main component CMakeLists.txt.""" + idf_requires = [] if minimal else (get_available_components() or []) + requires_str = " ".join(idf_requires) + + # Extract compile definitions from build flags (-DXXX -> XXX) + compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")] + compile_defs_str = "\n ".join(compile_defs) if compile_defs else "" + + # Extract compile options (-W flags, excluding linker flags) + compile_opts = [ + flag + for flag in CORE.build_flags + if flag.startswith("-W") and not flag.startswith("-Wl,") + ] + compile_opts_str = "\n ".join(compile_opts) if compile_opts else "" + + # Extract linker options (-Wl, flags) + link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] + link_opts_str = "\n ".join(link_opts) if link_opts else "" + + return f"""\ +# Auto-generated by ESPHome +file(GLOB_RECURSE app_sources + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" +) + +idf_component_register( + SRCS ${{app_sources}} + INCLUDE_DIRS "." "esphome" + REQUIRES {requires_str} +) + +# Apply C++ standard +target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) + +# ESPHome compile definitions +target_compile_definitions(${{COMPONENT_LIB}} PUBLIC + {compile_defs_str} +) + +# ESPHome compile options +target_compile_options(${{COMPONENT_LIB}} PUBLIC + {compile_opts_str} +) + +# ESPHome linker options +target_link_options(${{COMPONENT_LIB}} PUBLIC + {link_opts_str} +) +""" + + +def write_project(minimal: bool = False) -> None: + """Write ESP-IDF project files.""" + mkdir_p(CORE.build_path) + mkdir_p(CORE.relative_src_path()) + + # Write top-level CMakeLists.txt + write_file_if_changed( + CORE.relative_build_path("CMakeLists.txt"), + get_project_cmakelists(), + ) + + # Write component CMakeLists.txt in src/ + write_file_if_changed( + CORE.relative_src_path("CMakeLists.txt"), + get_component_cmakelists(minimal=minimal), + ) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 607609bbc7..64dd22b0c3 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -160,21 +160,21 @@ async def to_code(config): zephyr_add_user("io-channels", f"<&adc {channel_id}>") zephyr_add_overlay( f""" -&adc {{ - #address-cells = <1>; - #size-cells = <0>; + &adc {{ + #address-cells = <1>; + #size-cells = <0>; - channel@{channel_id} {{ - reg = <{channel_id}>; - zephyr,gain = "{gain}"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; - zephyr,input-positive = ; - zephyr,resolution = <14>; - zephyr,oversampling = <8>; - }}; -}}; -""" + channel@{channel_id} {{ + reg = <{channel_id}>; + zephyr,gain = "{gain}"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <14>; + zephyr,oversampling = <8>; + }}; + }}; + """ ) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 21b0463dfe..4a9257231d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -3,6 +3,7 @@ #ifdef USE_API_NOISE #include "api_connection.h" // For ClientInfo struct #include "esphome/core/application.h" +#include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -256,28 +257,30 @@ APIError APINoiseFrameHelper::state_action_() { } if (state_ == State::SERVER_HELLO) { // send server hello - constexpr size_t mac_len = 13; // 12 hex chars + null terminator const std::string &name = App.get_name(); - char mac[mac_len]; + char mac[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac); // Calculate positions and sizes size_t name_len = name.size() + 1; // including null terminator size_t name_offset = 1; size_t mac_offset = name_offset + name_len; - size_t total_size = 1 + name_len + mac_len; + size_t total_size = 1 + name_len + MAC_ADDRESS_BUFFER_SIZE; - auto msg = std::make_unique(total_size); + // 1 (proto) + name (max ESPHOME_DEVICE_NAME_MAX_LEN) + 1 (name null) + // + mac (MAC_ADDRESS_BUFFER_SIZE - 1) + 1 (mac null) + constexpr size_t max_msg_size = 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + MAC_ADDRESS_BUFFER_SIZE; + uint8_t msg[max_msg_size]; // chosen proto msg[0] = 0x01; // node name, terminated by null byte - std::memcpy(msg.get() + name_offset, name.c_str(), name_len); + std::memcpy(msg + name_offset, name.c_str(), name_len); // node mac, terminated by null byte - std::memcpy(msg.get() + mac_offset, mac, mac_len); + std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - aerr = write_frame_(msg.get(), total_size); + aerr = write_frame_(msg, total_size); if (aerr != APIError::OK) return aerr; @@ -353,35 +356,32 @@ APIError APINoiseFrameHelper::state_action_() { return APIError::OK; } void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { + // Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes + uint8_t data[32]; + data[0] = 0x01; // failure + #ifdef USE_STORE_LOG_STR_IN_FLASH // On ESP8266 with flash strings, we need to use PROGMEM-aware functions size_t reason_len = strlen_P(reinterpret_cast(reason)); - size_t data_size = reason_len + 1; - auto data = std::make_unique(data_size); - data[0] = 0x01; // failure - - // Copy error message from PROGMEM if (reason_len > 0) { - memcpy_P(data.get() + 1, reinterpret_cast(reason), reason_len); + memcpy_P(data + 1, reinterpret_cast(reason), reason_len); } #else // Normal memory access const char *reason_str = LOG_STR_ARG(reason); size_t reason_len = strlen(reason_str); - size_t data_size = reason_len + 1; - auto data = std::make_unique(data_size); - data[0] = 0x01; // failure - - // Copy error message in bulk if (reason_len > 0) { - std::memcpy(data.get() + 1, reason_str, reason_len); + // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string + std::memcpy(data + 1, reason_str, reason_len); } #endif + size_t data_size = reason_len + 1; + // temporarily remove failed state auto orig_state = state_; state_ = State::EXPLICIT_REJECT; - write_frame_(data.get(), data_size); + write_frame_(data, data_size); state_ = orig_state; } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 7b03e4b6a7..6c721652e1 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.esp32 import add_idf_component import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE import esphome.final_validate as fv @@ -165,4 +166,7 @@ def final_validate_audio_schema( async def to_code(config): - cg.add_library("esphome/esp-audio-libs", "2.0.1") + add_idf_component( + name="esphome/esp-audio-libs", + ref="2.0.3", + ) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index d1ad571a52..8f514468c4 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -300,7 +300,7 @@ FileDecoderState AudioDecoder::decode_mp3_() { // Advance read pointer to match the offset for the syncword this->input_transfer_buffer_->decrease_buffer_length(offset); - uint8_t *buffer_start = this->input_transfer_buffer_->get_buffer_start(); + const uint8_t *buffer_start = this->input_transfer_buffer_->get_buffer_start(); buffer_length = (int) this->input_transfer_buffer_->available(); int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length, diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index 1d6f7e23b3..60f56fda54 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -135,8 +135,8 @@ void BluetoothConnection::loop() { // - For V3_WITH_CACHE: Services are never sent, disable after INIT state // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { + if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || + this->send_service_ == DONE_SENDING_SERVICES)) { this->disable_loop(); } } diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index c4507a54e5..46cd89e0e8 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -152,6 +152,13 @@ void CC1101Component::setup() { } } +void CC1101Component::call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) { + for (auto &listener : this->listeners_) { + listener->on_packet(packet, freq_offset, rssi, lqi); + } + this->packet_trigger_->trigger(packet, freq_offset, rssi, lqi); +} + void CC1101Component::loop() { if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || !this->gdo0_pin_->digital_read()) { @@ -198,7 +205,7 @@ void CC1101Component::loop() { bool crc_ok = (this->state_.LQI & STATUS_CRC_OK_MASK) != 0; uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK; if (this->state_.CRC_EN == 0 || crc_ok) { - this->packet_trigger_->trigger(this->packet_, freq_offset, rssi, lqi); + this->call_listeners_(this->packet_, freq_offset, rssi, lqi); } // Return to rx diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 43ae5b3612..6e3f01af90 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -11,6 +11,11 @@ namespace esphome::cc1101 { enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW, PLL_LOCK }; +class CC1101Listener { + public: + virtual void on_packet(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) = 0; +}; + class CC1101Component : public Component, public spi::SPIDevice { @@ -73,6 +78,7 @@ class CC1101Component : public Component, // Packet mode operations CC1101Error transmit_packet(const std::vector &packet); + void register_listener(CC1101Listener *listener) { this->listeners_.push_back(listener); } Trigger, float, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; } protected: @@ -89,9 +95,11 @@ class CC1101Component : public Component, InternalGPIOPin *gdo0_pin_{nullptr}; // Packet handling + void call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi); Trigger, float, float, uint8_t> *packet_trigger_{ new Trigger, float, float, uint8_t>()}; std::vector packet_; + std::vector listeners_; // Low-level Helpers uint8_t strobe_(Command cmd); diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index 11b13f5851..99c3017510 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -76,7 +76,6 @@ class CS5460AComponent : public Component, void restart() { restart_(); } void setup() override; - void loop() override {} void dump_config() override; protected: diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index c5ea051914..3ba488c0aa 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -106,9 +106,9 @@ DateCall &DateCall::set_date(uint16_t year, uint8_t month, uint8_t day) { DateCall &DateCall::set_date(ESPTime time) { return this->set_date(time.year, time.month, time.day_of_month); }; -DateCall &DateCall::set_date(const std::string &date) { +DateCall &DateCall::set_date(const char *date, size_t len) { ESPTime val{}; - if (!ESPTime::strptime(date, val)) { + if (!ESPTime::strptime(date, len, val)) { ESP_LOGE(TAG, "Could not convert the date string to an ESPTime object"); return *this; } diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 069116d162..955fd92c45 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -67,7 +67,9 @@ class DateCall { void perform(); DateCall &set_date(uint16_t year, uint8_t month, uint8_t day); DateCall &set_date(ESPTime time); - DateCall &set_date(const std::string &date); + DateCall &set_date(const char *date, size_t len); + DateCall &set_date(const char *date) { return this->set_date(date, strlen(date)); } + DateCall &set_date(const std::string &date) { return this->set_date(date.c_str(), date.size()); } DateCall &set_year(uint16_t year) { this->year_ = year; diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index fd3901fcfc..730abb3ca8 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -163,9 +163,9 @@ DateTimeCall &DateTimeCall::set_datetime(ESPTime datetime) { datetime.second); }; -DateTimeCall &DateTimeCall::set_datetime(const std::string &datetime) { +DateTimeCall &DateTimeCall::set_datetime(const char *datetime, size_t len) { ESPTime val{}; - if (!ESPTime::strptime(datetime, val)) { + if (!ESPTime::strptime(datetime, len, val)) { ESP_LOGE(TAG, "Could not convert the time string to an ESPTime object"); return *this; } diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 018346b34b..b5b8cd677e 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -71,7 +71,11 @@ class DateTimeCall { void perform(); DateTimeCall &set_datetime(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second); DateTimeCall &set_datetime(ESPTime datetime); - DateTimeCall &set_datetime(const std::string &datetime); + DateTimeCall &set_datetime(const char *datetime, size_t len); + DateTimeCall &set_datetime(const char *datetime) { return this->set_datetime(datetime, strlen(datetime)); } + DateTimeCall &set_datetime(const std::string &datetime) { + return this->set_datetime(datetime.c_str(), datetime.size()); + } DateTimeCall &set_datetime(time_t epoch_seconds); DateTimeCall &set_year(uint16_t year) { diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index d0b8875ed1..74e43fbbe7 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -74,9 +74,9 @@ TimeCall &TimeCall::set_time(uint8_t hour, uint8_t minute, uint8_t second) { TimeCall &TimeCall::set_time(ESPTime time) { return this->set_time(time.hour, time.minute, time.second); }; -TimeCall &TimeCall::set_time(const std::string &time) { +TimeCall &TimeCall::set_time(const char *time, size_t len) { ESPTime val{}; - if (!ESPTime::strptime(time, val)) { + if (!ESPTime::strptime(time, len, val)) { ESP_LOGE(TAG, "Could not convert the time string to an ESPTime object"); return *this; } diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index d3be3130b1..e4bb113eb5 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -69,7 +69,9 @@ class TimeCall { void perform(); TimeCall &set_time(uint8_t hour, uint8_t minute, uint8_t second); TimeCall &set_time(ESPTime time); - TimeCall &set_time(const std::string &time); + TimeCall &set_time(const char *time, size_t len); + TimeCall &set_time(const char *time) { return this->set_time(time, strlen(time)); } + TimeCall &set_time(const std::string &time) { return this->set_time(time.c_str(), time.size()); } TimeCall &set_hour(uint8_t hour) { this->hour_ = hour; diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 19f15d7d98..a4b6468b49 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -3,21 +3,80 @@ #include "esphome/core/log.h" #include +extern "C" { +#include + +// Global reset info struct populated by SDK at boot +extern struct rst_info resetInfo; + +// Core version - either a string pointer or a version number to format as hex +extern uint32_t core_version; +extern const char *core_release; +} + namespace esphome { namespace debug { static const char *const TAG = "debug"; +// Get reset reason string from reason code (no heap allocation) +// Returns LogString* pointing to flash (PROGMEM) on ESP8266 +static const LogString *get_reset_reason_str(uint32_t reason) { + switch (reason) { + case REASON_DEFAULT_RST: + return LOG_STR("Power On"); + case REASON_WDT_RST: + return LOG_STR("Hardware Watchdog"); + case REASON_EXCEPTION_RST: + return LOG_STR("Exception"); + case REASON_SOFT_WDT_RST: + return LOG_STR("Software Watchdog"); + case REASON_SOFT_RESTART: + return LOG_STR("Software/System restart"); + case REASON_DEEP_SLEEP_AWAKE: + return LOG_STR("Deep-Sleep Wake"); + case REASON_EXT_SYS_RST: + return LOG_STR("External System"); + default: + return LOG_STR("Unknown"); + } +} + +// Size for core version hex buffer +static constexpr size_t CORE_VERSION_BUFFER_SIZE = 12; + +// Get core version string (no heap allocation) +// Returns either core_release directly or formats core_version as hex into provided buffer +static const char *get_core_version_str(std::span buffer) { + if (core_release != nullptr) { + return core_release; + } + snprintf_P(buffer.data(), CORE_VERSION_BUFFER_SIZE, PSTR("%08x"), core_version); + return buffer.data(); +} + +// Size for reset info buffer +static constexpr size_t RESET_INFO_BUFFER_SIZE = 200; + +// Get detailed reset info string (no heap allocation) +// For watchdog/exception resets, includes detailed exception info +static const char *get_reset_info_str(std::span buffer, uint32_t reason) { + if (reason >= REASON_WDT_RST && reason <= REASON_SOFT_WDT_RST) { + snprintf_P(buffer.data(), RESET_INFO_BUFFER_SIZE, + PSTR("Fatal exception:%d flag:%d (%s) epc1:0x%08x epc2:0x%08x epc3:0x%08x excvaddr:0x%08x depc:0x%08x"), + static_cast(resetInfo.exccause), static_cast(reason), + LOG_STR_ARG(get_reset_reason_str(reason)), resetInfo.epc1, resetInfo.epc2, resetInfo.epc3, + resetInfo.excvaddr, resetInfo.depc); + return buffer.data(); + } + return LOG_STR_ARG(get_reset_reason_str(reason)); +} + const char *DebugComponent::get_reset_reason_(std::span buffer) { - char *buf = buffer.data(); -#if !defined(CLANG_TIDY) - String reason = ESP.getResetReason(); // NOLINT - snprintf_P(buf, RESET_REASON_BUFFER_SIZE, PSTR("%s"), reason.c_str()); - return buf; -#else - buf[0] = '\0'; - return buf; -#endif + // Copy from flash to provided buffer + strncpy_P(buffer.data(), (PGM_P) get_reset_reason_str(resetInfo.reason), RESET_REASON_BUFFER_SIZE - 1); + buffer[RESET_REASON_BUFFER_SIZE - 1] = '\0'; + return buffer.data(); } const char *DebugComponent::get_wakeup_cause_(std::span buffer) { @@ -33,37 +92,42 @@ size_t DebugComponent::get_device_info_(std::span constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - const char *flash_mode; + const LogString *flash_mode; switch (ESP.getFlashChipMode()) { // NOLINT(readability-static-accessed-through-instance) case FM_QIO: - flash_mode = "QIO"; + flash_mode = LOG_STR("QIO"); break; case FM_QOUT: - flash_mode = "QOUT"; + flash_mode = LOG_STR("QOUT"); break; case FM_DIO: - flash_mode = "DIO"; + flash_mode = LOG_STR("DIO"); break; case FM_DOUT: - flash_mode = "DOUT"; + flash_mode = LOG_STR("DOUT"); break; default: - flash_mode = "UNKNOWN"; + flash_mode = LOG_STR("UNKNOWN"); } - uint32_t flash_size = ESP.getFlashChipSize() / 1024; // NOLINT - uint32_t flash_speed = ESP.getFlashChipSpeed() / 1000000; // NOLINT - ESP_LOGD(TAG, "Flash Chip: Size=%" PRIu32 "kB Speed=%" PRIu32 "MHz Mode=%s", flash_size, flash_speed, flash_mode); + uint32_t flash_size = ESP.getFlashChipSize() / 1024; // NOLINT(readability-static-accessed-through-instance) + uint32_t flash_speed = ESP.getFlashChipSpeed() / 1000000; // NOLINT(readability-static-accessed-through-instance) + ESP_LOGD(TAG, "Flash Chip: Size=%" PRIu32 "kB Speed=%" PRIu32 "MHz Mode=%s", flash_size, flash_speed, + LOG_STR_ARG(flash_mode)); pos = buf_append_printf(buf, size, pos, "|Flash: %" PRIu32 "kB Speed:%" PRIu32 "MHz Mode:%s", flash_size, flash_speed, - flash_mode); + LOG_STR_ARG(flash_mode)); -#if !defined(CLANG_TIDY) char reason_buffer[RESET_REASON_BUFFER_SIZE]; - const char *reset_reason = get_reset_reason_(std::span(reason_buffer)); + const char *reset_reason = get_reset_reason_(reason_buffer); + char core_version_buffer[CORE_VERSION_BUFFER_SIZE]; + char reset_info_buffer[RESET_INFO_BUFFER_SIZE]; + // NOLINTBEGIN(readability-static-accessed-through-instance) uint32_t chip_id = ESP.getChipId(); uint8_t boot_version = ESP.getBootVersion(); uint8_t boot_mode = ESP.getBootMode(); uint8_t cpu_freq = ESP.getCpuFreqMHz(); uint32_t flash_chip_id = ESP.getFlashChipId(); + const char *sdk_version = ESP.getSdkVersion(); + // NOLINTEND(readability-static-accessed-through-instance) ESP_LOGD(TAG, "Chip ID: 0x%08" PRIX32 "\n" @@ -74,19 +138,18 @@ size_t DebugComponent::get_device_info_(std::span "Flash Chip ID=0x%08" PRIX32 "\n" "Reset Reason: %s\n" "Reset Info: %s", - chip_id, ESP.getSdkVersion(), ESP.getCoreVersion().c_str(), boot_version, boot_mode, cpu_freq, flash_chip_id, - reset_reason, ESP.getResetInfo().c_str()); + chip_id, sdk_version, get_core_version_str(core_version_buffer), boot_version, boot_mode, cpu_freq, + flash_chip_id, reset_reason, get_reset_info_str(reset_info_buffer, resetInfo.reason)); pos = buf_append_printf(buf, size, pos, "|Chip: 0x%08" PRIX32, chip_id); - pos = buf_append_printf(buf, size, pos, "|SDK: %s", ESP.getSdkVersion()); - pos = buf_append_printf(buf, size, pos, "|Core: %s", ESP.getCoreVersion().c_str()); + pos = buf_append_printf(buf, size, pos, "|SDK: %s", sdk_version); + pos = buf_append_printf(buf, size, pos, "|Core: %s", get_core_version_str(core_version_buffer)); pos = buf_append_printf(buf, size, pos, "|Boot: %u", boot_version); pos = buf_append_printf(buf, size, pos, "|Mode: %u", boot_mode); pos = buf_append_printf(buf, size, pos, "|CPU: %u", cpu_freq); pos = buf_append_printf(buf, size, pos, "|Flash: 0x%08" PRIX32, flash_chip_id); pos = buf_append_printf(buf, size, pos, "|Reset: %s", reset_reason); - pos = buf_append_printf(buf, size, pos, "|%s", ESP.getResetInfo().c_str()); -#endif + pos = buf_append_printf(buf, size, pos, "|%s", get_reset_info_str(reset_info_buffer, resetInfo.reason)); return pos; } diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index 6a88522b0d..0291cc3061 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -132,6 +132,26 @@ void DebugComponent::log_partition_info_() { flash_area_foreach(fa_cb, nullptr); } +static const char *regout0_to_str(uint32_t value) { + switch (value) { + case (UICR_REGOUT0_VOUT_DEFAULT): + return "1.8V (default)"; + case (UICR_REGOUT0_VOUT_1V8): + return "1.8V"; + case (UICR_REGOUT0_VOUT_2V1): + return "2.1V"; + case (UICR_REGOUT0_VOUT_2V4): + return "2.4V"; + case (UICR_REGOUT0_VOUT_2V7): + return "2.7V"; + case (UICR_REGOUT0_VOUT_3V0): + return "3.0V"; + case (UICR_REGOUT0_VOUT_3V3): + return "3.3V"; + } + return "???V"; +} + size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); @@ -145,34 +165,14 @@ size_t DebugComponent::get_device_info_(std::span // Regulator stage 0 if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_HIGH) { const char *reg0_type = nrf_power_dcdcen_vddh_get(NRF_POWER) ? "DC/DC" : "LDO"; - const char *reg0_voltage; - switch (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) { - case (UICR_REGOUT0_VOUT_DEFAULT << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "1.8V (default)"; - break; - case (UICR_REGOUT0_VOUT_1V8 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "1.8V"; - break; - case (UICR_REGOUT0_VOUT_2V1 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "2.1V"; - break; - case (UICR_REGOUT0_VOUT_2V4 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "2.4V"; - break; - case (UICR_REGOUT0_VOUT_2V7 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "2.7V"; - break; - case (UICR_REGOUT0_VOUT_3V0 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "3.0V"; - break; - case (UICR_REGOUT0_VOUT_3V3 << UICR_REGOUT0_VOUT_Pos): - reg0_voltage = "3.3V"; - break; - default: - reg0_voltage = "???V"; - } + const char *reg0_voltage = regout0_to_str((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos); ESP_LOGD(TAG, "Regulator stage 0: %s, %s", reg0_type, reg0_voltage); pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: %s, %s", reg0_type, reg0_voltage); +#ifdef USE_NRF52_REG0_VOUT + if ((NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) >> UICR_REGOUT0_VOUT_Pos != USE_NRF52_REG0_VOUT) { + ESP_LOGE(TAG, "Regulator stage 0: expected %s", regout0_to_str(USE_NRF52_REG0_VOUT)); + } +#endif } else { ESP_LOGD(TAG, "Regulator stage 0: disabled"); pos = buf_append_printf(buf, size, pos, "|Regulator stage 0: disabled"); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 45fe8d1c26..07446ab046 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -34,6 +34,7 @@ from esphome.const import ( KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, + KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_ESP32, @@ -53,6 +54,7 @@ from .const import ( # noqa KEY_COMPONENTS, KEY_ESP32, KEY_EXTRA_BUILD_FILES, + KEY_FLASH_SIZE, KEY_PATH, KEY_REF, KEY_REPO, @@ -199,6 +201,7 @@ def set_core_data(config): ) CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES] = {} @@ -962,12 +965,54 @@ async def _add_yaml_idf_components(components: list[ConfigType]): async def to_code(config): - cg.add_platformio_option("board", config[CONF_BOARD]) - cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) - cg.add_platformio_option( - "board_upload.maximum_size", - int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, - ) + framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + conf = config[CONF_FRAMEWORK] + + # Check if using native ESP-IDF build (--native-idf) + use_platformio = not CORE.data.get(KEY_NATIVE_IDF, False) + if use_platformio: + # Clear IDF environment variables to avoid conflicts with PlatformIO's ESP-IDF + # but keep them when using --native-idf for native ESP-IDF builds + for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): + os.environ.pop(clean_var, None) + + cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) + cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) + + if CONF_SOURCE in conf: + cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) + + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + + add_extra_script( + "post", + "post_build.py", + Path(__file__).parent / "post_build.py.script", + ) + + # In testing mode, add IRAM fix script to allow linking grouped component tests + # Similar to ESP8266's approach but for ESP-IDF + if CORE.testing_mode: + cg.add_build_flag("-DESPHOME_TESTING_MODE") + add_extra_script( + "pre", + "iram_fix.py", + Path(__file__).parent / "iram_fix.py.script", + ) + else: + cg.add_build_flag("-Wno-error=format") + cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_build_flag("-Wl,-z,noexecstack") @@ -977,79 +1022,49 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant]) cg.add_define(ThreadModel.MULTI_ATOMICS) - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - - framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - - conf = config[CONF_FRAMEWORK] - cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) - if CONF_SOURCE in conf: - cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) - if conf[CONF_ADVANCED][CONF_IGNORE_EFUSE_CUSTOM_MAC]: cg.add_define("USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC") - for clean_var in ("IDF_PATH", "IDF_TOOLS_PATH"): - os.environ.pop(clean_var, None) - # Set the location of the IDF component manager cache os.environ["IDF_COMPONENT_CACHE_PATH"] = str( CORE.relative_internal_path(".espressif") ) - add_extra_script( - "pre", - "pre_build.py", - Path(__file__).parent / "pre_build.py.script", - ) - - add_extra_script( - "post", - "post_build.py", - Path(__file__).parent / "post_build.py.script", - ) - - # In testing mode, add IRAM fix script to allow linking grouped component tests - # Similar to ESP8266's approach but for ESP-IDF - if CORE.testing_mode: - cg.add_build_flag("-DESPHOME_TESTING_MODE") - add_extra_script( - "pre", - "iram_fix.py", - Path(__file__).parent / "iram_fix.py.script", - ) - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_platformio_option("framework", "espidf") cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") + if use_platformio: + cg.add_platformio_option("framework", "espidf") else: - cg.add_platformio_option("framework", "arduino, espidf") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") + if use_platformio: + cg.add_platformio_option("framework", "arduino, espidf") + + # Add IDF framework source for Arduino builds to ensure it uses the same version as + # the ESP-IDF framework + if (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: + cg.add_platformio_option( + "platform_packages", + [_format_framework_espidf_version(idf_ver, None)], + ) + + # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency + if get_esp32_variant() == VARIANT_ESP32S2: + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_define( "USE_ARDUINO_VERSION_CODE", cg.RawExpression( f"VERSION_CODE({framework_ver.major}, {framework_ver.minor}, {framework_ver.patch})" ), ) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) - # Add IDF framework source for Arduino builds to ensure it uses the same version as - # the ESP-IDF framework - if (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - cg.add_platformio_option( - "platform_packages", [_format_framework_espidf_version(idf_ver, None)] - ) - - # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency - if get_esp32_variant() == VARIANT_ESP32S2: - cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") - cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") - cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") - cg.add_build_flag("-Wno-nonnull-compare") add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) @@ -1196,7 +1211,8 @@ async def to_code(config): "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] ) - cg.add_platformio_option("board_build.partitions", "partitions.csv") + if use_platformio: + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( "partitions.csv", CORE.relative_config_path(config[CONF_PARTITIONS]) @@ -1361,19 +1377,16 @@ def copy_files(): _write_idf_component_yml() if "partitions.csv" not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: + flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] if CORE.using_arduino: write_file_if_changed( CORE.relative_build_path("partitions.csv"), - get_arduino_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), + get_arduino_partition_csv(flash_size), ) else: write_file_if_changed( CORE.relative_build_path("partitions.csv"), - get_idf_partition_csv( - CORE.platformio_options.get("board_upload.flash_size") - ), + get_idf_partition_csv(flash_size), ) # IDF build scripts look for version string to put in the build. # However, if the build path does not have an initialized git repo, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index dfb736f615..2a9456db23 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -2,6 +2,7 @@ import esphome.codegen as cg KEY_ESP32 = "esp32" KEY_BOARD = "board" +KEY_FLASH_SIZE = "flash_size" KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 08439746b6..4e0bb68133 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -181,7 +181,8 @@ class ESP32Preferences : public ESPPreferences { if (actual_len != to_save.len) { return true; } - auto stored_data = std::make_unique(actual_len); + // Most preferences are small, use stack buffer with heap fallback for large ones + SmallBufferWithHeapFallback<256> stored_data(actual_len); err = nvs_get_blob(nvs_handle, key_str, stored_data.get(), &actual_len); if (err != 0) { ESP_LOGV(TAG, "nvs_get_blob('%s') failed: %s", key_str, esp_err_to_name(err)); diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 01f79156a9..c464c89390 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -50,7 +50,7 @@ void BLEClientBase::loop() { this->set_state(espbt::ClientState::INIT); return; } - if (this->state_ == espbt::ClientState::INIT) { + if (this->state() == espbt::ClientState::INIT) { auto ret = esp_ble_gattc_app_register(this->app_id); if (ret) { ESP_LOGE(TAG, "gattc app register failed. app_id=%d code=%d", this->app_id, ret); @@ -60,7 +60,7 @@ void BLEClientBase::loop() { } // If idle, we can disable the loop as connect() // will enable it again when a connection is needed. - else if (this->state_ == espbt::ClientState::IDLE) { + else if (this->state() == espbt::ClientState::IDLE) { this->disable_loop(); } } @@ -86,7 +86,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->address_ == 0 || device.address_uint64() != this->address_) return false; - if (this->state_ != espbt::ClientState::IDLE) + if (this->state() != espbt::ClientState::IDLE) return false; this->log_event_("Found device"); @@ -102,10 +102,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { void BLEClientBase::connect() { // Prevent duplicate connection attempts - if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || - this->state_ == espbt::ClientState::ESTABLISHED) { + if (this->state() == espbt::ClientState::CONNECTING || this->state() == espbt::ClientState::CONNECTED || + this->state() == espbt::ClientState::ESTABLISHED) { ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_, - espbt::client_state_to_string(this->state_)); + espbt::client_state_to_string(this->state())); return; } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); @@ -133,12 +133,12 @@ void BLEClientBase::connect() { esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } void BLEClientBase::disconnect() { - if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) { + if (this->state() == espbt::ClientState::IDLE || this->state() == espbt::ClientState::DISCONNECTING) { ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_, - espbt::client_state_to_string(this->state_)); + espbt::client_state_to_string(this->state())); return; } - if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + if (this->state() == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, this->address_str_); this->want_disconnect_ = true; @@ -150,7 +150,7 @@ void BLEClientBase::disconnect() { void BLEClientBase::unconditional_disconnect() { // Disconnect without checking the state. ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_, this->conn_id_); - if (this->state_ == espbt::ClientState::DISCONNECTING) { + if (this->state() == espbt::ClientState::DISCONNECTING) { this->log_error_("Already disconnecting"); return; } @@ -170,7 +170,7 @@ void BLEClientBase::unconditional_disconnect() { this->log_gattc_warning_("esp_ble_gattc_close", err); } - if (this->state_ == espbt::ClientState::DISCOVERED) { + if (this->state() == espbt::ClientState::DISCOVERED) { this->set_address(0); this->set_state(espbt::ClientState::IDLE); } else { @@ -295,18 +295,18 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an // error, if the error occurred at the BTA/GATT layer. This can result in the event // arriving after we've already transitioned to IDLE state. - if (this->state_ == espbt::ClientState::IDLE) { + if (this->state() == espbt::ClientState::IDLE) { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, this->address_str_, param->open.status); break; } - if (this->state_ != espbt::ClientState::CONNECTING) { + if (this->state() != espbt::ClientState::CONNECTING) { // This should not happen but lets log it in case it does // because it means we have a bad assumption about how the // ESP BT stack works. ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, - this->address_str_, espbt::client_state_to_string(this->state_), param->open.status); + this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -327,7 +327,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. - this->state_ = espbt::ClientState::ESTABLISHED; + this->set_state_internal_(espbt::ClientState::ESTABLISHED); break; } // For V3_WITHOUT_CACHE, we already set fast params before connecting @@ -356,7 +356,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ return false; // Check if we were disconnected while waiting for service discovery if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && - this->state_ == espbt::ClientState::CONNECTED) { + this->state() == espbt::ClientState::CONNECTED) { this->log_warning_("Remote closed during discovery"); } else { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, @@ -433,7 +433,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ #endif } ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_); - this->state_ = espbt::ClientState::ESTABLISHED; + this->set_state_internal_(espbt::ClientState::ESTABLISHED); break; } case ESP_GATTC_READ_DESCR_EVT: { diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index c52f0e5d2d..c2336b2349 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -44,7 +44,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void unconditional_disconnect(); void release_services(); - bool connected() { return this->state_ == espbt::ClientState::ESTABLISHED; } + bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; } void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 995755ac84..73a298d279 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -105,15 +105,13 @@ void ESP32BLETracker::loop() { } // Check for scan timeout - moved here from scheduler to avoid false reboots - // when the loop is blocked + // when the loop is blocked. This must run every iteration for safety. if (this->scanner_state_ == ScannerState::RUNNING) { switch (this->scan_timeout_state_) { case ScanTimeoutState::MONITORING: { - uint32_t now = App.get_loop_component_start_time(); - uint32_t timeout_ms = this->scan_duration_ * 2000; // Robust time comparison that handles rollover correctly // This works because unsigned arithmetic wraps around predictably - if ((now - this->scan_start_time_) > timeout_ms) { + if ((App.get_loop_component_start_time() - this->scan_start_time_) > this->scan_timeout_ms_) { // First time we've seen the timeout exceeded - wait one more loop iteration // This ensures all components have had a chance to process pending events // This is because esp32_ble may not have run yet and called @@ -128,13 +126,31 @@ void ESP32BLETracker::loop() { ESP_LOGE(TAG, "Scan never terminated, rebooting"); App.reboot(); break; - case ScanTimeoutState::INACTIVE: - // This case should be unreachable - scanner and timeout states are always synchronized break; } } + // Fast path: skip expensive client state counting and processing + // if no state has changed since last loop iteration. + // + // How state changes ensure we reach the code below: + // - handle_scanner_failure_(): scanner_state_ becomes FAILED via set_scanner_state_(), or + // scan_set_param_failed_ requires scanner_state_==RUNNING which can only be reached via + // set_scanner_state_(RUNNING) in gap_scan_start_complete_() (scan params are set during + // STARTING, not RUNNING, so version is always incremented before this condition is true) + // - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_() + // - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or + // connecting client finishes (state change), or scanner reaches RUNNING/IDLE + // + // All conditions that affect the logic below are tied to state changes that increment + // state_version_, so the fast path is safe. + if (this->state_version_ == this->last_processed_version_) { + return; + } + this->last_processed_version_ = this->state_version_; + + // State changed - do full processing ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; @@ -142,6 +158,7 @@ void ESP32BLETracker::loop() { this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); } + // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set if (this->scanner_state_ == ScannerState::FAILED || (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->handle_scanner_failure_(); @@ -160,6 +177,8 @@ void ESP32BLETracker::loop() { */ + // Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and + // all clients are idle (their state changes increment version when they finish) if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(false); @@ -168,8 +187,9 @@ void ESP32BLETracker::loop() { this->start_scan_(false); // first = false } } - // If there is a discovered client and no connecting - // clients, then promote the discovered client to ready to connect. + // Promote discovered clients: reached when a client's state becomes DISCOVERED (via set_state()), + // or when a blocking condition clears (connecting client finishes, scanner reaches RUNNING/IDLE). + // All these trigger state_version_ increment, so we'll process and check promotion eligibility. // We check both RUNNING and IDLE states because: // - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately // - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler) @@ -236,6 +256,7 @@ void ESP32BLETracker::start_scan_(bool first) { // Start timeout monitoring in loop() instead of using scheduler // This prevents false reboots when the loop is blocked this->scan_start_time_ = App.get_loop_component_start_time(); + this->scan_timeout_ms_ = this->scan_duration_ * 2000; this->scan_timeout_state_ = ScanTimeoutState::MONITORING; esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_); @@ -253,6 +274,10 @@ void ESP32BLETracker::start_scan_(bool first) { void ESP32BLETracker::register_client(ESPBTClient *client) { #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT client->app_id = ++this->app_id_; + // Give client a pointer to our state_version_ so it can notify us of state changes. + // This enables loop() fast-path optimization - we skip expensive work when no state changed. + // Safe because ESP32BLETracker (singleton) outlives all registered clients. + client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); this->recalculate_advertisement_parser_types(); #endif @@ -382,6 +407,7 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; + this->state_version_++; for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f538a0eddc..fa0cdb6f45 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -216,6 +216,19 @@ enum class ConnectionType : uint8_t { V3_WITHOUT_CACHE }; +/// Base class for BLE GATT clients that connect to remote devices. +/// +/// State Change Tracking Design: +/// ----------------------------- +/// ESP32BLETracker::loop() needs to know when client states change to avoid +/// expensive polling. Rather than checking all clients every iteration (~7000/min), +/// we use a version counter owned by ESP32BLETracker that clients increment on +/// state changes. The tracker compares versions to skip work when nothing changed. +/// +/// Ownership: ESP32BLETracker owns state_version_. Clients hold a non-owning +/// pointer (tracker_state_version_) set during register_client(). Clients +/// increment the counter through this pointer when their state changes. +/// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { public: virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -225,26 +238,49 @@ class ESPBTClient : public ESPBTDeviceListener { virtual void disconnect() = 0; bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } + + /// Set the client state with IDLE handling (clears want_disconnect_). + /// Notifies the tracker of state change for loop optimization. virtual void set_state(ClientState st) { - this->state_ = st; + this->set_state_internal_(st); if (st == ClientState::IDLE) { this->want_disconnect_ = false; } } - ClientState state() const { return state_; } + ClientState state() const { return this->state_; } + + /// Called by ESP32BLETracker::register_client() to enable state change notifications. + /// The pointer must remain valid for the lifetime of the client (guaranteed since + /// ESP32BLETracker is a singleton that outlives all clients). + void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout uint8_t app_id; // App IDs are small integers assigned sequentially protected: - // Group 1: 1-byte types - ClientState state_{ClientState::INIT}; + /// Set state without IDLE handling - use for direct state transitions. + /// Increments the tracker's state version counter to signal that loop() + /// should do full processing on the next iteration. + void set_state_internal_(ClientState st) { + this->state_ = st; + // Notify tracker that state changed (tracker_state_version_ is owned by ESP32BLETracker) + if (this->tracker_state_version_ != nullptr) { + (*this->tracker_state_version_)++; + } + } + // want_disconnect_ is set to true when a disconnect is requested // while the client is connecting. This is used to disconnect the // client as soon as we get the connection id (conn_id_) from the // ESP_GATTC_OPEN_EVT event. bool want_disconnect_{false}; - // 2 bytes used, 2 bytes padding + + private: + ClientState state_{ClientState::INIT}; + /// Non-owning pointer to ESP32BLETracker::state_version_. When this client's + /// state changes, we increment the tracker's counter to signal that loop() + /// should perform full processing. Null if client not registered with tracker. + uint8_t *tracker_state_version_{nullptr}; }; class ESP32BLETracker : public Component, @@ -380,6 +416,16 @@ class ESP32BLETracker : public Component, // Group 4: 1-byte types (enums, uint8_t, bool) uint8_t app_id_{0}; uint8_t scan_start_fail_count_{0}; + /// Version counter for loop() fast-path optimization. Incremented when: + /// - Scanner state changes (via set_scanner_state_()) + /// - Any registered client's state changes (clients hold pointer to this counter) + /// Owned by this class; clients receive non-owning pointer via register_client(). + /// When loop() sees state_version_ == last_processed_version_, it skips expensive + /// client state counting and takes the fast path (just timeout check + return). + uint8_t state_version_{0}; + /// Last state_version_ value when loop() did full processing. Compared against + /// state_version_ to detect if any state changed since last iteration. + uint8_t last_processed_version_{0}; ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; @@ -396,6 +442,8 @@ class ESP32BLETracker : public Component, EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot }; uint32_t scan_start_time_{0}; + /// Precomputed timeout value: scan_duration_ * 2000 + uint32_t scan_timeout_ms_{0}; ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; }; diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 90698b2661..2f39b47f3c 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_BASELINE, CONF_CO2, CONF_ID, + CONF_WARMUP_TIME, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, STATE_CLASS_MEASUREMENT, @@ -14,8 +15,6 @@ from esphome.const import ( DEPENDENCIES = ["uart"] -CONF_WARMUP_TIME = "warmup_time" - hc8_ns = cg.esphome_ns.namespace("hc8") HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index ec6eac670f..0d760938d0 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -107,7 +107,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_MAX_TEMPERATURE): cv.temperature, } ), - cv.only_with_arduino, + cv.Any(cv.only_with_arduino, cv.only_on_esp32), ) @@ -126,6 +126,6 @@ async def to_code(config): cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_library("tonia/HeatpumpIR", "1.0.37") + cg.add_library("tonia/HeatpumpIR", "1.0.40") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 67447a3123..1f1362f8d8 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -1,6 +1,6 @@ #include "heatpumpir.h" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP32) #include #include "ir_sender_esphome.h" diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index ed43ffdc83..6270dd1e5a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP32) #include "esphome/components/climate_ir/climate_ir.h" diff --git a/esphome/components/heatpumpir/ir_sender_esphome.cpp b/esphome/components/heatpumpir/ir_sender_esphome.cpp index 173d595119..f010c72dac 100644 --- a/esphome/components/heatpumpir/ir_sender_esphome.cpp +++ b/esphome/components/heatpumpir/ir_sender_esphome.cpp @@ -1,6 +1,6 @@ #include "ir_sender_esphome.h" -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP32) namespace esphome { namespace heatpumpir { diff --git a/esphome/components/heatpumpir/ir_sender_esphome.h b/esphome/components/heatpumpir/ir_sender_esphome.h index 944d0e859c..c4209145ba 100644 --- a/esphome/components/heatpumpir/ir_sender_esphome.h +++ b/esphome/components/heatpumpir/ir_sender_esphome.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) || defined(USE_ESP32) #include "esphome/components/remote_base/remote_base.h" #include // arduino-heatpump library diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index b133aa69b2..9f74fb1023 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -157,6 +157,7 @@ async def to_code(config): if CORE.is_esp32: cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) + cg.add(var.set_verify_ssl(config[CONF_VERIFY_SSL])) if config.get(CONF_VERIFY_SSL): esp32.add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 1de947ba5b..eedd321d80 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -89,7 +89,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c config.max_redirection_count = this->redirect_limit_; config.auth_type = HTTP_AUTH_TYPE_BASIC; #if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE - if (secure) { + if (secure && this->verify_ssl_) { config.crt_bundle_attach = esp_crt_bundle_attach; } #endif diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 4dc4736423..0fae67f5bc 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -34,6 +34,7 @@ class HttpRequestIDF : public HttpRequestComponent { void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } void set_buffer_size_tx(uint16_t buffer_size_tx) { this->buffer_size_tx_ = buffer_size_tx; } + void set_verify_ssl(bool verify_ssl) { this->verify_ssl_ = verify_ssl; } protected: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, @@ -42,6 +43,7 @@ class HttpRequestIDF : public HttpRequestComponent { // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{}; + bool verify_ssl_{true}; /// @brief Monitors the http client events to gather response headers static esp_err_t http_event_handler(esp_http_client_event_t *evt); diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 4cd737c60d..28fdcd41ef 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] json_ns = cg.esphome_ns.namespace("json") @@ -12,6 +12,11 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.BUS) async def to_code(config): - cg.add_library("bblanchon/ArduinoJson", "7.4.2") + if CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + add_idf_component(name="bblanchon/arduinojson", ref="7.4.2") + else: + cg.add_library("bblanchon/ArduinoJson", "7.4.2") cg.add_define("USE_JSON") cg.add_global(json_ns.using) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8318722b80..503ec7e167 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -382,4 +382,11 @@ async def component_to_code(config): "custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS ) + # Disable LWIP statistics to save RAM - not needed in production + # Must explicitly disable all sub-stats to avoid redefinition warnings + cg.add_platformio_option( + "custom_options.lwip", + ["LWIP_STATS=0", "MEM_STATS=0", "MEMP_STATS=0"], + ) + await cg.register_component(var, config) diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 68bc279767..978dcce3fa 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -166,8 +166,8 @@ class LibreTinyPreferences : public ESPPreferences { return true; } - // Allocate buffer on heap to avoid stack allocation for large data - auto stored_data = std::make_unique(kv.value_len); + // Most preferences are small, use stack buffer with heap fallback for large ones + SmallBufferWithHeapFallback<256> stored_data(kv.value_len); fdb_blob_make(&this->blob, stored_data.get(), kv.value_len); size_t actual_len = fdb_kv_get_blob(db, key_str, &this->blob); if (actual_len != kv.value_len) { diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index aca6ec10f3..9fa1ba3600 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -28,16 +28,14 @@ const LogString *lock_state_to_string(LockState state) { Lock::Lock() : state(LOCK_STATE_NONE) {} LockCall Lock::make_call() { return LockCall(this); } -void Lock::lock() { +void Lock::set_state_(LockState state) { auto call = this->make_call(); - call.set_state(LOCK_STATE_LOCKED); - this->control(call); -} -void Lock::unlock() { - auto call = this->make_call(); - call.set_state(LOCK_STATE_UNLOCKED); + call.set_state(state); this->control(call); } + +void Lock::lock() { this->set_state_(LOCK_STATE_LOCKED); } +void Lock::unlock() { this->set_state_(LOCK_STATE_UNLOCKED); } void Lock::open() { if (traits.get_supports_open()) { ESP_LOGD(TAG, "'%s' Opening.", this->get_name().c_str()); diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index f77b11b145..b518c8b846 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -156,6 +156,9 @@ class Lock : public EntityBase { protected: friend LockCall; + /// Helper for lock/unlock convenience methods + void set_state_(LockState state); + /** Perform the open latch action with hardware. This method is optional to implement * when creating a new lock. * diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 34430dbafa..3a726d4046 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -1,8 +1,5 @@ #include "logger.h" #include -#ifdef USE_ESPHOME_TASK_LOG_BUFFER -#include // For unique_ptr -#endif #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -199,7 +196,8 @@ inline uint8_t Logger::level_for(const char *tag) { Logger::Logger(uint32_t baud_rate, size_t tx_buffer_size) : baud_rate_(baud_rate), tx_buffer_size_(tx_buffer_size) { // add 1 to buffer size for null terminator - this->tx_buffer_ = new char[this->tx_buffer_size_ + 1]; // NOLINT + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed + this->tx_buffer_ = new char[this->tx_buffer_size_ + 1]; #if defined(USE_ESP32) || defined(USE_LIBRETINY) this->main_task_ = xTaskGetCurrentTaskHandle(); #elif defined(USE_ZEPHYR) @@ -212,11 +210,14 @@ Logger::Logger(uint32_t baud_rate, size_t tx_buffer_size) : baud_rate_(baud_rate void Logger::init_log_buffer(size_t total_buffer_size) { #ifdef USE_HOST // Host uses slot count instead of byte size - this->log_buffer_ = esphome::make_unique(total_buffer_size); + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed + this->log_buffer_ = new logger::TaskLogBufferHost(total_buffer_size); #elif defined(USE_ESP32) - this->log_buffer_ = esphome::make_unique(total_buffer_size); + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed + this->log_buffer_ = new logger::TaskLogBuffer(total_buffer_size); #elif defined(USE_LIBRETINY) - this->log_buffer_ = esphome::make_unique(total_buffer_size); + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed + this->log_buffer_ = new logger::TaskLogBufferLibreTiny(total_buffer_size); #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 3e8538c2ae..fe9cab4993 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -412,11 +412,11 @@ class Logger : public Component { #endif #ifdef USE_ESPHOME_TASK_LOG_BUFFER #ifdef USE_HOST - std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer + logger::TaskLogBufferHost *log_buffer_{nullptr}; // Allocated once, never freed #elif defined(USE_ESP32) - std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer + logger::TaskLogBuffer *log_buffer_{nullptr}; // Allocated once, never freed #elif defined(USE_LIBRETINY) - std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer + logger::TaskLogBufferLibreTiny *log_buffer_{nullptr}; // Allocated once, never freed #endif #endif diff --git a/esphome/components/lvgl/widgets/dropdown.py b/esphome/components/lvgl/widgets/dropdown.py index 9ff183f3dd..ca89bb625b 100644 --- a/esphome/components/lvgl/widgets/dropdown.py +++ b/esphome/components/lvgl/widgets/dropdown.py @@ -1,3 +1,4 @@ +from esphome import codegen as cg import esphome.config_validation as cv from esphome.const import CONF_OPTIONS @@ -24,6 +25,34 @@ from .label import CONF_LABEL CONF_DROPDOWN = "dropdown" CONF_DROPDOWN_LIST = "dropdown_list" +# Example valid dropdown symbol (left arrow) for error messages +EXAMPLE_DROPDOWN_SYMBOL = "\U00002190" # ← + + +def dropdown_symbol_validator(value): + """ + Validate that the dropdown symbol is a single Unicode character + with a codepoint of 0x100 (256) or greater. + This is required because LVGL uses codepoints below 0x100 for internal symbols. + """ + value = cv.string(value) + # len(value) counts Unicode code points, not grapheme clusters or bytes + if len(value) != 1: + raise cv.Invalid( + f"Dropdown symbol must be a single character, got '{value}' with length {len(value)}" + ) + codepoint = ord(value) + if codepoint < 0x100: + # Format the example symbol as a Unicode escape for the error message + example_escape = f"\\U{ord(EXAMPLE_DROPDOWN_SYMBOL):08X}" + raise cv.Invalid( + f"Dropdown symbol must have a Unicode codepoint of 0x100 (256) or greater. " + f"'{value}' has codepoint {codepoint} (0x{codepoint:X}). " + f"Use a character like '{example_escape}' ({EXAMPLE_DROPDOWN_SYMBOL}) or other Unicode symbols with codepoint >= 0x100." + ) + return value + + lv_dropdown_t = LvSelect("LvDropdownType", parents=(LvCompound,)) lv_dropdown_list_t = LvType("lv_dropdown_list_t") @@ -33,7 +62,7 @@ dropdown_list_spec = WidgetType( DROPDOWN_BASE_SCHEMA = cv.Schema( { - cv.Optional(CONF_SYMBOL): lv_text, + cv.Optional(CONF_SYMBOL): dropdown_symbol_validator, cv.Exclusive(CONF_SELECTED_INDEX, CONF_SELECTED_TEXT): lv_int, cv.Exclusive(CONF_SELECTED_TEXT, CONF_SELECTED_TEXT): lv_text, cv.Optional(CONF_DROPDOWN_LIST): part_schema(dropdown_list_spec.parts), @@ -70,7 +99,7 @@ class DropdownType(WidgetType): if options := config.get(CONF_OPTIONS): lv_add(w.var.set_options(options)) if symbol := config.get(CONF_SYMBOL): - lv.dropdown_set_symbol(w.var.obj, await lv_text.process(symbol)) + lv.dropdown_set_symbol(w.var.obj, cg.safe_exp(symbol)) if (selected := config.get(CONF_SELECTED_INDEX)) is not None: value = await lv_int.process(selected) lv_add(w.var.set_selected_index(value, literal("LV_ANIM_OFF"))) diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index 99c1f38829..2b8f0d39b2 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -2,6 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include @@ -43,8 +44,17 @@ template class Mapping { esph_log_e(TAG, "Key '%p' not found in mapping", key); } else if constexpr (std::is_same_v) { esph_log_e(TAG, "Key '%s' not found in mapping", key.c_str()); + } else if constexpr (std::is_integral_v) { + char buf[24]; // enough for 64-bit integer + if constexpr (std::is_unsigned_v) { + buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(key)); + } else { + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, static_cast(key)); + } + esph_log_e(TAG, "Key '%s' not found in mapping", buf); } else { - esph_log_e(TAG, "Key '%s' not found in mapping", to_string(key).c_str()); + // All supported key types are handled above - this should never be reached + static_assert(sizeof(K) == 0, "Unsupported key type for Mapping error logging"); } return {}; } diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index e6b43e59cb..3123f3b604 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -24,13 +24,14 @@ static void register_esp32(MDNSComponent *comp, StaticVector(service.txt_records.size()); + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); for (size_t i = 0; i < service.txt_records.size(); i++) { const auto &record = service.txt_records[i]; // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records[i].key = MDNS_STR_ARG(record.key); - txt_records[i].value = MDNS_STR_ARG(record.value); + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); } uint16_t port = const_cast &>(service.port).value(); err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 1f698be404..2841afde7a 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_CO2, CONF_ID, CONF_TEMPERATURE, + CONF_WARMUP_TIME, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_TEMPERATURE, ICON_MOLECULE_CO2, @@ -18,7 +19,6 @@ from esphome.const import ( DEPENDENCIES = ["uart"] CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" -CONF_WARMUP_TIME = "warmup_time" CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index b923583fa1..e7364f3406 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -413,6 +413,12 @@ void MQTTClientComponent::loop() { this->last_connected_ = now; this->resubscribe_subscriptions_(); + + // Process pending resends for all MQTT components centrally + // This is more efficient than each component polling in its own loop + for (MQTTComponent *component : this->children_) { + component->process_resend(); + } } break; } diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 36b7c6a6c9..cb8b92cad0 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -329,16 +329,12 @@ void MQTTComponent::call_setup() { } } -void MQTTComponent::call_loop() { - if (this->is_internal()) +void MQTTComponent::process_resend() { + // Called by MQTTClientComponent when connected to process pending resends + // Note: is_internal() check not needed - internal components are never registered + if (!this->resend_state_) return; - this->loop(); - - if (!this->resend_state_ || !this->is_connected_()) { - return; - } - this->resend_state_ = false; if (this->is_discovery_enabled()) { if (!this->send_discovery_()) { diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 676e3ad35d..dea91e3d5a 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -81,8 +81,6 @@ class MQTTComponent : public Component { /// Override setup_ so that we can call send_discovery() when needed. void call_setup() override; - void call_loop() override; - void call_dump_config() override; /// Send discovery info the Home Assistant, override this. @@ -133,6 +131,9 @@ class MQTTComponent : public Component { /// Internal method for the MQTT client base to schedule a resend of the state on reconnect. void schedule_resend_state(); + /// Process pending resend if needed (called by MQTTClientComponent) + void process_resend(); + /** Send a MQTT message. * * @param topic The topic. diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2ac45a55ac..ebbe0fbccc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ABOVE, CONF_ACCURACY_DECIMALS, CONF_ALPHA, + CONF_BASELINE, CONF_BELOW, CONF_CALIBRATION, CONF_DEVICE_CLASS, @@ -38,7 +39,6 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, - CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, @@ -107,7 +107,7 @@ from esphome.const import ( ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -574,38 +574,56 @@ async def lambda_filter_to_code(config, filter_id): return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter) -DELTA_SCHEMA = cv.Schema( - { - cv.Required(CONF_VALUE): cv.positive_float, - cv.Optional(CONF_TYPE, default="absolute"): cv.one_of( - "absolute", "percentage", lower=True - ), - } +def validate_delta_value(value): + if isinstance(value, str) and value.endswith("%"): + # Check it's a well-formed percentage, but return the string as-is + try: + cv.positive_float(value[:-1]) + return value + except cv.Invalid as exc: + raise cv.Invalid("Malformed delta % value") from exc + return cv.positive_float(value) + + +# This ideally would be done with `cv.maybe_simple_value` but it doesn't seem to respect the default for min_value. +DELTA_SCHEMA = cv.Any( + cv.All( + { + # Ideally this would be 'default=float("inf")' but it doesn't translate well to C++ + cv.Optional(CONF_MAX_VALUE): validate_delta_value, + cv.Optional(CONF_MIN_VALUE, default="0.0"): validate_delta_value, + cv.Optional(CONF_BASELINE): cv.templatable(cv.float_), + }, + cv.has_at_least_one_key(CONF_MAX_VALUE, CONF_MIN_VALUE), + ), + validate_delta_value, ) -def validate_delta(config): - try: - value = cv.positive_float(config) - return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "absolute"}) - except cv.Invalid: - pass - try: - value = cv.percentage(config) - return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "percentage"}) - except cv.Invalid: - pass - raise cv.Invalid("Delta filter requires a positive number or percentage value.") +def _get_delta(value): + if isinstance(value, str): + assert value.endswith("%") + return 0.0, float(value[:-1]) + return value, 0.0 -@FILTER_REGISTRY.register("delta", DeltaFilter, cv.Any(DELTA_SCHEMA, validate_delta)) +@FILTER_REGISTRY.register("delta", DeltaFilter, DELTA_SCHEMA) async def delta_filter_to_code(config, filter_id): - percentage = config[CONF_TYPE] == "percentage" - return cg.new_Pvariable( - filter_id, - config[CONF_VALUE], - percentage, - ) + # The config could be just the min_value, or it could be a dict. + max = MockObj("std::numeric_limits::infinity()"), 0 + if isinstance(config, dict): + min = _get_delta(config[CONF_MIN_VALUE]) + if CONF_MAX_VALUE in config: + max = _get_delta(config[CONF_MAX_VALUE]) + else: + min = _get_delta(config) + var = cg.new_Pvariable(filter_id, *min, *max) + if isinstance(config, dict) and (baseline_lambda := config.get(CONF_BASELINE)): + baseline = await cg.process_lambda( + baseline_lambda, [(float, "x")], return_type=float + ) + cg.add(var.set_baseline(baseline)) + return var @FILTER_REGISTRY.register("or", OrFilter, validate_filters) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 8450ec4c4e..3adf28748d 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -291,22 +291,27 @@ optional ThrottleWithPriorityFilter::new_value(float value) { } // DeltaFilter -DeltaFilter::DeltaFilter(float delta, bool percentage_mode) - : delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {} +DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) + : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} + +void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } + optional DeltaFilter::new_value(float value) { - if (std::isnan(value)) { - if (std::isnan(this->last_value_)) { - return {}; - } else { - return this->last_value_ = value; - } + // Always yield the first value. + if (std::isnan(this->last_value_)) { + this->last_value_ = value; + return value; } - float diff = fabsf(value - this->last_value_); - if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) { - if (this->percentage_mode_) { - this->current_delta_ = fabsf(value * this->delta_); - } - return this->last_value_ = value; + // calculate min and max using the linear equation + float ref = this->baseline_(this->last_value_); + float min = fabsf(this->min_a0_ + ref * this->min_a1_); + float max = fabsf(this->max_a0_ + ref * this->max_a1_); + float delta = fabsf(value - ref); + // if there is no reference, e.g. for the first value, just accept this one, + // otherwise accept only if within range. + if (delta > min && delta <= max) { + this->last_value_ = value; + return value; } return {}; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 15c7656a7b..573b916a5d 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -452,15 +452,21 @@ class HeartbeatFilter : public Filter, public Component { class DeltaFilter : public Filter { public: - explicit DeltaFilter(float delta, bool percentage_mode); + explicit DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1); + + void set_baseline(float (*fn)(float)); optional new_value(float value) override; protected: - float delta_; - float current_delta_; + // These values represent linear equations for the min and max values but in practice only one of a0 and a1 will be + // non-zero Each limit is calculated as fabs(a0 + value * a1) + + float min_a0_, min_a1_, max_a0_, max_a1_; + // default baseline is the previous value + float (*baseline_)(float) = [](float last_value) { return last_value; }; + float last_value_{NAN}; - bool percentage_mode_; }; class OrFilter : public Filter { diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index bb94ecb675..fd8725b363 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -46,15 +46,15 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t #endif // Format sockaddr into caller-provided buffer, returns length written (excluding null) -static size_t format_sockaddr_to(const struct sockaddr_storage &storage, std::span buf) { - if (storage.ss_family == AF_INET) { - const auto *addr = reinterpret_cast(&storage); +size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf) { + if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) { + const auto *addr = reinterpret_cast(addr_ptr); if (esphome_inet_ntop4(&addr->sin_addr, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); } #if USE_NETWORK_IPV6 - else if (storage.ss_family == AF_INET6) { - const auto *addr = reinterpret_cast(&storage); + else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) { + const auto *addr = reinterpret_cast(addr_ptr); #ifndef USE_SOCKET_IMPL_LWIP_TCP // Format IPv4-mapped IPv6 addresses as regular IPv4 (not supported on ESP8266 raw TCP) if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && @@ -78,7 +78,7 @@ size_t Socket::getpeername_to(std::span buf) { buf[0] = '\0'; return 0; } - return format_sockaddr_to(storage, buf); + return format_sockaddr_to(reinterpret_cast(&storage), len, buf); } size_t Socket::getsockname_to(std::span buf) { @@ -88,7 +88,7 @@ size_t Socket::getsockname_to(std::span buf) { buf[0] = '\0'; return 0; } - return format_sockaddr_to(storage, buf); + return format_sockaddr_to(reinterpret_cast(&storage), len, buf); } std::unique_ptr socket_ip(int type, int protocol) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index d74804fdb0..e8b0948acd 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -102,6 +102,9 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +/// Format sockaddr into caller-provided buffer, returns length written (excluding null) +size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf); + #if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) /// Delay that can be woken early by socket activity. /// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 55d2040a3a..abeda5fc46 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -487,7 +487,7 @@ void AsyncEventSource::deferrable_send_state(void *source, const char *event_typ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server, esphome::web_server::WebServer *ws) - : server_(server), web_server_(ws), entities_iterator_(new esphome::web_server::ListEntitiesIterator(ws, server)) { + : server_(server), web_server_(ws), entities_iterator_(ws, server) { httpd_req_t *req = *request; httpd_resp_set_status(req, HTTPD_200); @@ -531,12 +531,12 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * } #endif - this->entities_iterator_->begin(ws->include_internal_); + this->entities_iterator_.begin(ws->include_internal_); // just dump them all up-front and take advantage of the deferred queue // on second thought that takes too long, but leaving the commented code here for debug purposes - // while(!this->entities_iterator_->completed()) { - // this->entities_iterator_->advance(); + // while(!this->entities_iterator_.completed()) { + // this->entities_iterator_.advance(); //} } @@ -634,8 +634,8 @@ void AsyncEventSourceResponse::process_buffer_() { void AsyncEventSourceResponse::loop() { process_buffer_(); process_deferred_queue_(); - if (!this->entities_iterator_->completed()) - this->entities_iterator_->advance(); + if (!this->entities_iterator_.completed()) + this->entities_iterator_.advance(); } bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, @@ -781,7 +781,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e message_generator_t *message_generator) { // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing // up in the web GUI and reduces event load during initial connect - if (!entities_iterator_->completed() && 0 != strcmp(event_type, "state_detail_all")) + if (!this->entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) return; if (source == nullptr) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 2a334a11e3..a6c984792a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -13,11 +13,14 @@ #include #include +#ifdef USE_WEBSERVER +#include "esphome/components/web_server/list_entities.h" +#endif + namespace esphome { #ifdef USE_WEBSERVER namespace web_server { class WebServer; -class ListEntitiesIterator; }; // namespace web_server #endif namespace web_server_idf { @@ -284,7 +287,7 @@ class AsyncEventSourceResponse { std::atomic fd_{}; std::vector deferred_queue_; esphome::web_server::WebServer *web_server_; - std::unique_ptr entities_iterator_; + esphome::web_server::ListEntitiesIterator entities_iterator_; std::string event_buffer_{""}; size_t event_bytes_sent_; uint16_t consecutive_send_failures_{0}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 6fb5dd5769..de0600cf5b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -920,7 +920,16 @@ bssid_t WiFiComponent::wifi_bssid() { } return bssid; } -std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } +std::string WiFiComponent::wifi_ssid() { + struct station_config conf {}; + if (!wifi_station_get_config(&conf)) { + return ""; + } + // conf.ssid is uint8[32], not null-terminated if full + auto *ssid_s = reinterpret_cast(conf.ssid); + size_t len = strnlen(ssid_s, sizeof(conf.ssid)); + return {ssid_s, len}; +} const char *WiFiComponent::wifi_ssid_to(std::span buffer) { struct station_config conf {}; if (!wifi_station_get_config(&conf)) { @@ -934,16 +943,24 @@ const char *WiFiComponent::wifi_ssid_to(std::span buffer return buffer.data(); } int8_t WiFiComponent::wifi_rssi() { - if (WiFi.status() != WL_CONNECTED) + if (wifi_station_get_connect_status() != STATION_GOT_IP) return WIFI_RSSI_DISCONNECTED; - int8_t rssi = WiFi.RSSI(); + sint8 rssi = wifi_station_get_rssi(); // Values >= 31 are error codes per NONOS SDK API, not valid RSSI readings return rssi >= 31 ? WIFI_RSSI_DISCONNECTED : rssi; } -int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } -network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; } -network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; } -network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {(const ip_addr_t *) WiFi.dnsIP(num)}; } +int32_t WiFiComponent::get_wifi_channel() { return wifi_get_channel(); } +network::IPAddress WiFiComponent::wifi_subnet_mask_() { + struct ip_info ip {}; + wifi_get_ip_info(STATION_IF, &ip); + return network::IPAddress(&ip.netmask); +} +network::IPAddress WiFiComponent::wifi_gateway_ip_() { + struct ip_info ip {}; + wifi_get_ip_info(STATION_IF, &ip); + return network::IPAddress(&ip.gw); +} +network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_getserver(num)); } void WiFiComponent::wifi_loop_() {} } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 848ec3e11c..99474ac2f8 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -827,16 +827,17 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } uint16_t number = it.number; - auto records = std::make_unique(number); - err = esp_wifi_scan_get_ap_records(&number, records.get()); - if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_scan_get_ap_records failed: %s", esp_err_to_name(err)); - return; - } - scan_result_.init(number); - for (int i = 0; i < number; i++) { - auto &record = records[i]; + + // Process one record at a time to avoid large buffer allocation + wifi_ap_record_t record; + for (uint16_t i = 0; i < number; i++) { + err = esp_wifi_scan_get_ap_record(&record); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_scan_get_ap_record failed: %s", esp_err_to_name(err)); + esp_wifi_clear_ap_list(); // Free remaining records not yet retrieved + break; + } bssid_t bssid; std::copy(record.bssid, record.bssid + 6, bssid.begin()); std::string ssid(reinterpret_cast(record.ssid)); diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index 9ecb5b7490..5f72d0aa74 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -79,13 +79,17 @@ async def setup_conf(config, key): async def to_code(config): # Request specific WiFi listeners based on which sensors are configured + # Each sensor needs its own listener slot - call request for EACH sensor + # SSID and BSSID use WiFiConnectStateListener - if CONF_SSID in config or CONF_BSSID in config: - wifi.request_wifi_connect_state_listener() + for key in (CONF_SSID, CONF_BSSID): + if key in config: + wifi.request_wifi_connect_state_listener() # IP address and DNS use WiFiIPStateListener - if CONF_IP_ADDRESS in config or CONF_DNS_ADDRESS in config: - wifi.request_wifi_ip_state_listener() + for key in (CONF_IP_ADDRESS, CONF_DNS_ADDRESS): + if key in config: + wifi.request_wifi_ip_state_listener() # Scan results use WiFiScanResultsListener if CONF_SCAN_RESULTS in config: diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 8f66c46015..773e52d6e1 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -6,7 +6,7 @@ namespace x9c { static const char *const TAG = "x9c.output"; -void X9cOutput::trim_value(int change_amount) { +void X9cOutput::trim_value(int32_t change_amount) { if (change_amount == 0) { return; } @@ -47,17 +47,17 @@ void X9cOutput::setup() { if (this->initial_value_ <= 0.50) { this->trim_value(-101); // Set min value (beyond 0) - this->trim_value(static_cast(roundf(this->initial_value_ * 100))); + this->trim_value(lroundf(this->initial_value_ * 100)); } else { this->trim_value(101); // Set max value (beyond 100) - this->trim_value(static_cast(roundf(this->initial_value_ * 100) - 100)); + this->trim_value(lroundf(this->initial_value_ * 100) - 100); } this->pot_value_ = this->initial_value_; this->write_state(this->initial_value_); } void X9cOutput::write_state(float state) { - this->trim_value(static_cast(roundf((state - this->pot_value_) * 100))); + this->trim_value(lroundf((state - this->pot_value_) * 100)); this->pot_value_ = state; } diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index e7cc29a6cc..66c3df14e1 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -18,7 +18,7 @@ class X9cOutput : public output::FloatOutput, public Component { void setup() override; void dump_config() override; - void trim_value(int change_amount); + void trim_value(int32_t change_amount); protected: void write_state(float state) override; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index c9540f4f01..94f25f02ac 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -2,7 +2,7 @@ #ifdef USE_ZEPHYR #include "esphome/core/hal.h" -struct device; +#include namespace esphome { namespace zephyr { diff --git a/esphome/const.py b/esphome/const.py index c88d5811b4..4243b2e25d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1086,6 +1086,7 @@ CONF_WAKEUP_PIN = "wakeup_pin" CONF_WAND_ID = "wand_id" CONF_WARM_WHITE = "warm_white" CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature" +CONF_WARMUP_TIME = "warmup_time" CONF_WATCHDOG_THRESHOLD = "watchdog_threshold" CONF_WATCHDOG_TIMEOUT = "watchdog_timeout" CONF_WATER_HEATER = "water_heater" @@ -1379,6 +1380,7 @@ KEY_FRAMEWORK_VERSION = "framework_version" KEY_NAME = "name" KEY_VARIANT = "variant" KEY_PAST_SAFE_MODE = "past_safe_mode" +KEY_NATIVE_IDF = "native_idf" # Entity categories ENTITY_CATEGORY_NONE = "" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 70593d8153..9a7dd49609 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, + KEY_NATIVE_IDF, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, PLATFORM_BK72XX, @@ -763,6 +764,9 @@ class EsphomeCore: @property def firmware_bin(self) -> Path: + # Check if using native ESP-IDF build (--native-idf) + if self.data.get(KEY_NATIVE_IDF, False): + return self.relative_build_path("build", f"{self.name}.bin") if self.is_libretiny: return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.bin") diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2f61f7d195..98e8c02d07 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -47,18 +47,21 @@ struct ComponentPriorityOverride { }; // Error messages for failed components +// Using raw pointer instead of unique_ptr to avoid global constructor/destructor overhead +// This is never freed as error messages persist for the lifetime of the device // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -std::unique_ptr> component_error_messages; +std::vector *component_error_messages = nullptr; // Setup priority overrides - freed after setup completes +// Using raw pointer instead of unique_ptr to avoid global constructor/destructor overhead // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -std::unique_ptr> setup_priority_overrides; +std::vector *setup_priority_overrides = nullptr; // Helper to store error messages - reduces duplication between deprecated and new API // Remove before 2026.6.0 when deprecated const char* API is removed void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { // Lazy allocate the error messages vector if needed if (!component_error_messages) { - component_error_messages = std::make_unique>(); + component_error_messages = new std::vector(); } // Check if this component already has an error message for (auto &entry : *component_error_messages) { @@ -467,7 +470,7 @@ float Component::get_actual_setup_priority() const { void Component::set_setup_priority(float priority) { // Lazy allocate the vector if needed if (!setup_priority_overrides) { - setup_priority_overrides = std::make_unique>(); + setup_priority_overrides = new std::vector(); // Reserve some space to avoid reallocations (most configs have < 10 overrides) setup_priority_overrides->reserve(10); } @@ -553,7 +556,8 @@ WarnIfComponentBlockingGuard::~WarnIfComponentBlockingGuard() {} void clear_setup_priority_overrides() { // Free the setup priority map completely - setup_priority_overrides.reset(); + delete setup_priority_overrides; + setup_priority_overrides = nullptr; } } // namespace esphome diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bd3a4def05..1aa29fa3f7 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -17,6 +17,8 @@ #include #include +#include + #include "esphome/core/optional.h" #ifdef USE_ESP8266 @@ -348,6 +350,8 @@ template class FixedVector { size_t size() const { return size_; } bool empty() const { return size_ == 0; } + size_t capacity() const { return capacity_; } + bool full() const { return size_ == capacity_; } /// Access element without bounds checking (matches std::vector behavior) /// Caller must ensure index is valid (i < size()) @@ -369,13 +373,15 @@ template class FixedVector { /// @brief Helper class for efficient buffer allocation - uses stack for small sizes, heap for large /// This is useful when most operations need a small buffer but occasionally need larger ones. /// The stack buffer avoids heap allocation in the common case, while heap fallback handles edge cases. -template class SmallBufferWithHeapFallback { +/// @tparam STACK_SIZE Number of elements in the stack buffer +/// @tparam T Element type (default: uint8_t) +template class SmallBufferWithHeapFallback { public: explicit SmallBufferWithHeapFallback(size_t size) { if (size <= STACK_SIZE) { this->buffer_ = this->stack_buffer_; } else { - this->heap_buffer_ = new uint8_t[size]; + this->heap_buffer_ = new T[size]; this->buffer_ = this->heap_buffer_; } } @@ -387,12 +393,12 @@ template class SmallBufferWithHeapFallback { SmallBufferWithHeapFallback(SmallBufferWithHeapFallback &&) = delete; SmallBufferWithHeapFallback &operator=(SmallBufferWithHeapFallback &&) = delete; - uint8_t *get() { return this->buffer_; } + T *get() { return this->buffer_; } private: - uint8_t stack_buffer_[STACK_SIZE]; - uint8_t *heap_buffer_{nullptr}; - uint8_t *buffer_; + T stack_buffer_[STACK_SIZE]; + T *heap_buffer_{nullptr}; + T *buffer_; }; ///@} @@ -568,6 +574,10 @@ template constexpr T convert_little_endian(T val) { bool str_equals_case_insensitive(const std::string &a, const std::string &b); /// Compare StringRefs for equality in case-insensitive manner. bool str_equals_case_insensitive(StringRef a, StringRef b); +/// Compare C strings for equality in case-insensitive manner (no heap allocation). +inline bool str_equals_case_insensitive(const char *a, const char *b) { return strcasecmp(a, b) == 0; } +inline bool str_equals_case_insensitive(const std::string &a, const char *b) { return strcasecmp(a.c_str(), b) == 0; } +inline bool str_equals_case_insensitive(const char *a, const std::string &b) { return strcasecmp(a, b.c_str()) == 0; } /// Check whether a string starts with a value. bool str_startswith(const std::string &str, const std::string &start); @@ -1343,16 +1353,30 @@ template class LazyCallbackManager; * * Memory overhead comparison (32-bit systems): * - CallbackManager: 12 bytes (empty std::vector) - * - LazyCallbackManager: 4 bytes (nullptr unique_ptr) + * - LazyCallbackManager: 4 bytes (nullptr pointer) + * + * Uses plain pointer instead of unique_ptr to avoid template instantiation overhead. + * The class is explicitly non-copyable/non-movable for Rule of Five compliance. * * @tparam Ts The arguments for the callbacks, wrapped in void(). */ template class LazyCallbackManager { public: + LazyCallbackManager() = default; + /// Destructor - clean up allocated CallbackManager if any. + /// In practice this never runs (entities live for device lifetime) but included for correctness. + ~LazyCallbackManager() { delete this->callbacks_; } + + // Non-copyable and non-movable (entities are never copied or moved) + LazyCallbackManager(const LazyCallbackManager &) = delete; + LazyCallbackManager &operator=(const LazyCallbackManager &) = delete; + LazyCallbackManager(LazyCallbackManager &&) = delete; + LazyCallbackManager &operator=(LazyCallbackManager &&) = delete; + /// Add a callback to the list. Allocates the underlying CallbackManager on first use. void add(std::function &&callback) { if (!this->callbacks_) { - this->callbacks_ = make_unique>(); + this->callbacks_ = new CallbackManager(); } this->callbacks_->add(std::move(callback)); } @@ -1374,7 +1398,7 @@ template class LazyCallbackManager { void operator()(Ts... args) { this->call(args...); } protected: - std::unique_ptr> callbacks_; + CallbackManager *callbacks_{nullptr}; }; /// Helper class to deduplicate items in a series of values. diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 4047033f84..554431c631 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -67,7 +67,7 @@ std::string ESPTime::strftime(const char *format) { std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); } -bool ESPTime::strptime(const std::string &time_to_parse, ESPTime &esp_time) { +bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) { uint16_t year; uint8_t month; uint8_t day; @@ -75,40 +75,41 @@ bool ESPTime::strptime(const std::string &time_to_parse, ESPTime &esp_time) { uint8_t minute; uint8_t second; int num; + const int ilen = static_cast(len); - if (sscanf(time_to_parse.c_str(), "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, // NOLINT - &second, &num) == 6 && // NOLINT - num == static_cast(time_to_parse.size())) { + if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT + &hour, // NOLINT + &minute, // NOLINT + &second, &num) == 6 && // NOLINT + num == ilen) { esp_time.year = year; esp_time.month = month; esp_time.day_of_month = day; esp_time.hour = hour; esp_time.minute = minute; esp_time.second = second; - } else if (sscanf(time_to_parse.c_str(), "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT - &hour, // NOLINT - &minute, &num) == 5 && // NOLINT - num == static_cast(time_to_parse.size())) { + } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT + &hour, // NOLINT + &minute, &num) == 5 && // NOLINT + num == ilen) { esp_time.year = year; esp_time.month = month; esp_time.day_of_month = day; esp_time.hour = hour; esp_time.minute = minute; esp_time.second = 0; - } else if (sscanf(time_to_parse.c_str(), "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT - num == static_cast(time_to_parse.size())) { + } else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT + num == ilen) { esp_time.hour = hour; esp_time.minute = minute; esp_time.second = second; - } else if (sscanf(time_to_parse.c_str(), "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT - num == static_cast(time_to_parse.size())) { + } else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT + num == ilen) { esp_time.hour = hour; esp_time.minute = minute; esp_time.second = 0; - } else if (sscanf(time_to_parse.c_str(), "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT - num == static_cast(time_to_parse.size())) { + } else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT + num == ilen) { esp_time.year = year; esp_time.month = month; esp_time.day_of_month = day; diff --git a/esphome/core/time.h b/esphome/core/time.h index f6f1d57dbb..87ebb5c221 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -80,11 +81,20 @@ struct ESPTime { } /** Convert a string to ESPTime struct as specified by the format argument. - * @param time_to_parse null-terminated c string formatet like this: 2020-08-25 05:30:00. + * @param time_to_parse c string formatted like this: 2020-08-25 05:30:00. + * @param len length of the string (not including null terminator if present) * @param esp_time an instance of a ESPTime struct - * @return the success sate of the parsing + * @return the success state of the parsing */ - static bool strptime(const std::string &time_to_parse, ESPTime &esp_time); + static bool strptime(const char *time_to_parse, size_t len, ESPTime &esp_time); + /// @copydoc strptime(const char *, size_t, ESPTime &) + static bool strptime(const char *time_to_parse, ESPTime &esp_time) { + return strptime(time_to_parse, strlen(time_to_parse), esp_time); + } + /// @copydoc strptime(const char *, size_t, ESPTime &) + static bool strptime(const std::string &time_to_parse, ESPTime &esp_time) { + return strptime(time_to_parse.c_str(), time_to_parse.size(), esp_time); + } /// Convert a C tm struct instance with a C unix epoch timestamp to an ESPTime instance. static ESPTime from_c_tm(struct tm *c_tm, time_t c_time); diff --git a/esphome/espidf_api.py b/esphome/espidf_api.py new file mode 100644 index 0000000000..9e9c57bfbd --- /dev/null +++ b/esphome/espidf_api.py @@ -0,0 +1,229 @@ +"""ESP-IDF direct build API for ESPHome.""" + +import json +import logging +import os +from pathlib import Path +import shutil +import subprocess + +from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE +from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME +from esphome.core import CORE, EsphomeError + +_LOGGER = logging.getLogger(__name__) + + +def _get_idf_path() -> Path | None: + """Get IDF_PATH from environment or common locations.""" + # Check environment variable first + if "IDF_PATH" in os.environ: + path = Path(os.environ["IDF_PATH"]) + if path.is_dir(): + return path + + # Check common installation locations + common_paths = [ + Path.home() / "esp" / "esp-idf", + Path.home() / ".espressif" / "esp-idf", + Path("/opt/esp-idf"), + ] + + for path in common_paths: + if path.is_dir() and (path / "tools" / "idf.py").is_file(): + return path + + return None + + +def _get_idf_env() -> dict[str, str]: + """Get environment variables needed for ESP-IDF build. + + Requires the user to have sourced export.sh before running esphome. + """ + env = os.environ.copy() + + idf_path = _get_idf_path() + if idf_path is None: + raise EsphomeError( + "ESP-IDF not found. Please install ESP-IDF and source export.sh:\n" + " git clone -b v5.3.2 --recursive https://github.com/espressif/esp-idf.git ~/esp-idf\n" + " cd ~/esp-idf && ./install.sh\n" + " source ~/esp-idf/export.sh\n" + "See: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/" + ) + + env["IDF_PATH"] = str(idf_path) + return env + + +def run_idf_py( + *args, cwd: Path | None = None, capture_output: bool = False +) -> int | str: + """Run idf.py with the given arguments.""" + idf_path = _get_idf_path() + if idf_path is None: + raise EsphomeError("ESP-IDF not found") + + env = _get_idf_env() + idf_py = idf_path / "tools" / "idf.py" + + cmd = ["python", str(idf_py)] + list(args) + + if cwd is None: + cwd = CORE.build_path + + _LOGGER.debug("Running: %s", " ".join(cmd)) + _LOGGER.debug(" in directory: %s", cwd) + + if capture_output: + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + _LOGGER.error("idf.py failed:\n%s", result.stderr) + return result.stdout + result = subprocess.run( + cmd, + cwd=cwd, + env=env, + check=False, + ) + return result.returncode + + +def run_reconfigure() -> int: + """Run cmake reconfigure only (no build).""" + return run_idf_py("reconfigure") + + +def run_compile(config, verbose: bool) -> int: + """Compile the ESP-IDF project. + + Uses two-phase configure to auto-discover available components: + 1. If no previous build, configure with minimal REQUIRES to discover components + 2. Regenerate CMakeLists.txt with discovered components + 3. Run full build + """ + from esphome.build_gen.espidf import has_discovered_components, write_project + + # Check if we need to do discovery phase + if not has_discovered_components(): + _LOGGER.info("Discovering available ESP-IDF components...") + write_project(minimal=True) + rc = run_reconfigure() + if rc != 0: + _LOGGER.error("Component discovery failed") + return rc + _LOGGER.info("Regenerating CMakeLists.txt with discovered components...") + write_project(minimal=False) + + # Build + args = ["build"] + + if verbose: + args.append("-v") + + # Add parallel job limit if configured + if CONF_COMPILE_PROCESS_LIMIT in config.get(CONF_ESPHOME, {}): + limit = config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT] + args.extend(["-j", str(limit)]) + + # Set the sdkconfig file + sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + if sdkconfig_path.is_file(): + args.extend(["-D", f"SDKCONFIG={sdkconfig_path}"]) + + return run_idf_py(*args) + + +def get_firmware_path() -> Path: + """Get the path to the compiled firmware binary.""" + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.bin" + + +def get_factory_firmware_path() -> Path: + """Get the path to the factory firmware (with bootloader).""" + build_dir = CORE.relative_build_path("build") + return build_dir / f"{CORE.name}.factory.bin" + + +def create_factory_bin() -> bool: + """Create factory.bin by merging bootloader, partition table, and app.""" + build_dir = CORE.relative_build_path("build") + flasher_args_path = build_dir / "flasher_args.json" + + if not flasher_args_path.is_file(): + _LOGGER.warning("flasher_args.json not found, cannot create factory.bin") + return False + + try: + with open(flasher_args_path, encoding="utf-8") as f: + flash_data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + _LOGGER.error("Failed to read flasher_args.json: %s", e) + return False + + # Get flash size from config + flash_size = CORE.data[KEY_ESP32][KEY_FLASH_SIZE] + + # Build esptool merge command + sections = [] + for addr, fname in sorted( + flash_data.get("flash_files", {}).items(), key=lambda kv: int(kv[0], 16) + ): + file_path = build_dir / fname + if file_path.is_file(): + sections.extend([addr, str(file_path)]) + else: + _LOGGER.warning("Flash file not found: %s", file_path) + + if not sections: + _LOGGER.warning("No flash sections found") + return False + + output_path = get_factory_firmware_path() + chip = flash_data.get("extra_esptool_args", {}).get("chip", "esp32") + + cmd = [ + "python", + "-m", + "esptool", + "--chip", + chip, + "merge_bin", + "--flash_size", + flash_size, + "--output", + str(output_path), + ] + sections + + _LOGGER.info("Creating factory.bin...") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + if result.returncode != 0: + _LOGGER.error("Failed to create factory.bin: %s", result.stderr) + return False + + _LOGGER.info("Created: %s", output_path) + return True + + +def create_ota_bin() -> bool: + """Copy the firmware to .ota.bin for ESPHome OTA compatibility.""" + firmware_path = get_firmware_path() + ota_path = firmware_path.with_suffix(".ota.bin") + + if not firmware_path.is_file(): + _LOGGER.warning("Firmware not found: %s", firmware_path) + return False + + shutil.copy(firmware_path, ota_path) + _LOGGER.info("Created: %s", ota_path) + return True diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5903e68e8e..9be787d0cd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,4 +1,8 @@ dependencies: + bblanchon/arduinojson: + version: "7.4.2" + esphome/esp-audio-libs: + version: 2.0.3 espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: diff --git a/platformio.ini b/platformio.ini index 4180971b54..b109284933 100644 --- a/platformio.ini +++ b/platformio.ini @@ -34,7 +34,6 @@ build_flags = [common] ; Base dependencies for all environments lib_deps_base = - bblanchon/ArduinoJson@7.4.2 ; json wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier @@ -85,7 +84,7 @@ lib_deps = fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.37 ; heatpumpir + tonia/HeatpumpIR@1.0.40 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -111,6 +110,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) Update ; ota (Arduino built-in) ESP32Async/ESPAsyncTCP@2.0.0 ; async_tcp @@ -154,7 +154,6 @@ lib_deps = makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.2 ; wireguard - esphome/esp-audio-libs@2.0.1 ; audio kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word build_flags = @@ -178,7 +177,7 @@ lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.2 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word - esphome/esp-audio-libs@2.0.1 ; audio + tonia/HeatpumpIR@1.0.40 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare @@ -201,6 +200,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base build_flags = ${common:arduino.build_flags} @@ -216,6 +216,7 @@ platform = libretiny@1.9.2 framework = arduino lib_compat_mode = soft lib_deps = + bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.7.8 ; web_server_base droscy/esp_wireguard@0.4.2 ; wireguard build_flags = @@ -239,6 +240,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} + bblanchon/ArduinoJson@7.4.2 ; json ; All the actual environments are defined below. diff --git a/pyproject.toml b/pyproject.toml index d6aa584237..47dd4b7473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.9.0", "wheel>=0.43,<0.46"] +requires = ["setuptools==80.10.1", "wheel>=0.43,<0.46"] build-backend = "setuptools.build_meta" [project] diff --git a/script/ci-custom.py b/script/ci-custom.py index ee2e73872c..01e197057a 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -732,6 +732,26 @@ def lint_no_heap_allocating_helpers(fname, match): ) +@lint_re_check( + # Match sprintf/vsprintf but not snprintf/vsnprintf + # [^\w] ensures we don't match the safe variants + r"[^\w](v?sprintf)\s*\(" + CPP_RE_EOL, + include=cpp_include, +) +def lint_no_sprintf(fname, match): + func = match.group(1) + safe_func = func.replace("sprintf", "snprintf") + return ( + f"{highlight(func + '()')} is not allowed in ESPHome. It has no buffer size limit " + f"and can cause buffer overflows.\n" + f"Please use one of these alternatives:\n" + f" - {highlight(safe_func + '(buf, sizeof(buf), fmt, ...)')} for general formatting\n" + f" - {highlight('buf_append_printf(buf, sizeof(buf), pos, fmt, ...)')} for " + f"offset-based formatting (also stores format strings in flash on ESP8266)\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + @lint_content_find_check( "ESP_LOG", include=["*.h", "*.tcc"], diff --git a/tests/components/debug/test.nrf52-adafruit.yaml b/tests/components/debug/test.nrf52-adafruit.yaml index dade44d145..6a446634af 100644 --- a/tests/components/debug/test.nrf52-adafruit.yaml +++ b/tests/components/debug/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ <<: !include common.yaml + +nrf52: + reg0: + voltage: 2.1V diff --git a/tests/components/heatpumpir/test.esp32-idf.yaml b/tests/components/heatpumpir/test.esp32-idf.yaml new file mode 100644 index 0000000000..e891f9dc85 --- /dev/null +++ b/tests/components/heatpumpir/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 3b888c3d19..afc3fd9819 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -121,6 +121,8 @@ sensor: min_value: -10.0 - debounce: 0.1s - delta: 5.0 + - delta: + max_value: 2% - exponential_moving_average: alpha: 0.1 send_every: 15 diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml new file mode 100644 index 0000000000..19bd2d5ca4 --- /dev/null +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -0,0 +1,180 @@ +esphome: + name: test-delta-filters + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +sensor: + - platform: template + name: "Source Sensor 1" + id: source_sensor_1 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 2" + id: source_sensor_2 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 3" + id: source_sensor_3 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 4" + id: source_sensor_4 + accuracy_decimals: 1 + + - platform: copy + source_id: source_sensor_1 + name: "Filter Min" + id: filter_min + filters: + - delta: + min_value: 10 + + - platform: copy + source_id: source_sensor_2 + name: "Filter Max" + id: filter_max + filters: + - delta: + max_value: 10 + + - platform: copy + source_id: source_sensor_3 + id: test_3_baseline + filters: + - median: + window_size: 6 + send_every: 1 + send_first_at: 1 + + - platform: copy + source_id: source_sensor_3 + name: "Filter Baseline Max" + id: filter_baseline_max + filters: + - delta: + max_value: 10 + baseline: !lambda return id(test_3_baseline).state; + + - platform: copy + source_id: source_sensor_4 + name: "Filter Zero Delta" + id: filter_zero_delta + filters: + - delta: 0 + +script: + - id: test_filter_min + then: + - sensor.template.publish: + id: source_sensor_1 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 5.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 12.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 8.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: -2.0 + + - id: test_filter_max + then: + - sensor.template.publish: + id: source_sensor_2 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 5.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 40.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 10.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: -40.0 # Filtered out + + - id: test_filter_baseline_max + then: + - sensor.template.publish: + id: source_sensor_3 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 2.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 3.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 40.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 20.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 20.0 + + - id: test_filter_zero_delta + then: + - sensor.template.publish: + id: source_sensor_4 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_4 + state: 1.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_4 + state: 2.0 + +button: + - platform: template + name: "Test Filter Min" + id: btn_filter_min + on_press: + - script.execute: test_filter_min + + - platform: template + name: "Test Filter Max" + id: btn_filter_max + on_press: + - script.execute: test_filter_max + + - platform: template + name: "Test Filter Baseline Max" + id: btn_filter_baseline_max + on_press: + - script.execute: test_filter_baseline_max + + - platform: template + name: "Test Filter Zero Delta" + id: btn_filter_zero_delta + on_press: + - script.execute: test_filter_zero_delta diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py new file mode 100644 index 0000000000..c7a26bf9d1 --- /dev/null +++ b/tests/integration/test_sensor_filters_delta.py @@ -0,0 +1,163 @@ +"""Test sensor DeltaFilter functionality.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_filters_delta( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + + sensor_values: dict[str, list[float]] = { + "filter_min": [], + "filter_max": [], + "filter_baseline_max": [], + "filter_zero_delta": [], + } + + filter_min_done = loop.create_future() + filter_max_done = loop.create_future() + filter_baseline_max_done = loop.create_future() + filter_zero_delta_done = loop.create_future() + + def on_state(state: EntityState) -> None: + if not isinstance(state, SensorState) or state.missing_state: + return + + sensor_name = key_to_sensor.get(state.key) + if sensor_name not in sensor_values: + return + + sensor_values[sensor_name].append(state.state) + + # Check completion conditions + if ( + sensor_name == "filter_min" + and len(sensor_values[sensor_name]) == 3 + and not filter_min_done.done() + ): + filter_min_done.set_result(True) + elif ( + sensor_name == "filter_max" + and len(sensor_values[sensor_name]) == 3 + and not filter_max_done.done() + ): + filter_max_done.set_result(True) + elif ( + sensor_name == "filter_baseline_max" + and len(sensor_values[sensor_name]) == 4 + and not filter_baseline_max_done.done() + ): + filter_baseline_max_done.set_result(True) + elif ( + sensor_name == "filter_zero_delta" + and len(sensor_values[sensor_name]) == 2 + and not filter_zero_delta_done.done() + ): + filter_zero_delta_done.set_result(True) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + # Get entities and build key mapping + entities, _ = await client.list_entities_services() + key_to_sensor = build_key_to_entity_mapping( + entities, + { + "filter_min": "Filter Min", + "filter_max": "Filter Max", + "filter_baseline_max": "Filter Baseline Max", + "filter_zero_delta": "Filter Zero Delta", + }, + ) + + # Set up initial state helper with all entities + initial_state_helper = InitialStateHelper(entities) + + # Subscribe to state changes with wrapper + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states + await initial_state_helper.wait_for_initial_states() + + # Find all buttons + button_name_map = { + "Test Filter Min": "filter_min", + "Test Filter Max": "filter_max", + "Test Filter Baseline Max": "filter_baseline_max", + "Test Filter Zero Delta": "filter_zero_delta", + } + buttons = {} + for entity in entities: + if isinstance(entity, ButtonInfo) and entity.name in button_name_map: + buttons[button_name_map[entity.name]] = entity.key + + assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}" + + # Test 1: Min + sensor_values["filter_min"].clear() + client.button_command(buttons["filter_min"]) + try: + await asyncio.wait_for(filter_min_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 1 timed out. Values: {sensor_values['filter_min']}") + + expected = [1.0, 12.0, -2.0] + assert sensor_values["filter_min"] == pytest.approx(expected), ( + f"Test 1 failed: expected {expected}, got {sensor_values['filter_min']}" + ) + + # Test 2: Max + sensor_values["filter_max"].clear() + client.button_command(buttons["filter_max"]) + try: + await asyncio.wait_for(filter_max_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 2 timed out. Values: {sensor_values['filter_max']}") + + expected = [1.0, 5.0, 10.0] + assert sensor_values["filter_max"] == pytest.approx(expected), ( + f"Test 2 failed: expected {expected}, got {sensor_values['filter_max']}" + ) + + # Test 3: Baseline Max + sensor_values["filter_baseline_max"].clear() + client.button_command(buttons["filter_baseline_max"]) + try: + await asyncio.wait_for(filter_baseline_max_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 3 timed out. Values: {sensor_values['filter_baseline_max']}" + ) + + expected = [1.0, 2.0, 3.0, 20.0] + assert sensor_values["filter_baseline_max"] == pytest.approx(expected), ( + f"Test 3 failed: expected {expected}, got {sensor_values['filter_baseline_max']}" + ) + + # Test 4: Zero Delta + sensor_values["filter_zero_delta"].clear() + client.button_command(buttons["filter_zero_delta"]) + try: + await asyncio.wait_for(filter_zero_delta_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 4 timed out. Values: {sensor_values['filter_zero_delta']}" + ) + + expected = [1.0, 2.0] + assert sensor_values["filter_zero_delta"] == pytest.approx(expected), ( + f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}" + )