diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 9661c2ca02..0a272d21ba 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d272a88e8ca28ae9340a9a03295a566432a52cb696501908f57764475bf7ca65 +15dc295268b2dcf75942f42759b3ddec64eba89f75525698eb39c95a7f4b14ce 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/alarm_control_panel/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index 248b5065ad..ab0a780cef 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -67,52 +67,29 @@ void AlarmControlPanel::add_on_ready_callback(std::function &&callback) this->ready_callback_.add(std::move(callback)); } -void AlarmControlPanel::arm_away(optional code) { +void AlarmControlPanel::arm_with_code_(AlarmControlPanelCall &(AlarmControlPanelCall::*arm_method)(), + const char *code) { auto call = this->make_call(); - call.arm_away(); - if (code.has_value()) - call.set_code(code.value()); + (call.*arm_method)(); + if (code != nullptr) + call.set_code(code); call.perform(); } -void AlarmControlPanel::arm_home(optional code) { - auto call = this->make_call(); - call.arm_home(); - if (code.has_value()) - call.set_code(code.value()); - call.perform(); +void AlarmControlPanel::arm_away(const char *code) { this->arm_with_code_(&AlarmControlPanelCall::arm_away, code); } + +void AlarmControlPanel::arm_home(const char *code) { this->arm_with_code_(&AlarmControlPanelCall::arm_home, code); } + +void AlarmControlPanel::arm_night(const char *code) { this->arm_with_code_(&AlarmControlPanelCall::arm_night, code); } + +void AlarmControlPanel::arm_vacation(const char *code) { + this->arm_with_code_(&AlarmControlPanelCall::arm_vacation, code); } -void AlarmControlPanel::arm_night(optional code) { - auto call = this->make_call(); - call.arm_night(); - if (code.has_value()) - call.set_code(code.value()); - call.perform(); +void AlarmControlPanel::arm_custom_bypass(const char *code) { + this->arm_with_code_(&AlarmControlPanelCall::arm_custom_bypass, code); } -void AlarmControlPanel::arm_vacation(optional code) { - auto call = this->make_call(); - call.arm_vacation(); - if (code.has_value()) - call.set_code(code.value()); - call.perform(); -} - -void AlarmControlPanel::arm_custom_bypass(optional code) { - auto call = this->make_call(); - call.arm_custom_bypass(); - if (code.has_value()) - call.set_code(code.value()); - call.perform(); -} - -void AlarmControlPanel::disarm(optional code) { - auto call = this->make_call(); - call.disarm(); - if (code.has_value()) - call.set_code(code.value()); - call.perform(); -} +void AlarmControlPanel::disarm(const char *code) { this->arm_with_code_(&AlarmControlPanelCall::disarm, code); } } // namespace esphome::alarm_control_panel diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index 340f15bcd6..e8dc197e26 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -76,37 +76,53 @@ class AlarmControlPanel : public EntityBase { * * @param code The code */ - void arm_away(optional code = nullopt); + void arm_away(const char *code = nullptr); + void arm_away(const optional &code) { + this->arm_away(code.has_value() ? code.value().c_str() : nullptr); + } /** arm the alarm in home mode * * @param code The code */ - void arm_home(optional code = nullopt); + void arm_home(const char *code = nullptr); + void arm_home(const optional &code) { + this->arm_home(code.has_value() ? code.value().c_str() : nullptr); + } /** arm the alarm in night mode * * @param code The code */ - void arm_night(optional code = nullopt); + void arm_night(const char *code = nullptr); + void arm_night(const optional &code) { + this->arm_night(code.has_value() ? code.value().c_str() : nullptr); + } /** arm the alarm in vacation mode * * @param code The code */ - void arm_vacation(optional code = nullopt); + void arm_vacation(const char *code = nullptr); + void arm_vacation(const optional &code) { + this->arm_vacation(code.has_value() ? code.value().c_str() : nullptr); + } /** arm the alarm in custom bypass mode * * @param code The code */ - void arm_custom_bypass(optional code = nullopt); + void arm_custom_bypass(const char *code = nullptr); + void arm_custom_bypass(const optional &code) { + this->arm_custom_bypass(code.has_value() ? code.value().c_str() : nullptr); + } /** disarm the alarm * * @param code The code */ - void disarm(optional code = nullopt); + void disarm(const char *code = nullptr); + void disarm(const optional &code) { this->disarm(code.has_value() ? code.value().c_str() : nullptr); } /** Get the state * @@ -118,6 +134,8 @@ class AlarmControlPanel : public EntityBase { protected: friend AlarmControlPanelCall; + // Helper to reduce code duplication for arm/disarm methods + void arm_with_code_(AlarmControlPanelCall &(AlarmControlPanelCall::*arm_method)(), const char *code); // in order to store last panel state in flash ESPPreferenceObject pref_; // current state diff --git a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp index 5e98d58368..ba58ee3904 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp @@ -10,8 +10,10 @@ static const char *const TAG = "alarm_control_panel"; AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent_(parent) {} -AlarmControlPanelCall &AlarmControlPanelCall::set_code(const std::string &code) { - this->code_ = code; +AlarmControlPanelCall &AlarmControlPanelCall::set_code(const char *code) { + if (code != nullptr) { + this->code_ = std::string(code); + } return *this; } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel_call.h b/esphome/components/alarm_control_panel/alarm_control_panel_call.h index cff00900dd..58764ea166 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel_call.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel_call.h @@ -14,7 +14,8 @@ class AlarmControlPanelCall { public: AlarmControlPanelCall(AlarmControlPanel *parent); - AlarmControlPanelCall &set_code(const std::string &code); + AlarmControlPanelCall &set_code(const char *code); + AlarmControlPanelCall &set_code(const std::string &code) { return this->set_code(code.c_str()); } AlarmControlPanelCall &arm_away(); AlarmControlPanelCall &arm_home(); AlarmControlPanelCall &arm_night(); diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index ce5ceadb47..4ff34de0d5 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -66,15 +66,7 @@ template class ArmAwayAction : public Action { TEMPLATABLE_VALUE(std::string, code) - void play(const Ts &...x) override { - auto call = this->alarm_control_panel_->make_call(); - auto code = this->code_.optional_value(x...); - if (code.has_value()) { - call.set_code(code.value()); - } - call.arm_away(); - call.perform(); - } + void play(const Ts &...x) override { this->alarm_control_panel_->arm_away(this->code_.optional_value(x...)); } protected: AlarmControlPanel *alarm_control_panel_; @@ -86,15 +78,7 @@ template class ArmHomeAction : public Action { TEMPLATABLE_VALUE(std::string, code) - void play(const Ts &...x) override { - auto call = this->alarm_control_panel_->make_call(); - auto code = this->code_.optional_value(x...); - if (code.has_value()) { - call.set_code(code.value()); - } - call.arm_home(); - call.perform(); - } + void play(const Ts &...x) override { this->alarm_control_panel_->arm_home(this->code_.optional_value(x...)); } protected: AlarmControlPanel *alarm_control_panel_; @@ -106,15 +90,7 @@ template class ArmNightAction : public Action { TEMPLATABLE_VALUE(std::string, code) - void play(const Ts &...x) override { - auto call = this->alarm_control_panel_->make_call(); - auto code = this->code_.optional_value(x...); - if (code.has_value()) { - call.set_code(code.value()); - } - call.arm_night(); - call.perform(); - } + void play(const Ts &...x) override { this->alarm_control_panel_->arm_night(this->code_.optional_value(x...)); } protected: AlarmControlPanel *alarm_control_panel_; 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/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/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index a77e291237..8cc7b2663c 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -190,7 +190,7 @@ async def to_code(config): # Rotation is handled by setting the transform display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} await display.register_display(var, display_config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 45fe8d1c26..da49fdc070 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, @@ -180,6 +182,12 @@ def set_core_data(config): path=[CONF_CPU_FREQUENCY], ) + if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ": + _LOGGER.warning( + "400MHz on ESP32-P4 is experimental and may not boot. " + "Consider using 360MHz instead. See https://github.com/esphome/esphome/issues/13425" + ) + CORE.data[KEY_ESP32] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP32 conf = config[CONF_FRAMEWORK] @@ -199,6 +207,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] = {} @@ -339,7 +348,12 @@ def add_extra_build_file(filename: str, path: Path) -> bool: def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/espressif/arduino-esp32/releases) version to # a PIO pioarduino/framework-arduinoespressif32 value - return f"pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/{str(ver)}/esp32-{str(ver)}.zip" + # 3.3.6+ changed filename from esp32-{ver}.zip to esp32-core-{ver}.tar.xz + if ver >= cv.Version(3, 3, 6): + filename = f"esp32-core-{ver}.tar.xz" + else: + filename = f"esp32-{ver}.zip" + return f"pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/{ver}/{filename}" def _format_framework_espidf_version(ver: cv.Version, release: str) -> str: @@ -374,11 +388,12 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 5), - "latest": cv.Version(3, 3, 5), - "dev": cv.Version(3, 3, 5), + "recommended": cv.Version(3, 3, 6), + "latest": cv.Version(3, 3, 6), + "dev": cv.Version(3, 3, 6), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version(3, 3, 6): cv.Version(55, 3, 36), cv.Version(3, 3, 5): cv.Version(55, 3, 35), cv.Version(3, 3, 4): cv.Version(55, 3, 31, "2"), cv.Version(3, 3, 3): cv.Version(55, 3, 31, "2"), @@ -396,6 +411,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(3, 3, 6): cv.Version(5, 5, 2), cv.Version(3, 3, 5): cv.Version(5, 5, 2), cv.Version(3, 3, 4): cv.Version(5, 5, 1), cv.Version(3, 3, 3): cv.Version(5, 5, 1), @@ -418,7 +434,7 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(5, 5, 2), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { - cv.Version(5, 5, 2): cv.Version(55, 3, 35), + cv.Version(5, 5, 2): cv.Version(55, 3, 36), cv.Version(5, 5, 1): cv.Version(55, 3, 31, "2"), cv.Version(5, 5, 0): cv.Version(55, 3, 31, "2"), cv.Version(5, 4, 3): cv.Version(55, 3, 32), @@ -435,9 +451,9 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 35), - "latest": cv.Version(55, 3, 35), - "dev": cv.Version(55, 3, 35), + "recommended": cv.Version(55, 3, 36), + "latest": cv.Version(55, 3, 36), + "dev": cv.Version(55, 3, 36), } @@ -962,12 +978,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 +1035,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 +1224,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 +1390,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/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index a82ee48718..ebcdd5f36e 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -11,6 +11,7 @@ #include #ifdef USE_ESP32_HOSTED_HTTP_UPDATE +#include "esphome/components/http_request/http_request.h" #include "esphome/components/json/json_util.h" #include "esphome/components/network/util.h" #endif @@ -184,15 +185,23 @@ bool Esp32HostedUpdate::fetch_manifest_() { } // Read manifest JSON into string (manifest is small, ~1KB max) + // NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h + // Use http_read_loop_result() helper instead of checking return values directly std::string json_str; json_str.reserve(container->content_length); uint8_t buf[256]; + uint32_t last_data_time = millis(); + const uint32_t read_timeout = this->http_request_parent_->get_timeout(); while (container->get_bytes_read() < container->content_length) { - int read = container->read(buf, sizeof(buf)); - if (read > 0) { - json_str.append(reinterpret_cast(buf), read); - } + int read_or_error = container->read(buf, sizeof(buf)); + App.feed_wdt(); yield(); + auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); + if (result == http_request::HttpReadLoopResult::RETRY) + continue; + if (result != http_request::HttpReadLoopResult::DATA) + break; // ERROR or TIMEOUT + json_str.append(reinterpret_cast(buf), read_or_error); } container->end(); @@ -297,32 +306,38 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { } // Stream firmware to coprocessor while computing SHA256 + // NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h + // Use http_read_loop_result() helper instead of checking return values directly sha256::SHA256 hasher; hasher.init(); uint8_t buffer[CHUNK_SIZE]; + uint32_t last_data_time = millis(); + const uint32_t read_timeout = this->http_request_parent_->get_timeout(); while (container->get_bytes_read() < total_size) { - int read = container->read(buffer, sizeof(buffer)); + int read_or_error = container->read(buffer, sizeof(buffer)); // Feed watchdog and give other tasks a chance to run App.feed_wdt(); yield(); - // Exit loop if no data available (stream closed or end of data) - if (read <= 0) { - if (read < 0) { - ESP_LOGE(TAG, "Stream closed with error"); - esp_hosted_slave_ota_end(); // NOLINT - container->end(); - this->status_set_error(LOG_STR("Download failed")); - return false; + auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout); + if (result == http_request::HttpReadLoopResult::RETRY) + continue; + if (result != http_request::HttpReadLoopResult::DATA) { + if (result == http_request::HttpReadLoopResult::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading firmware data"); + } else { + ESP_LOGE(TAG, "Error reading firmware data: %d", read_or_error); } - // read == 0: no more data available, exit loop - break; + esp_hosted_slave_ota_end(); // NOLINT + container->end(); + this->status_set_error(LOG_STR("Download failed")); + return false; } - hasher.add(buffer, read); - err = esp_hosted_slave_ota_write(buffer, read); // NOLINT + hasher.add(buffer, read_or_error); + err = esp_hosted_slave_ota_write(buffer, read_or_error); // NOLINT if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); esp_hosted_slave_ota_end(); // NOLINT diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 47987b4a95..35d1cd07f7 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -12,7 +12,6 @@ extern "C" { #include "preferences.h" #include -#include namespace esphome::esp8266 { @@ -143,16 +142,8 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { return false; const size_t buffer_size = static_cast(this->length_words) + 1; - uint32_t stack_buffer[PREF_BUFFER_WORDS]; - std::unique_ptr heap_buffer; - uint32_t *buffer; - - if (buffer_size <= PREF_BUFFER_WORDS) { - buffer = stack_buffer; - } else { - heap_buffer = make_unique(buffer_size); - buffer = heap_buffer.get(); - } + SmallBufferWithHeapFallback buffer_alloc(buffer_size); + uint32_t *buffer = buffer_alloc.get(); memset(buffer, 0, buffer_size * sizeof(uint32_t)); memcpy(buffer, data, len); @@ -167,16 +158,8 @@ class ESP8266PreferenceBackend : public ESPPreferenceBackend { return false; const size_t buffer_size = static_cast(this->length_words) + 1; - uint32_t stack_buffer[PREF_BUFFER_WORDS]; - std::unique_ptr heap_buffer; - uint32_t *buffer; - - if (buffer_size <= PREF_BUFFER_WORDS) { - buffer = stack_buffer; - } else { - heap_buffer = make_unique(buffer_size); - buffer = heap_buffer.get(); - } + SmallBufferWithHeapFallback buffer_alloc(buffer_size); + uint32_t *buffer = buffer_alloc.get(); bool ret = this->in_flash ? load_from_flash(this->offset, buffer, buffer_size) : load_from_rtc(this->offset, buffer, buffer_size); 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.h b/esphome/components/http_request/http_request.h index a8c2cdfc63..fb39ca504c 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -79,6 +79,81 @@ inline bool is_redirect(int const status) { */ inline bool is_success(int const status) { return status >= HTTP_STATUS_OK && status < HTTP_STATUS_MULTIPLE_CHOICES; } +/* + * HTTP Container Read Semantics + * ============================= + * + * IMPORTANT: These semantics differ from standard BSD sockets! + * + * BSD socket read() returns: + * > 0: bytes read + * == 0: connection closed (EOF) + * < 0: error (check errno) + * + * HttpContainer::read() returns: + * > 0: bytes read successfully + * == 0: no data available yet OR all content read + * (caller should check bytes_read vs content_length) + * < 0: error or connection closed (caller should EXIT) + * HTTP_ERROR_CONNECTION_CLOSED (-1) = connection closed prematurely + * other negative values = platform-specific errors + * + * Platform behaviors: + * - ESP-IDF: blocking reads, 0 only returned when all content read + * - Arduino: non-blocking, 0 means "no data yet" or "all content read" + * + * Use the helper functions below instead of checking return values directly: + * - http_read_loop_result(): for manual loops with per-chunk processing + * - http_read_fully(): for simple "read N bytes into buffer" operations + */ + +/// Error code returned by HttpContainer::read() when connection closed prematurely +/// NOTE: Unlike BSD sockets where 0 means EOF, here 0 means "no data yet, retry" +static constexpr int HTTP_ERROR_CONNECTION_CLOSED = -1; + +/// Status of a read operation +enum class HttpReadStatus : uint8_t { + OK, ///< Read completed successfully + ERROR, ///< Read error occurred + TIMEOUT, ///< Timeout waiting for data +}; + +/// Result of an HTTP read operation +struct HttpReadResult { + HttpReadStatus status; ///< Status of the read operation + int error_code; ///< Error code from read() on failure, 0 on success +}; + +/// Result of processing a non-blocking read with timeout (for manual loops) +enum class HttpReadLoopResult : uint8_t { + DATA, ///< Data was read, process it + RETRY, ///< No data yet, already delayed, caller should continue loop + ERROR, ///< Read error, caller should exit loop + TIMEOUT, ///< Timeout waiting for data, caller should exit loop +}; + +/// Process a read result with timeout tracking and delay handling +/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error +/// @param last_data_time Time of last successful read, updated when data received +/// @param timeout_ms Maximum time to wait for data +/// @return DATA if data received, RETRY if should continue loop, ERROR/TIMEOUT if should exit +inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, + uint32_t timeout_ms) { + if (bytes_read_or_error > 0) { + last_data_time = millis(); + return HttpReadLoopResult::DATA; + } + if (bytes_read_or_error < 0) { + return HttpReadLoopResult::ERROR; + } + // bytes_read_or_error == 0: no data available yet + if (millis() - last_data_time >= timeout_ms) { + return HttpReadLoopResult::TIMEOUT; + } + delay(1); // Small delay to prevent tight spinning + return HttpReadLoopResult::RETRY; +} + class HttpRequestComponent; class HttpContainer : public Parented { @@ -88,6 +163,33 @@ class HttpContainer : public Parented { int status_code; uint32_t duration_ms; + /** + * @brief Read data from the HTTP response body. + * + * WARNING: These semantics differ from BSD sockets! + * BSD sockets: 0 = EOF (connection closed) + * This method: 0 = no data yet OR all content read, negative = error/closed + * + * @param buf Buffer to read data into + * @param max_len Maximum number of bytes to read + * @return + * - > 0: Number of bytes read successfully + * - 0: No data available yet OR all content read + * (check get_bytes_read() >= content_length to distinguish) + * - HTTP_ERROR_CONNECTION_CLOSED (-1): Connection closed prematurely + * - < -1: Other error (platform-specific error code) + * + * Platform notes: + * - ESP-IDF: blocking read, 0 only when all content read + * - Arduino: non-blocking, 0 can mean "no data yet" or "all content read" + * + * Use get_bytes_read() and content_length to track progress. + * When get_bytes_read() >= content_length, all data has been received. + * + * IMPORTANT: Do not use raw return values directly. Use these helpers: + * - http_read_loop_result(): for loops with per-chunk processing + * - http_read_fully(): for simple "read N bytes" operations + */ virtual int read(uint8_t *buf, size_t max_len) = 0; virtual void end() = 0; @@ -110,6 +212,38 @@ class HttpContainer : public Parented { std::map> response_headers_{}; }; +/// Read data from HTTP container into buffer with timeout handling +/// Handles feed_wdt, yield, and timeout checking internally +/// @param container The HTTP container to read from +/// @param buffer Buffer to read into +/// @param total_size Total bytes to read +/// @param chunk_size Maximum bytes per read call +/// @param timeout_ms Read timeout in milliseconds +/// @return HttpReadResult with status and error_code on failure +inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, size_t total_size, size_t chunk_size, + uint32_t timeout_ms) { + size_t read_index = 0; + uint32_t last_data_time = millis(); + + while (read_index < total_size) { + int read_bytes_or_error = container->read(buffer + read_index, std::min(chunk_size, total_size - read_index)); + + App.feed_wdt(); + yield(); + + auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms); + if (result == HttpReadLoopResult::RETRY) + continue; + if (result == HttpReadLoopResult::ERROR) + return {HttpReadStatus::ERROR, read_bytes_or_error}; + if (result == HttpReadLoopResult::TIMEOUT) + return {HttpReadStatus::TIMEOUT, 0}; + + read_index += read_bytes_or_error; + } + return {HttpReadStatus::OK, 0}; +} + class HttpRequestResponseTrigger : public Trigger, std::string &> { public: void process(const std::shared_ptr &container, std::string &response_body) { @@ -124,6 +258,7 @@ class HttpRequestComponent : public Component { void set_useragent(const char *useragent) { this->useragent_ = useragent; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } + uint32_t get_timeout() const { return this->timeout_; } void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } @@ -249,15 +384,21 @@ template class HttpRequestSendAction : public Action { RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); if (buf != nullptr) { + // NOTE: HttpContainer::read() has non-BSD socket semantics - see top of this file + // Use http_read_loop_result() helper instead of checking return values directly size_t read_index = 0; + uint32_t last_data_time = millis(); + const uint32_t read_timeout = this->parent_->get_timeout(); while (container->get_bytes_read() < max_length) { - int read = container->read(buf + read_index, std::min(max_length - read_index, 512)); - if (read <= 0) { - break; - } + int read_or_error = container->read(buf + read_index, std::min(max_length - read_index, 512)); App.feed_wdt(); yield(); - read_index += read; + auto result = http_read_loop_result(read_or_error, last_data_time, read_timeout); + if (result == HttpReadLoopResult::RETRY) + continue; + if (result != HttpReadLoopResult::DATA) + break; // ERROR or TIMEOUT + read_index += read_or_error; } response_body.reserve(read_index); response_body.assign((char *) buf, read_index); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index a653942b18..8ec4d2bc4b 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -139,6 +139,23 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return container; } +// Arduino HTTP read implementation +// +// WARNING: Return values differ from BSD sockets! See http_request.h for full documentation. +// +// Arduino's WiFiClient is inherently non-blocking - available() returns 0 when +// no data is ready. We use connected() to distinguish "no data yet" from +// "connection closed". +// +// WiFiClient behavior: +// available() > 0: data ready to read +// available() == 0 && connected(): no data yet, still connected +// available() == 0 && !connected(): connection closed +// +// We normalize to HttpContainer::read() contract (NOT BSD socket semantics!): +// > 0: bytes read +// 0: no data yet, retry <-- NOTE: 0 means retry, NOT EOF! +// < 0: error/connection closed <-- connection closed returns -1, not 0 int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); @@ -146,7 +163,7 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { WiFiClient *stream_ptr = this->client_.getStreamPtr(); if (stream_ptr == nullptr) { ESP_LOGE(TAG, "Stream pointer vanished!"); - return -1; + return HTTP_ERROR_CONNECTION_CLOSED; } int available_data = stream_ptr->available(); @@ -154,7 +171,15 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { if (bufsize == 0) { this->duration_ms += (millis() - start); - return 0; + // Check if we've read all expected content + if (this->bytes_read_ >= this->content_length) { + return 0; // All content read successfully + } + // No data available - check if connection is still open + if (!stream_ptr->connected()) { + return HTTP_ERROR_CONNECTION_CLOSED; // Connection closed prematurely + } + return 0; // No data yet, caller should retry } App.feed_wdt(); diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 1de947ba5b..b6fb7f7ea9 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 @@ -209,26 +209,57 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return container; } +// ESP-IDF HTTP read implementation (blocking mode) +// +// WARNING: Return values differ from BSD sockets! See http_request.h for full documentation. +// +// esp_http_client_read() in blocking mode returns: +// > 0: bytes read +// 0: connection closed (end of stream) +// < 0: error +// +// We normalize to HttpContainer::read() contract: +// > 0: bytes read +// 0: no data yet / all content read (caller should check bytes_read vs content_length) +// < 0: error/connection closed int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); - this->feed_wdt(); - int read_len = esp_http_client_read(this->client_, (char *) buf, max_len); - this->feed_wdt(); - if (read_len > 0) { - this->bytes_read_ += read_len; + // Check if we've already read all expected content + if (this->bytes_read_ >= this->content_length) { + return 0; // All content read successfully } + + this->feed_wdt(); + int read_len_or_error = esp_http_client_read(this->client_, (char *) buf, max_len); + this->feed_wdt(); + this->duration_ms += (millis() - start); - return read_len; + if (read_len_or_error > 0) { + this->bytes_read_ += read_len_or_error; + return read_len_or_error; + } + + // Connection closed by server before all content received + if (read_len_or_error == 0) { + return HTTP_ERROR_CONNECTION_CLOSED; + } + + // Negative value - error, return the actual error code for debugging + return read_len_or_error; } void HttpContainerIDF::end() { + if (this->client_ == nullptr) { + return; // Already cleaned up + } watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); esp_http_client_close(this->client_); esp_http_client_cleanup(this->client_); + this->client_ = nullptr; } void HttpContainerIDF::feed_wdt() { 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/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 2a7db9137f..6c77e75d8c 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -115,39 +115,47 @@ uint8_t OtaHttpRequestComponent::do_ota_() { return error_code; } + // NOTE: HttpContainer::read() has non-BSD socket semantics - see http_request.h + // Use http_read_loop_result() helper instead of checking return values directly + uint32_t last_data_time = millis(); + const uint32_t read_timeout = this->parent_->get_timeout(); + while (container->get_bytes_read() < container->content_length) { - // read a maximum of chunk_size bytes into buf. (real read size returned) - int bufsize = container->read(buf, OtaHttpRequestComponent::HTTP_RECV_BUFFER); - ESP_LOGVV(TAG, "bytes_read_ = %u, body_length_ = %u, bufsize = %i", container->get_bytes_read(), - container->content_length, bufsize); + // read a maximum of chunk_size bytes into buf. (real read size returned, or negative error code) + int bufsize_or_error = container->read(buf, OtaHttpRequestComponent::HTTP_RECV_BUFFER); + ESP_LOGVV(TAG, "bytes_read_ = %u, body_length_ = %u, bufsize_or_error = %i", container->get_bytes_read(), + container->content_length, bufsize_or_error); // feed watchdog and give other tasks a chance to run App.feed_wdt(); yield(); - // Exit loop if no data available (stream closed or end of data) - if (bufsize <= 0) { - if (bufsize < 0) { - ESP_LOGE(TAG, "Stream closed with error"); - this->cleanup_(std::move(backend), container); - return OTA_CONNECTION_ERROR; + auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout); + if (result == HttpReadLoopResult::RETRY) + continue; + if (result != HttpReadLoopResult::DATA) { + if (result == HttpReadLoopResult::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading data"); + } else { + ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error); } - // bufsize == 0: no more data available, exit loop - break; + this->cleanup_(std::move(backend), container); + return OTA_CONNECTION_ERROR; } - if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { + // At this point bufsize_or_error > 0, so it's a valid size + if (bufsize_or_error <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { // add read bytes to MD5 - md5_receive.add(buf, bufsize); + md5_receive.add(buf, bufsize_or_error); // write bytes to OTA backend this->update_started_ = true; - error_code = backend->write(buf, bufsize); + error_code = backend->write(buf, bufsize_or_error); if (error_code != ota::OTA_RESPONSE_OK) { // error code explanation available at // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code, - container->get_bytes_read() - bufsize, container->content_length); + container->get_bytes_read() - bufsize_or_error, container->content_length); this->cleanup_(std::move(backend), container); return error_code; } @@ -244,19 +252,19 @@ bool OtaHttpRequestComponent::http_get_md5_() { } this->md5_expected_.resize(MD5_SIZE); - int read_len = 0; - while (container->get_bytes_read() < MD5_SIZE) { - read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE); - if (read_len <= 0) { - break; - } - App.feed_wdt(); - yield(); - } + auto result = http_read_fully(container.get(), (uint8_t *) this->md5_expected_.data(), MD5_SIZE, MD5_SIZE, + this->parent_->get_timeout()); container->end(); - ESP_LOGV(TAG, "Read len: %u, MD5 expected: %u", read_len, MD5_SIZE); - return read_len == MD5_SIZE; + if (result.status != HttpReadStatus::OK) { + if (result.status == HttpReadStatus::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading MD5"); + } else { + ESP_LOGE(TAG, "Error reading MD5: %d", result.error_code); + } + return false; + } + return true; } bool OtaHttpRequestComponent::validate_url_(const std::string &url) { diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 82b391e01f..c63e55d159 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -11,7 +11,12 @@ namespace http_request { // The update function runs in a task only on ESP32s. #ifdef USE_ESP32 -#define UPDATE_RETURN vTaskDelete(nullptr) // Delete the current update task +// vTaskDelete doesn't return, but clang-tidy doesn't know that +#define UPDATE_RETURN \ + do { \ + vTaskDelete(nullptr); \ + __builtin_unreachable(); \ + } while (0) #else #define UPDATE_RETURN return #endif @@ -70,19 +75,21 @@ void HttpRequestUpdate::update_task(void *params) { UPDATE_RETURN; } - size_t read_index = 0; - while (container->get_bytes_read() < container->content_length) { - int read_bytes = container->read(data + read_index, MAX_READ_SIZE); - - yield(); - - if (read_bytes <= 0) { - // Network error or connection closed - break to avoid infinite loop - break; + auto read_result = http_read_fully(container.get(), data, container->content_length, MAX_READ_SIZE, + this_update->request_parent_->get_timeout()); + if (read_result.status != HttpReadStatus::OK) { + if (read_result.status == HttpReadStatus::TIMEOUT) { + ESP_LOGE(TAG, "Timeout reading manifest"); + } else { + ESP_LOGE(TAG, "Error reading manifest: %d", read_result.error_code); } - - read_index += read_bytes; + // Defer to main loop to avoid race condition on component_state_ read-modify-write + this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to read manifest")); }); + allocator.deallocate(data, container->content_length); + container->end(); + UPDATE_RETURN; } + size_t read_index = container->get_bytes_read(); bool valid = false; { // Ensures the response string falls out of scope and deallocates before the task ends diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 9588bccd55..bfb2300f4f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -223,7 +223,7 @@ async def to_code(config): var = cg.Pvariable(config[CONF_ID], rhs) await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) if init_sequences := config.get(CONF_INIT_SEQUENCE): 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/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/max7219/display.py b/esphome/components/max7219/display.py index c9d10f3c45..a434125148 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -29,7 +29,7 @@ CONFIG_SCHEMA = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index fef121ff10..e6d53efc5d 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -86,7 +86,7 @@ CONFIG_SCHEMA = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) 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/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 96e167b2e6..084fe6de14 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -260,7 +260,7 @@ async def to_code(config): cg.add(var.set_enable_pins(enable)) if CONF_SPI_ID in config: - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) sequence, madctl = model.get_sequence(config) cg.add(var.set_init_sequence(sequence)) cg.add(var.set_madctl(madctl)) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 69bf133c68..8dccfa3a92 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -443,6 +443,4 @@ async def to_code(config): ) cg.add(var.set_writer(lambda_)) await display.register_display(var, config) - await spi.register_spi_device(var, config) - # Displays are write-only, set the SPI device to write-only as well - cg.add(var.set_write_only(True)) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/mqtt/custom_mqtt_device.cpp b/esphome/components/mqtt/custom_mqtt_device.cpp index 7ff65bb42c..64521f5cf3 100644 --- a/esphome/components/mqtt/custom_mqtt_device.cpp +++ b/esphome/components/mqtt/custom_mqtt_device.cpp @@ -18,7 +18,7 @@ bool CustomMQTTDevice::publish(const std::string &topic, float value, int8_t num } bool CustomMQTTDevice::publish(const std::string &topic, int value) { char buffer[24]; - int len = snprintf(buffer, sizeof(buffer), "%d", value); + size_t len = buf_append_printf(buffer, sizeof(buffer), 0, "%d", value); return global_mqtt_client->publish(topic, buffer, len); } bool CustomMQTTDevice::publish_json(const std::string &topic, const json::json_build_t &f, uint8_t qos, bool retain) { diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index 6245d10882..715e6feed8 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -43,7 +43,7 @@ void MQTTAlarmControlPanelComponent::setup() { void MQTTAlarmControlPanelComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT alarm_control_panel '%s':", this->alarm_control_panel_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); ESP_LOGCONFIG(TAG, " Supported Features: %" PRIu32 "\n" " Requires Code to Disarm: %s\n" diff --git a/esphome/components/mqtt/mqtt_binary_sensor.cpp b/esphome/components/mqtt/mqtt_binary_sensor.cpp index a37043406b..7cbb5dcc0e 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.cpp +++ b/esphome/components/mqtt/mqtt_binary_sensor.cpp @@ -19,7 +19,7 @@ void MQTTBinarySensorComponent::setup() { void MQTTBinarySensorComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Binary Sensor '%s':", this->binary_sensor_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, false) + LOG_MQTT_COMPONENT(true, false); } MQTTBinarySensorComponent::MQTTBinarySensorComponent(binary_sensor::BinarySensor *binary_sensor) : binary_sensor_(binary_sensor) { diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index de79b61358..e7364f3406 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -98,7 +98,17 @@ void MQTTClientComponent::send_device_info_() { uint8_t index = 0; for (auto &ip : network::get_ip_addresses()) { if (ip.is_set()) { - root["ip" + (index == 0 ? "" : esphome::to_string(index))] = ip.str(); + char key[8]; // "ip" + up to 3 digits + null + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + if (index == 0) { + key[0] = 'i'; + key[1] = 'p'; + key[2] = '\0'; + } else { + buf_append_printf(key, sizeof(key), 0, "ip%u", index); + } + ip.str_to(ip_buf); + root[key] = ip_buf; index++; } } diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 2ba466af3b..7607a4e817 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -27,20 +27,23 @@ inline char *append_char(char *p, char c) { // Max lengths for stack-based topic building. // These limits are enforced at Python config validation time in mqtt/__init__.py // using cv.Length() validators for topic_prefix and discovery_prefix. -// MQTT_COMPONENT_TYPE_MAX_LEN and MQTT_SUFFIX_MAX_LEN are defined in mqtt_component.h. +// MQTT_COMPONENT_TYPE_MAX_LEN, MQTT_SUFFIX_MAX_LEN, and MQTT_DEFAULT_TOPIC_MAX_LEN are in mqtt_component.h. // ESPHOME_DEVICE_NAME_MAX_LEN and OBJECT_ID_MAX_LEN are defined in entity_base.h. // This ensures the stack buffers below are always large enough. -static constexpr size_t TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) static constexpr size_t DISCOVERY_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) - -// Stack buffer sizes - safe because all inputs are length-validated at config time -// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null -static constexpr size_t DEFAULT_TOPIC_MAX_LEN = - TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; // Format: prefix + "/" + type + "/" + name + "/" + object_id + "/config" + null static constexpr size_t DISCOVERY_TOPIC_MAX_LEN = DISCOVERY_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 7 + 1; +// Function implementation of LOG_MQTT_COMPONENT macro to reduce code size +void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic) { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + if (state_topic) + ESP_LOGCONFIG(tag, " State Topic: '%s'", obj->get_state_topic_to_(buf).c_str()); + if (command_topic) + ESP_LOGCONFIG(tag, " Command Topic: '%s'", obj->get_command_topic_to_(buf).c_str()); +} + void MQTTComponent::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTComponent::set_subscribe_qos(uint8_t qos) { this->subscribe_qos_ = qos; } @@ -69,19 +72,18 @@ std::string MQTTComponent::get_discovery_topic_(const MQTTDiscoveryInfo &discove return std::string(buf, p - buf); } -std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) const { +StringRef MQTTComponent::get_default_topic_for_to_(std::span buf, const char *suffix, + size_t suffix_len) const { const std::string &topic_prefix = global_mqtt_client->get_topic_prefix(); if (topic_prefix.empty()) { - // If the topic_prefix is null, the default topic should be null - return ""; + return StringRef(); // Empty topic_prefix means no default topic } const char *comp_type = this->component_type(); char object_id_buf[OBJECT_ID_MAX_LEN]; StringRef object_id = this->get_default_object_id_to_(object_id_buf); - char buf[DEFAULT_TOPIC_MAX_LEN]; - char *p = buf; + char *p = buf.data(); p = append_str(p, topic_prefix.data(), topic_prefix.size()); p = append_char(p, '/'); @@ -89,21 +91,44 @@ std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) con p = append_char(p, '/'); p = append_str(p, object_id.c_str(), object_id.size()); p = append_char(p, '/'); - p = append_str(p, suffix.data(), suffix.size()); + p = append_str(p, suffix, suffix_len); + *p = '\0'; - return std::string(buf, p - buf); + return StringRef(buf.data(), p - buf.data()); +} + +std::string MQTTComponent::get_default_topic_for_(const std::string &suffix) const { + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_default_topic_for_to_(buf, suffix.data(), suffix.size()); + return std::string(ref.c_str(), ref.size()); +} + +StringRef MQTTComponent::get_state_topic_to_(std::span buf) const { + if (this->custom_state_topic_.has_value()) { + // Returns ref to existing data for static/value, uses buf only for lambda case + return this->custom_state_topic_.ref_or_copy_to(buf.data(), buf.size()); + } + return this->get_default_topic_for_to_(buf, "state", 5); +} + +StringRef MQTTComponent::get_command_topic_to_(std::span buf) const { + if (this->custom_command_topic_.has_value()) { + // Returns ref to existing data for static/value, uses buf only for lambda case + return this->custom_command_topic_.ref_or_copy_to(buf.data(), buf.size()); + } + return this->get_default_topic_for_to_(buf, "command", 7); } std::string MQTTComponent::get_state_topic_() const { - if (this->custom_state_topic_.has_value()) - return this->custom_state_topic_.value(); - return this->get_default_topic_for_("state"); + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_state_topic_to_(buf); + return std::string(ref.c_str(), ref.size()); } std::string MQTTComponent::get_command_topic_() const { - if (this->custom_command_topic_.has_value()) - return this->custom_command_topic_.value(); - return this->get_default_topic_for_("command"); + char buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + StringRef ref = this->get_command_topic_to_(buf); + return std::string(ref.c_str(), ref.size()); } bool MQTTComponent::publish(const std::string &topic, const std::string &payload) { @@ -168,10 +193,14 @@ bool MQTTComponent::send_discovery_() { break; } - if (config.state_topic) - root[MQTT_STATE_TOPIC] = this->get_state_topic_(); - if (config.command_topic) - root[MQTT_COMMAND_TOPIC] = this->get_command_topic_(); + if (config.state_topic) { + char state_topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + root[MQTT_STATE_TOPIC] = this->get_state_topic_to_(state_topic_buf); + } + if (config.command_topic) { + char command_topic_buf[MQTT_DEFAULT_TOPIC_MAX_LEN]; + root[MQTT_COMMAND_TOPIC] = this->get_command_topic_to_(command_topic_buf); + } if (this->command_retain_) root[MQTT_COMMAND_RETAIN] = true; @@ -190,27 +219,37 @@ bool MQTTComponent::send_discovery_() { StringRef object_id = this->get_default_object_id_to_(object_id_buf); if (discovery_info.unique_id_generator == MQTT_MAC_ADDRESS_UNIQUE_ID_GENERATOR) { char friendly_name_hash[9]; - snprintf(friendly_name_hash, sizeof(friendly_name_hash), "%08" PRIx32, fnv1_hash(this->friendly_name_())); + buf_append_printf(friendly_name_hash, sizeof(friendly_name_hash), 0, "%08" PRIx32, + fnv1_hash(this->friendly_name_())); // Format: mac-component_type-hash (e.g. "aabbccddeeff-sensor-12345678") // MAC (12) + "-" (1) + domain (max 20) + "-" (1) + hash (8) + null (1) = 43 char unique_id[MAC_ADDRESS_BUFFER_SIZE + ESPHOME_DOMAIN_MAX_LEN + 11]; char mac_buf[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_buf); - snprintf(unique_id, sizeof(unique_id), "%s-%s-%s", mac_buf, this->component_type(), friendly_name_hash); + buf_append_printf(unique_id, sizeof(unique_id), 0, "%s-%s-%s", mac_buf, this->component_type(), + friendly_name_hash); root[MQTT_UNIQUE_ID] = unique_id; } else { // default to almost-unique ID. It's a hack but the only way to get that // gorgeous device registry view. - root[MQTT_UNIQUE_ID] = "ESP" + std::string(this->component_type()) + object_id.c_str(); + // "ESP" (3) + component_type (max 20) + object_id (max 128) + null + char unique_id_buf[3 + MQTT_COMPONENT_TYPE_MAX_LEN + OBJECT_ID_MAX_LEN + 1]; + buf_append_printf(unique_id_buf, sizeof(unique_id_buf), 0, "ESP%s%s", this->component_type(), + object_id.c_str()); + root[MQTT_UNIQUE_ID] = unique_id_buf; } const std::string &node_name = App.get_name(); - if (discovery_info.object_id_generator == MQTT_DEVICE_NAME_OBJECT_ID_GENERATOR) - root[MQTT_OBJECT_ID] = node_name + "_" + object_id.c_str(); + if (discovery_info.object_id_generator == MQTT_DEVICE_NAME_OBJECT_ID_GENERATOR) { + // node_name (max 31) + "_" (1) + object_id (max 128) + null + char object_id_full[ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1]; + buf_append_printf(object_id_full, sizeof(object_id_full), 0, "%s_%s", node_name.c_str(), object_id.c_str()); + root[MQTT_OBJECT_ID] = object_id_full; + } const std::string &friendly_name_ref = App.get_friendly_name(); const std::string &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref; - std::string node_area = App.get_area(); + const char *node_area = App.get_area(); JsonObject device_info = root[MQTT_DEVICE].to(); char mac[MAC_ADDRESS_BUFFER_SIZE]; @@ -221,18 +260,29 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_PROJECT_VERSION " (ESPHome " ESPHOME_VERSION ")"; const char *model = std::strchr(ESPHOME_PROJECT_NAME, '.'); device_info[MQTT_DEVICE_MODEL] = model == nullptr ? ESPHOME_BOARD : model + 1; - device_info[MQTT_DEVICE_MANUFACTURER] = - model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); + if (model == nullptr) { + device_info[MQTT_DEVICE_MANUFACTURER] = ESPHOME_PROJECT_NAME; + } else { + // Extract manufacturer (part before '.') using stack buffer to avoid heap allocation + // memcpy is used instead of strncpy since we know the exact length and strncpy + // would still require manual null-termination + char manufacturer[sizeof(ESPHOME_PROJECT_NAME)]; + size_t len = model - ESPHOME_PROJECT_NAME; + memcpy(manufacturer, ESPHOME_PROJECT_NAME, len); + manufacturer[len] = '\0'; + device_info[MQTT_DEVICE_MANUFACTURER] = manufacturer; + } #else static const char ver_fmt[] PROGMEM = ESPHOME_VERSION " (config hash 0x%08" PRIx32 ")"; + // Buffer sized for format string expansion: ~4 bytes net growth from format specifier to 8 hex digits, plus + // safety margin + char version_buf[sizeof(ver_fmt) + 8]; #ifdef USE_ESP8266 - char fmt_buf[sizeof(ver_fmt)]; - strcpy_P(fmt_buf, ver_fmt); - const char *fmt = fmt_buf; + snprintf_P(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); #else - const char *fmt = ver_fmt; + snprintf(version_buf, sizeof(version_buf), ver_fmt, App.get_config_hash()); #endif - device_info[MQTT_DEVICE_SW_VERSION] = str_sprintf(fmt, App.get_config_hash()); + device_info[MQTT_DEVICE_SW_VERSION] = version_buf; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; @@ -246,7 +296,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = "Host"; #endif #endif - if (!node_area.empty()) { + if (node_area[0] != '\0') { device_info[MQTT_DEVICE_SUGGESTED_AREA] = node_area; } @@ -288,7 +338,9 @@ void MQTTComponent::set_availability(std::string topic, std::string payload_avai } void MQTTComponent::disable_availability() { this->set_availability("", "", ""); } void MQTTComponent::call_setup() { - if (this->is_internal()) + // Cache is_internal result once during setup - topics don't change after this + this->is_internal_ = this->compute_is_internal_(); + if (this->is_internal_) return; this->setup(); @@ -340,26 +392,28 @@ StringRef MQTTComponent::get_default_object_id_to_(std::spanget_entity()->get_icon_ref(); } bool MQTTComponent::is_disabled_by_default_() const { return this->get_entity()->is_disabled_by_default(); } -bool MQTTComponent::is_internal() { +bool MQTTComponent::compute_is_internal_() { if (this->custom_state_topic_.has_value()) { - // If the custom state_topic is null, return true as it is internal and should not publish + // If the custom state_topic is empty, return true as it is internal and should not publish // else, return false, as it is explicitly set to a topic, so it is not internal and should publish - return this->get_state_topic_().empty(); + // Using is_empty() avoids heap allocation for non-lambda cases + return this->custom_state_topic_.is_empty(); } if (this->custom_command_topic_.has_value()) { - // If the custom command_topic is null, return true as it is internal and should not publish + // If the custom command_topic is empty, return true as it is internal and should not publish // else, return false, as it is explicitly set to a topic, so it is not internal and should publish - return this->get_command_topic_().empty(); + // Using is_empty() avoids heap allocation for non-lambda cases + return this->custom_command_topic_.is_empty(); } - // No custom topics have been set - if (this->get_default_topic_for_("").empty()) { - // If the default topic prefix is null, then the component, by default, is internal and should not publish + // No custom topics have been set - check topic_prefix directly to avoid allocation + if (global_mqtt_client->get_topic_prefix().empty()) { + // If the default topic prefix is empty, then the component, by default, is internal and should not publish return true; } - // Use ESPHome's component internal state if topic_prefix is not null with no custom state_topic or command_topic + // Use ESPHome's component internal state if topic_prefix is not empty with no custom state_topic or command_topic return this->get_entity()->is_internal(); } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index dea91e3d5a..1a5e6db3af 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -20,17 +20,22 @@ struct SendDiscoveryConfig { bool command_topic{true}; ///< If the command topic should be included. Default to true. }; -// Max lengths for stack-based topic building (must match mqtt_component.cpp) +// Max lengths for stack-based topic building. +// These limits are enforced at Python config validation time in mqtt/__init__.py +// using cv.Length() validators for topic_prefix and discovery_prefix. +// This ensures the stack buffers are always large enough. static constexpr size_t MQTT_COMPONENT_TYPE_MAX_LEN = 20; static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32; +static constexpr size_t MQTT_TOPIC_PREFIX_MAX_LEN = 64; // Validated in Python: cv.Length(max=64) +// Stack buffer size - safe because all inputs are length-validated at config time +// Format: prefix + "/" + type + "/" + object_id + "/" + suffix + null +static constexpr size_t MQTT_DEFAULT_TOPIC_MAX_LEN = + MQTT_TOPIC_PREFIX_MAX_LEN + 1 + MQTT_COMPONENT_TYPE_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1 + MQTT_SUFFIX_MAX_LEN + 1; -#define LOG_MQTT_COMPONENT(state_topic, command_topic) \ - if (state_topic) { \ - ESP_LOGCONFIG(TAG, " State Topic: '%s'", this->get_state_topic_().c_str()); \ - } \ - if (command_topic) { \ - ESP_LOGCONFIG(TAG, " Command Topic: '%s'", this->get_command_topic_().c_str()); \ - } +class MQTTComponent; // Forward declaration +void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); + +#define LOG_MQTT_COMPONENT(state_topic, command_topic) log_mqtt_component(TAG, this, state_topic, command_topic) // Macro to define component_type() with compile-time length verification // Usage: MQTT_COMPONENT_TYPE(MQTTSensorComponent, "sensor") @@ -74,6 +79,8 @@ static constexpr size_t MQTT_SUFFIX_MAX_LEN = 32; * a clean separation. */ class MQTTComponent : public Component { + friend void log_mqtt_component(const char *tag, MQTTComponent *obj, bool state_topic, bool command_topic); + public: /// Constructs a MQTTComponent. explicit MQTTComponent(); @@ -88,7 +95,8 @@ class MQTTComponent : public Component { virtual bool send_initial_state() = 0; - virtual bool is_internal(); + /// Returns cached is_internal result (computed once during setup). + bool is_internal() const { return this->is_internal_; } /// Set QOS for state messages. void set_qos(uint8_t qos); @@ -179,7 +187,16 @@ class MQTTComponent : public Component { /// Helper method to get the discovery topic for this component. std::string get_discovery_topic_(const MQTTDiscoveryInfo &discovery_info) const; - /** Get this components state/command/... topic. + /** Get this components state/command/... topic into a buffer. + * + * @param buf The buffer to write to (must be exactly MQTT_DEFAULT_TOPIC_MAX_LEN). + * @param suffix The suffix/key such as "state" or "command". + * @return StringRef pointing to the buffer with the topic. + */ + StringRef get_default_topic_for_to_(std::span buf, const char *suffix, + size_t suffix_len) const; + + /** Get this components state/command/... topic (allocates std::string). * * @param suffix The suffix/key such as "state" or "command". * @return The full topic. @@ -200,10 +217,20 @@ class MQTTComponent : public Component { /// Get whether the underlying Entity is disabled by default bool is_disabled_by_default_() const; - /// Get the MQTT topic that new states will be shared to. + /// Get the MQTT state topic into a buffer (no heap allocation for non-lambda custom topics). + /// @param buf Buffer of exactly MQTT_DEFAULT_TOPIC_MAX_LEN bytes. + /// @return StringRef pointing to the topic in the buffer. + StringRef get_state_topic_to_(std::span buf) const; + + /// Get the MQTT command topic into a buffer (no heap allocation for non-lambda custom topics). + /// @param buf Buffer of exactly MQTT_DEFAULT_TOPIC_MAX_LEN bytes. + /// @return StringRef pointing to the topic in the buffer. + StringRef get_command_topic_to_(std::span buf) const; + + /// Get the MQTT topic that new states will be shared to (allocates std::string). std::string get_state_topic_() const; - /// Get the MQTT topic for listening to commands. + /// Get the MQTT topic for listening to commands (allocates std::string). std::string get_command_topic_() const; bool is_connected_() const; @@ -221,12 +248,18 @@ class MQTTComponent : public Component { std::unique_ptr availability_; - bool command_retain_{false}; - bool retain_{true}; - uint8_t qos_{0}; - uint8_t subscribe_qos_{0}; - bool discovery_enabled_{true}; - bool resend_state_{false}; + // Packed bitfields - QoS values are 0-2, bools are flags + uint8_t qos_ : 2 {0}; + uint8_t subscribe_qos_ : 2 {0}; + bool command_retain_ : 1 {false}; + bool retain_ : 1 {true}; + bool discovery_enabled_ : 1 {true}; + bool resend_state_ : 1 {false}; + bool is_internal_ : 1 {false}; ///< Cached result of compute_is_internal_(), set during setup + + /// Compute is_internal status based on topics and entity state. + /// Called once during setup to cache the result. + bool compute_is_internal_(); }; } // namespace esphome::mqtt diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index f2df6af236..493514c8fb 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -51,7 +51,7 @@ void MQTTCoverComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT cover '%s':", this->cover_->get_name().c_str()); auto traits = this->cover_->get_traits(); bool has_command_topic = traits.get_supports_position() || !traits.get_supports_tilt(); - LOG_MQTT_COMPONENT(true, has_command_topic) + LOG_MQTT_COMPONENT(true, has_command_topic); if (traits.get_supports_position()) { ESP_LOGCONFIG(TAG, " Position State Topic: '%s'\n" diff --git a/esphome/components/mqtt/mqtt_date.cpp b/esphome/components/mqtt/mqtt_date.cpp index dba7c1a671..cbe4045486 100644 --- a/esphome/components/mqtt/mqtt_date.cpp +++ b/esphome/components/mqtt/mqtt_date.cpp @@ -36,7 +36,7 @@ void MQTTDateComponent::setup() { void MQTTDateComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Date '%s':", this->date_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); } MQTT_COMPONENT_TYPE(MQTTDateComponent, "date") diff --git a/esphome/components/mqtt/mqtt_datetime.cpp b/esphome/components/mqtt/mqtt_datetime.cpp index 5f1cf19b97..f7b4ef0685 100644 --- a/esphome/components/mqtt/mqtt_datetime.cpp +++ b/esphome/components/mqtt/mqtt_datetime.cpp @@ -47,7 +47,7 @@ void MQTTDateTimeComponent::setup() { void MQTTDateTimeComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT DateTime '%s':", this->datetime_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); } MQTT_COMPONENT_TYPE(MQTTDateTimeComponent, "datetime") diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index a6f0503588..0909090023 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -175,7 +175,7 @@ bool MQTTFanComponent::publish_state() { auto traits = this->state_->get_traits(); if (traits.supports_speed()) { char buf[12]; - int len = snprintf(buf, sizeof(buf), "%d", this->state_->speed); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "%d", this->state_->speed); bool success = this->publish(this->get_speed_level_state_topic(), buf, len); failed = failed || !success; } diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index fac19f3210..e43cb63f4f 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -90,7 +90,7 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery bool MQTTJSONLightComponent::send_initial_state() { return this->publish_state_(); } void MQTTJSONLightComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Light '%s':", this->state_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); } } // namespace esphome::mqtt diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index 8342210ee4..a014096c5f 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -30,7 +30,7 @@ void MQTTNumberComponent::setup() { void MQTTNumberComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Number '%s':", this->number_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, false) + LOG_MQTT_COMPONENT(true, false); } MQTT_COMPONENT_TYPE(MQTTNumberComponent, "number") @@ -75,7 +75,7 @@ bool MQTTNumberComponent::send_initial_state() { } bool MQTTNumberComponent::publish_state(float value) { char buffer[64]; - snprintf(buffer, sizeof(buffer), "%f", value); + buf_append_printf(buffer, sizeof(buffer), 0, "%f", value); return this->publish(this->get_state_topic_(), buffer); } diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index 03ab82312b..2d830998ec 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -25,7 +25,7 @@ void MQTTSelectComponent::setup() { void MQTTSelectComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Select '%s':", this->select_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, false) + LOG_MQTT_COMPONENT(true, false); } MQTT_COMPONENT_TYPE(MQTTSelectComponent, "select") diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index c14c889d47..f136b82355 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -28,7 +28,7 @@ void MQTTSensorComponent::dump_config() { if (this->get_expire_after() > 0) { ESP_LOGCONFIG(TAG, " Expire After: %" PRIu32 "s", this->get_expire_after() / 1000); } - LOG_MQTT_COMPONENT(true, false) + LOG_MQTT_COMPONENT(true, false); } MQTT_COMPONENT_TYPE(MQTTSensorComponent, "sensor") diff --git a/esphome/components/mqtt/mqtt_text.cpp b/esphome/components/mqtt/mqtt_text.cpp index cee94965c6..fed9224b42 100644 --- a/esphome/components/mqtt/mqtt_text.cpp +++ b/esphome/components/mqtt/mqtt_text.cpp @@ -26,7 +26,7 @@ void MQTTTextComponent::setup() { void MQTTTextComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT text '%s':", this->text_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); } MQTT_COMPONENT_TYPE(MQTTTextComponent, "text") diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp index b75325022a..8749c3b59e 100644 --- a/esphome/components/mqtt/mqtt_time.cpp +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -36,7 +36,7 @@ void MQTTTimeComponent::setup() { void MQTTTimeComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Time '%s':", this->time_->get_name().c_str()); - LOG_MQTT_COMPONENT(true, true) + LOG_MQTT_COMPONENT(true, true); } MQTT_COMPONENT_TYPE(MQTTTimeComponent, "time") diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index 2faaace46b..8e66a69c6f 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -39,7 +39,7 @@ void MQTTValveComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT valve '%s':", this->valve_->get_name().c_str()); auto traits = this->valve_->get_traits(); bool has_command_topic = traits.get_supports_position(); - LOG_MQTT_COMPONENT(true, has_command_topic) + LOG_MQTT_COMPONENT(true, has_command_topic); if (traits.get_supports_position()) { ESP_LOGCONFIG(TAG, " Position State Topic: '%s'\n" diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 392481e39a..86551cbe23 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -16,6 +16,7 @@ CONF_EXIT_REPARSE_ON_START = "exit_reparse_on_start" CONF_FONT_ID = "font_id" CONF_FOREGROUND_PRESSED_COLOR = "foreground_pressed_color" CONF_MAX_COMMANDS_PER_LOOP = "max_commands_per_loop" +CONF_MAX_QUEUE_AGE = "max_queue_age" CONF_MAX_QUEUE_SIZE = "max_queue_size" CONF_ON_BUFFER_OVERFLOW = "on_buffer_overflow" CONF_ON_PAGE = "on_page" @@ -25,6 +26,7 @@ CONF_ON_WAKE = "on_wake" CONF_PRECISION = "precision" CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake" CONF_START_UP_PAGE = "start_up_page" +CONF_STARTUP_OVERRIDE_MS = "startup_override_ms" CONF_TFT_URL = "tft_url" CONF_TOUCH_SLEEP_TIMEOUT = "touch_sleep_timeout" CONF_VARIABLE_NAME = "variable_name" diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 0b4ba3a171..ffc509fc64 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -23,6 +23,7 @@ from .base_component import ( CONF_DUMP_DEVICE_INFO, CONF_EXIT_REPARSE_ON_START, CONF_MAX_COMMANDS_PER_LOOP, + CONF_MAX_QUEUE_AGE, CONF_MAX_QUEUE_SIZE, CONF_ON_BUFFER_OVERFLOW, CONF_ON_PAGE, @@ -31,6 +32,7 @@ from .base_component import ( CONF_ON_WAKE, CONF_SKIP_CONNECTION_HANDSHAKE, CONF_START_UP_PAGE, + CONF_STARTUP_OVERRIDE_MS, CONF_TFT_URL, CONF_TOUCH_SLEEP_TIMEOUT, CONF_WAKE_UP_PAGE, @@ -65,6 +67,12 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean, cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, + cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=TimePeriod(milliseconds=0), max=TimePeriod(milliseconds=65535) + ), + ), cv.Optional(CONF_MAX_COMMANDS_PER_LOOP): cv.uint16_t, cv.Optional(CONF_MAX_QUEUE_SIZE): cv.positive_int, cv.Optional(CONF_ON_BUFFER_OVERFLOW): automation.validate_automation( @@ -100,6 +108,12 @@ CONFIG_SCHEMA = ( } ), cv.Optional(CONF_SKIP_CONNECTION_HANDSHAKE, default=False): cv.boolean, + cv.Optional(CONF_STARTUP_OVERRIDE_MS, default="8000ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=TimePeriod(milliseconds=0), max=TimePeriod(milliseconds=65535) + ), + ), cv.Optional(CONF_START_UP_PAGE): cv.uint8_t, cv.Optional(CONF_TFT_URL): cv.url, cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( @@ -138,6 +152,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await uart.register_uart_device(var, config) + cg.add(var.set_max_queue_age(config[CONF_MAX_QUEUE_AGE])) + if max_queue_size := config.get(CONF_MAX_QUEUE_SIZE): cg.add_define("USE_NEXTION_MAX_QUEUE_SIZE") cg.add(var.set_max_queue_size(max_queue_size)) @@ -146,6 +162,8 @@ async def to_code(config): cg.add_define("USE_NEXTION_COMMAND_SPACING") cg.add(var.set_command_spacing(command_spacing.total_milliseconds)) + cg.add(var.set_startup_override_ms(config[CONF_STARTUP_OVERRIDE_MS])) + if CONF_BRIGHTNESS in config: cg.add(var.set_brightness(config[CONF_BRIGHTNESS])) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 354288e1a3..4bba33f961 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -152,21 +152,25 @@ void Nextion::dump_config() { #else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE ESP_LOGCONFIG(TAG, #ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - " Device Model: %s\n" - " FW Version: %s\n" - " Serial Number: %s\n" - " Flash Size: %s\n" + " Device Model: %s\n" + " FW Version: %s\n" + " Serial Number: %s\n" + " Flash Size: %s\n" + " Max queue age: %u ms\n" + " Startup override: %u ms\n", #endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO #ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - " Exit reparse: YES\n" + " Exit reparse: YES\n" #endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START - " Wake On Touch: %s\n" - " Touch Timeout: %" PRIu16, + " Wake On Touch: %s\n" + " Touch Timeout: %" PRIu16, #ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(), - this->flash_size_.c_str(), + this->flash_size_.c_str(), this->max_q_age_ms_, + this->startup_override_ms_ #endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO - YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_); + YESNO(this->connection_state_.auto_wake_on_touch_), + this->touch_sleep_timeout_); #endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP @@ -174,21 +178,21 @@ void Nextion::dump_config() { #endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP if (this->wake_up_page_ != 255) { - ESP_LOGCONFIG(TAG, " Wake Up Page: %u", this->wake_up_page_); + ESP_LOGCONFIG(TAG, " Wake Up Page: %u", this->wake_up_page_); } #ifdef USE_NEXTION_CONF_START_UP_PAGE if (this->start_up_page_ != 255) { - ESP_LOGCONFIG(TAG, " Start Up Page: %u", this->start_up_page_); + ESP_LOGCONFIG(TAG, " Start Up Page: %u", this->start_up_page_); } #endif // USE_NEXTION_CONF_START_UP_PAGE #ifdef USE_NEXTION_COMMAND_SPACING - ESP_LOGCONFIG(TAG, " Cmd spacing: %u ms", this->command_pacer_.get_spacing()); + ESP_LOGCONFIG(TAG, " Cmd spacing: %u ms", this->command_pacer_.get_spacing()); #endif // USE_NEXTION_COMMAND_SPACING #ifdef USE_NEXTION_MAX_QUEUE_SIZE - ESP_LOGCONFIG(TAG, " Max queue size: %zu", this->max_queue_size_); + ESP_LOGCONFIG(TAG, " Max queue size: %zu", this->max_queue_size_); #endif } @@ -336,7 +340,8 @@ void Nextion::loop() { if (this->started_ms_ == 0) this->started_ms_ = App.get_loop_component_start_time(); - if (this->started_ms_ + this->startup_override_ms_ < App.get_loop_component_start_time()) { + if (this->startup_override_ms_ > 0 && + this->started_ms_ + this->startup_override_ms_ < App.get_loop_component_start_time()) { ESP_LOGV(TAG, "Manual ready set"); this->connection_state_.nextion_reports_is_setup_ = true; } @@ -845,7 +850,8 @@ void Nextion::process_nextion_commands_() { const uint32_t ms = App.get_loop_component_start_time(); - if (!this->nextion_queue_.empty() && this->nextion_queue_.front()->queue_time + this->max_q_age_ms_ < ms) { + if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && + this->nextion_queue_.front()->queue_time + this->max_q_age_ms_ < ms) { for (size_t i = 0; i < this->nextion_queue_.size(); i++) { NextionComponentBase *component = this->nextion_queue_[i]->component; if (this->nextion_queue_[i]->queue_time + this->max_q_age_ms_ < ms) { diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 331e901578..c543e14bfe 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1309,6 +1309,30 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ bool is_connected() { return this->connection_state_.is_connected_; } + /** + * @brief Set the maximum age for queue items + * @param age_ms Maximum age in milliseconds before queue items are removed + */ + inline void set_max_queue_age(uint16_t age_ms) { this->max_q_age_ms_ = age_ms; } + + /** + * @brief Get the maximum age for queue items + * @return Maximum age in milliseconds + */ + inline uint16_t get_max_queue_age() const { return this->max_q_age_ms_; } + + /** + * @brief Set the startup override timeout + * @param timeout_ms Time in milliseconds to wait before forcing setup complete + */ + inline void set_startup_override_ms(uint16_t timeout_ms) { this->startup_override_ms_ = timeout_ms; } + + /** + * @brief Get the startup override timeout + * @return Startup override timeout in milliseconds + */ + inline uint16_t get_startup_override_ms() const { return this->startup_override_ms_; } + protected: #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP uint16_t max_commands_per_loop_{1000}; @@ -1479,9 +1503,10 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void reset_(bool reset_nextion = true); std::string command_data_; - const uint16_t startup_override_ms_ = 8000; - const uint16_t max_q_age_ms_ = 8000; uint32_t started_ms_ = 0; + + uint16_t startup_override_ms_ = 8000; ///< Timeout before forcing setup complete + uint16_t max_q_age_ms_ = 8000; ///< Maximum age for queue items in ms }; } // namespace nextion diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 2c24b133da..9d993c2105 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -44,7 +44,7 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index e4440c9b81..48d1f6d12e 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -161,7 +161,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) chip = DriverChip.chips[config[CONF_MODEL]] if chip.initsequence: 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/spi/__init__.py b/esphome/components/spi/__init__.py index e890567abf..931882be8d 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( ) from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core", "@clydebarrow"] spi_ns = cg.esphome_ns.namespace("spi") @@ -448,9 +449,13 @@ def spi_device_schema( ) -async def register_spi_device(var, config): +async def register_spi_device( + var: cg.Pvariable, config: ConfigType, write_only: bool = False +) -> None: parent = await cg.get_variable(config[CONF_SPI_ID]) cg.add(var.set_spi_parent(parent)) + if write_only: + cg.add(var.set_write_only(True)) if cs_pin := config.get(CONF_CS_PIN): pin = await cg.gpio_pin_expression(cs_pin) cg.add(var.set_cs_pin(pin)) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index a1837fa58d..107b6a3f1a 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -195,8 +195,11 @@ class SPIDelegateHw : public SPIDelegate { config.post_cb = nullptr; if (this->bit_order_ == BIT_ORDER_LSB_FIRST) config.flags |= SPI_DEVICE_BIT_LSBFIRST; - if (this->write_only_) + if (this->write_only_) { config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY; + ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)", + Utility::get_pin_no(this->cs_pin_)); + } esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Add device failed - err %X", err); diff --git a/esphome/components/ssd1306_spi/display.py b/esphome/components/ssd1306_spi/display.py index 4af41073d4..26953b4f39 100644 --- a/esphome/components/ssd1306_spi/display.py +++ b/esphome/components/ssd1306_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1306_base.setup_ssd1306(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/ssd1322_spi/display.py b/esphome/components/ssd1322_spi/display.py index 849e71abee..3d01caf874 100644 --- a/esphome/components/ssd1322_spi/display.py +++ b/esphome/components/ssd1322_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1322_base.setup_ssd1322(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/ssd1325_spi/display.py b/esphome/components/ssd1325_spi/display.py index e18db33c68..dbb9a14ac2 100644 --- a/esphome/components/ssd1325_spi/display.py +++ b/esphome/components/ssd1325_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1325_base.setup_ssd1325(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/ssd1327_spi/display.py b/esphome/components/ssd1327_spi/display.py index b622c098ec..f052764a91 100644 --- a/esphome/components/ssd1327_spi/display.py +++ b/esphome/components/ssd1327_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1327_base.setup_ssd1327(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/ssd1331_spi/display.py b/esphome/components/ssd1331_spi/display.py index 50895b3175..c16780302f 100644 --- a/esphome/components/ssd1331_spi/display.py +++ b/esphome/components/ssd1331_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1331_base.setup_ssd1331(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/ssd1351_spi/display.py b/esphome/components/ssd1351_spi/display.py index bd7033c3d4..2a6e984029 100644 --- a/esphome/components/ssd1351_spi/display.py +++ b/esphome/components/ssd1351_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await ssd1351_base.setup_ssd1351(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/st7567_spi/display.py b/esphome/components/st7567_spi/display.py index 305aa35024..02cd2c105c 100644 --- a/esphome/components/st7567_spi/display.py +++ b/esphome/components/st7567_spi/display.py @@ -32,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await st7567_base.setup_st7567(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 3078158d25..a8b12dfa28 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -173,7 +173,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) sequence = [] for seq in config[CONF_INIT_SEQUENCE]: diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 2761214315..9dc69f27ff 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -99,7 +99,7 @@ async def to_code(config): config[CONF_INVERT_COLORS], ) await setup_st7735(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index 8259eacf2d..c9f4199616 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -177,7 +177,7 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) cg.add(var.set_model_str(config[CONF_MODEL])) diff --git a/esphome/components/st7920/display.py b/esphome/components/st7920/display.py index de7b2247dd..ef33fac6c6 100644 --- a/esphome/components/st7920/display.py +++ b/esphome/components/st7920/display.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda( diff --git a/esphome/components/waveshare_epaper/display.py b/esphome/components/waveshare_epaper/display.py index cea0b2be5e..5db7a1fc3d 100644 --- a/esphome/components/waveshare_epaper/display.py +++ b/esphome/components/waveshare_epaper/display.py @@ -239,7 +239,7 @@ async def to_code(config): raise NotImplementedError() await display.register_display(var, config) - await spi.register_spi_device(var, config) + await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) cg.add(var.set_dc_pin(dc)) 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/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 162ed4e835..cc9f4ec193 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -460,13 +460,15 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { listener->on_wifi_connect_state(StringRef(it.ssid, it.ssid_len), it.bssid); } #endif - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) + // For static IP configurations, GOT_IP event may not fire, so set connected state here +#ifdef USE_WIFI_MANUAL_IP if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { s_sta_state = LTWiFiSTAState::CONNECTED; +#ifdef USE_WIFI_IP_STATE_LISTENERS for (auto *listener : this->ip_state_listeners_) { listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); } +#endif } #endif break; 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/automation.h b/esphome/core/automation.h index eac469d0fc..31a2fc06f4 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include #include #include @@ -190,15 +191,55 @@ template class TemplatableValue { /// Get the static string pointer (only valid if is_static_string() returns true) const char *get_static_string() const { return this->static_str_; } - protected: - enum : uint8_t { - NONE, - VALUE, - LAMBDA, - STATELESS_LAMBDA, - STATIC_STRING, // For const char* when T is std::string - avoids heap allocation - } type_; + /// Check if the string value is empty without allocating (for std::string specialization). + /// For NONE, returns true. For STATIC_STRING/VALUE, checks without allocation. + /// For LAMBDA/STATELESS_LAMBDA, must call value() which may allocate. + bool is_empty() const requires std::same_as { + switch (this->type_) { + case NONE: + return true; + case STATIC_STRING: + return this->static_str_ == nullptr || this->static_str_[0] == '\0'; + case VALUE: + return this->value_->empty(); + default: // LAMBDA/STATELESS_LAMBDA - must call value() + return this->value().empty(); + } + } + /// Get a StringRef to the string value without heap allocation when possible. + /// For STATIC_STRING/VALUE, returns reference to existing data (no allocation). + /// For LAMBDA/STATELESS_LAMBDA, calls value(), copies to provided buffer, returns ref to buffer. + /// @param lambda_buf Buffer used only for lambda case (must remain valid while StringRef is used). + /// @param lambda_buf_size Size of the buffer. + /// @return StringRef pointing to the string data. + StringRef ref_or_copy_to(char *lambda_buf, size_t lambda_buf_size) const requires std::same_as { + switch (this->type_) { + case NONE: + return StringRef(); + case STATIC_STRING: + if (this->static_str_ == nullptr) + return StringRef(); + return StringRef(this->static_str_, strlen(this->static_str_)); + case VALUE: + return StringRef(this->value_->data(), this->value_->size()); + default: { // LAMBDA/STATELESS_LAMBDA - must call value() and copy + std::string result = this->value(); + size_t copy_len = std::min(result.size(), lambda_buf_size - 1); + memcpy(lambda_buf, result.data(), copy_len); + lambda_buf[copy_len] = '\0'; + return StringRef(lambda_buf, copy_len); + } + } + } + + protected : enum : uint8_t { + NONE, + VALUE, + LAMBDA, + STATELESS_LAMBDA, + STATIC_STRING, // For const char* when T is std::string - avoids heap allocation + } type_; // For std::string, use heap pointer to minimize union size (4 bytes vs 12+). // For other types, store value inline as before. using ValueStorage = std::conditional_t; 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/defines.h b/esphome/core/defines.h index c229d1df7d..7c13823fba 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -234,7 +234,7 @@ #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 5) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 6) #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_MANUAL_IP 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..e9a588e4fd 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 @@ -133,9 +133,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.36/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.5/esp32-3.3.5.zip + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.6/esp32-core-3.3.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -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 = @@ -169,7 +168,7 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.36/platform-espressif32.zip platform_packages = pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.2/esp-idf-v5.5.2.tar.xz @@ -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 8e08ba4095..452aa062b5 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_re_check( # Match std::to_string() or unqualified to_string() calls # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string 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/nextion/common.yaml b/tests/components/nextion/common.yaml index f5ee12a51c..48a7292f43 100644 --- a/tests/components/nextion/common.yaml +++ b/tests/components/nextion/common.yaml @@ -277,6 +277,8 @@ display: command_spacing: 5ms max_commands_per_loop: 20 max_queue_size: 50 + startup_override_ms: 10000ms # Wait 10s for display ready + max_queue_age: 5000ms # Remove queue items after 5s on_sleep: then: lambda: 'ESP_LOGD("display","Display went to sleep");' 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']}" + )