diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index f47389fd05a..53f8604b7de 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -25,11 +25,13 @@ class AddressableLightDisplay : public display::DisplayBuffer { if (enabled_ && !enabled) { // enabled -> disabled // - Tell the parent light to refresh, effectively wiping the display. Also // restores the previous effect (if any). - light_state_->make_call().set_effect(this->last_effect_).perform(); + if (this->last_effect_index_.has_value()) { + light_state_->make_call().set_effect(*this->last_effect_index_).perform(); + } } else if (!enabled_ && enabled) { // disabled -> enabled - // - Save the current effect. - this->last_effect_ = light_state_->get_effect_name(); + // - Save the current effect index. + this->last_effect_index_ = light_state_->get_current_effect_index(); // - Disable any current effect. light_state_->make_call().set_effect(0).perform(); } @@ -56,7 +58,7 @@ class AddressableLightDisplay : public display::DisplayBuffer { int32_t width_; int32_t height_; std::vector addressable_light_buffer_; - optional last_effect_; + optional last_effect_index_; optional> pixel_mapper_f_; }; } // namespace addressable_light diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 43b721c2d53..5efd8396735 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2425,7 +2425,7 @@ message ZWaveProxyFrame { option (ifdef) = "USE_ZWAVE_PROXY"; option (no_delay) = true; - bytes data = 1 [(pointer_to_buffer) = true]; + bytes data = 1; } enum ZWaveProxyRequestType { @@ -2439,5 +2439,5 @@ message ZWaveProxyRequest { option (ifdef) = "USE_ZWAVE_PROXY"; ZWaveProxyRequestType type = 1; - bytes data = 2 [(pointer_to_buffer) = true]; + bytes data = 2; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 8588651968e..2ecd54bb00a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1530,7 +1530,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 13; + resp.api_version_minor = 14; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 47609f79b68..59c42aa0330 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -24,9 +24,10 @@ struct ClientInfo { // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending -// This was increased from 20 to 24 after removing the unique_id field from entity info messages, -// which reduced message sizes allowing more entities per batch without exceeding packet limits -static constexpr size_t MAX_INITIAL_PER_BATCH = 24; +// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch +// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then +static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id) +static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id) // Maximum number of packets to process in a single batch (platform-dependent) // This limit exists to prevent stack overflow from the PacketInfo array in process_batch_ // Each PacketInfo is 8 bytes, so 64 * 8 = 512 bytes, 32 * 8 = 256 bytes @@ -323,10 +324,16 @@ class APIConnection final : public APIServerConnection { APIConnection *conn, uint32_t remaining_size, bool is_single) { // Set common fields that are shared by all entity types msg.key = entity->get_object_id_hash(); - // Get object_id with zero heap allocation - // Static case returns direct reference, dynamic case uses buffer + + // API 1.14+ clients compute object_id client-side from the entity name + // For older clients, we must send object_id for backward compatibility + // See: https://github.com/esphome/backlog/issues/76 + // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then + // Buffer must remain in scope until encode_message_to_buffer is called char object_id_buf[OBJECT_ID_MAX_LEN]; - msg.set_object_id(entity->get_object_id_to(object_id_buf)); + if (!conn->client_supports_api_version(1, 14)) { + msg.set_object_id(entity->get_object_id_to(object_id_buf)); + } if (entity->has_own_name()) { msg.set_name(entity->get_name()); @@ -349,16 +356,24 @@ class APIConnection final : public APIServerConnection { inline bool check_voice_assistant_api_connection_() const; #endif + // Get the max batch size based on client API version + // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch + // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly + size_t get_max_batch_size_() const { + return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY; + } + // Helper method to process multiple entities from an iterator in a batch template void process_iterator_batch_(Iterator &iterator) { size_t initial_size = this->deferred_batch_.size(); - while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < MAX_INITIAL_PER_BATCH) { + size_t max_batch = this->get_max_batch_size_(); + while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { iterator.advance(); } // If the batch is full, process it immediately // Note: iterator.advance() already calls schedule_batch_() via schedule_message_() - if (this->deferred_batch_.size() >= MAX_INITIAL_PER_BATCH) { + if (this->deferred_batch_.size() >= max_batch) { this->process_batch_(); } } diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 20f8fcaf613..420f42a90a0 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -13,12 +13,26 @@ namespace esphome::api { static const char *const TAG = "api.frame_helper"; +// Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) +static constexpr size_t API_MAX_LOG_BYTES = 168; + #define HELPER_LOG(msg, ...) \ ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS -#define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) -#define LOG_PACKET_SENDING(data, len) ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(data, len).c_str()) +#define LOG_PACKET_RECEIVED(buffer) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Received frame: %s", \ + format_hex_pretty_to(hex_buf_, (buffer).data(), \ + (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ + } while (0) +#define LOG_PACKET_SENDING(data, len) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Sending raw: %s", \ + format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ + } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #define LOG_PACKET_SENDING(data, len) ((void) 0) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 1d6f32ee9df..37b497e2a13 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -24,12 +24,26 @@ static const char *const PROLOGUE_INIT = "NoiseAPIInit"; #endif static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") +// Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) +static constexpr size_t API_MAX_LOG_BYTES = 168; + #define HELPER_LOG(msg, ...) \ ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS -#define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) -#define LOG_PACKET_SENDING(data, len) ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(data, len).c_str()) +#define LOG_PACKET_RECEIVED(buffer) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Received frame: %s", \ + format_hex_pretty_to(hex_buf_, (buffer).data(), \ + (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ + } while (0) +#define LOG_PACKET_SENDING(data, len) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Sending raw: %s", \ + format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ + } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #define LOG_PACKET_SENDING(data, len) ((void) 0) diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index b5d90b24291..8b7d002d7c7 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -18,12 +18,26 @@ namespace esphome::api { static const char *const TAG = "api.plaintext"; +// Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) +static constexpr size_t API_MAX_LOG_BYTES = 168; + #define HELPER_LOG(msg, ...) \ ESP_LOGVV(TAG, "%s (%s): " msg, this->client_info_->name.c_str(), this->client_info_->peername.c_str(), ##__VA_ARGS__) #ifdef HELPER_LOG_PACKETS -#define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) -#define LOG_PACKET_SENDING(data, len) ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(data, len).c_str()) +#define LOG_PACKET_RECEIVED(buffer) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Received frame: %s", \ + format_hex_pretty_to(hex_buf_, (buffer).data(), \ + (buffer).size() < API_MAX_LOG_BYTES ? (buffer).size() : API_MAX_LOG_BYTES)); \ + } while (0) +#define LOG_PACKET_SENDING(data, len) \ + do { \ + char hex_buf_[format_hex_pretty_size(API_MAX_LOG_BYTES)]; \ + ESP_LOGVV(TAG, "Sending raw: %s", \ + format_hex_pretty_to(hex_buf_, data, (len) < API_MAX_LOG_BYTES ? (len) : API_MAX_LOG_BYTES)); \ + } while (0) #else #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #define LOG_PACKET_SENDING(data, len) ((void) 0) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 6275b4c211f..e08cf65bb96 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1046,7 +1046,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { class SubscribeLogsResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 29; - static constexpr uint8_t ESTIMATED_SIZE = 11; + static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "subscribe_logs_response"; } #endif @@ -1069,7 +1069,7 @@ class SubscribeLogsResponse final : public ProtoMessage { class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 124; - static constexpr uint8_t ESTIMATED_SIZE = 9; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "noise_encryption_set_key_request"; } #endif @@ -1161,7 +1161,7 @@ class HomeassistantActionRequest final : public ProtoMessage { class HomeassistantActionResponse final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 130; - static constexpr uint8_t ESTIMATED_SIZE = 24; + static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "homeassistant_action_response"; } #endif @@ -1388,7 +1388,7 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { class CameraImageResponse final : public StateResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 44; - static constexpr uint8_t ESTIMATED_SIZE = 20; + static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "camera_image_response"; } #endif @@ -2123,7 +2123,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { class BluetoothGATTReadResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 74; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_read_response"; } #endif @@ -2146,7 +2146,7 @@ class BluetoothGATTReadResponse final : public ProtoMessage { class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 75; - static constexpr uint8_t ESTIMATED_SIZE = 19; + static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_request"; } #endif @@ -2182,7 +2182,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 77; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_write_descriptor_request"; } #endif @@ -2218,7 +2218,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 79; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "bluetooth_gatt_notify_data_response"; } #endif diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 9a4e342525b..f47b67da6fa 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -128,7 +128,9 @@ void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this-> bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } void CH422GGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -std::string CH422GGPIOPin::dump_summary() const { return str_sprintf("EXIO%u via CH422G", pin_); } +size_t CH422GGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "EXIO%u via CH422G", this->pin_); +} void CH422GGPIOPin::set_flags(gpio::Flags flags) { flags_ = flags; this->parent_->pin_mode(this->pin_, flags); diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index 1193a3db270..8ed63db90ae 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -50,7 +50,7 @@ class CH422GGPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(CH422GComponent *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index a98245b889e..4b53d3a1721 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -97,10 +97,8 @@ void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpi gpio_isr_handler_add(this->get_pin_num(), func, arg); } -std::string ESP32InternalGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%" PRIu32, static_cast(this->pin_)); - return buffer; +size_t ESP32InternalGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "GPIO%" PRIu32, static_cast(this->pin_)); } void ESP32InternalGPIOPin::setup() { diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index d30f4bdcbad..3c13bd9b4ff 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -24,7 +24,7 @@ class ESP32InternalGPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return this->pin_; } diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index 2e69d400ca0..ad2f057163b 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor, esp32_ble, improv_base, output from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID AUTO_LOAD = ["esp32_ble_server", "improv_base"] CODEOWNERS = ["@jesserockz"] @@ -15,7 +15,6 @@ CONF_BLE_SERVER_ID = "ble_server_id" CONF_IDENTIFY_DURATION = "identify_duration" CONF_ON_PROVISIONED = "on_provisioned" CONF_ON_PROVISIONING = "on_provisioning" -CONF_ON_START = "on_start" CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index c4969a79b2e..77ccaf52c1f 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -28,6 +28,7 @@ from .const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_PIN_INITIAL_STATES, + KEY_WAVEFORM_REQUIRED, esp8266_ns, ) from .gpio import PinInitialState, add_pin_initial_states_array @@ -192,7 +193,12 @@ async def to_code(config): cg.add_platformio_option( "extra_scripts", - ["pre:testing_mode.py", "pre:exclude_updater.py", "post:post_build.py"], + [ + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + "post:post_build.py", + ], ) conf = config[CONF_FRAMEWORK] @@ -264,10 +270,24 @@ async def to_code(config): cg.add_platformio_option("board_build.ldscript", ld_script) CORE.add_job(add_pin_initial_states_array) + CORE.add_job(finalize_waveform_config) + + +@coroutine_with_priority(CoroPriority.WORKAROUNDS) +async def finalize_waveform_config() -> None: + """Add waveform stubs define if waveform is not required. + + This runs at WORKAROUNDS priority (-999) to ensure all components + have had a chance to call require_waveform() first. + """ + if not CORE.data.get(KEY_ESP8266, {}).get(KEY_WAVEFORM_REQUIRED, False): + # No component needs waveform - enable stubs and exclude Arduino waveform code + # Use build flag (visible to both C++ code and PlatformIO script) + cg.add_build_flag("-DUSE_ESP8266_WAVEFORM_STUBS") # Called by writer.py -def copy_files(): +def copy_files() -> None: dir = Path(__file__).parent post_build_file = dir / "post_build.py.script" copy_file_if_changed( @@ -284,3 +304,8 @@ def copy_files(): exclude_updater_file, CORE.relative_build_path("exclude_updater.py"), ) + exclude_waveform_file = dir / "exclude_waveform.py.script" + copy_file_if_changed( + exclude_waveform_file, + CORE.relative_build_path("exclude_waveform.py"), + ) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index b718306b01e..14425cde68e 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.core import CORE KEY_ESP8266 = "esp8266" KEY_BOARD = "board" @@ -6,6 +7,25 @@ KEY_PIN_INITIAL_STATES = "pin_initial_states" CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" KEY_FLASH_SIZE = "flash_size" +KEY_WAVEFORM_REQUIRED = "waveform_required" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") + + +def require_waveform() -> None: + """Mark that Arduino waveform/PWM support is required. + + Call this from components that need the Arduino waveform generator + (startWaveform, stopWaveform, analogWrite, Tone, Servo). + + If no component calls this, the waveform code is excluded from the build + to save ~596 bytes of RAM and 464 bytes of flash. + + Example: + from esphome.components.esp8266.const import require_waveform + + async def to_code(config): + require_waveform() + """ + CORE.data.setdefault(KEY_ESP8266, {})[KEY_WAVEFORM_REQUIRED] = True diff --git a/esphome/components/esp8266/exclude_waveform.py.script b/esphome/components/esp8266/exclude_waveform.py.script new file mode 100644 index 00000000000..35d6bc31f61 --- /dev/null +++ b/esphome/components/esp8266/exclude_waveform.py.script @@ -0,0 +1,50 @@ +# pylint: disable=E0602 +Import("env") # noqa + +import os + +# Filter out waveform/PWM code from the Arduino core build +# This saves ~596 bytes of RAM and 464 bytes of flash by not +# instantiating the waveform generator state structures (wvfState + pwmState). +# +# The waveform code is used by: analogWrite, Tone, Servo, and direct +# startWaveform/stopWaveform calls. ESPHome's esp8266_pwm component +# calls require_waveform() to keep this code when needed. +# +# When excluded, we provide stub implementations of stopWaveform() and +# _stopPWM() since digitalWrite() calls these unconditionally. + + +def has_define_flag(env, name): + """Check if a define exists in the build flags.""" + define_flag = f"-D{name}" + # Check BUILD_FLAGS (where ESPHome puts its defines) + for flag in env.get("BUILD_FLAGS", []): + if flag == define_flag or flag.startswith(f"{define_flag}="): + return True + # Also check CPPDEFINES list (parsed defines) + for define in env.get("CPPDEFINES", []): + if isinstance(define, tuple): + if define[0] == name: + return True + elif define == name: + return True + return False + +# USE_ESP8266_WAVEFORM_STUBS is defined when no component needs waveform +if has_define_flag(env, "USE_ESP8266_WAVEFORM_STUBS"): + + def filter_waveform_from_core(env, node): + """Filter callback to exclude waveform files from framework build.""" + path = node.get_path() + filename = os.path.basename(path) + if filename in ( + "core_esp8266_waveform_pwm.cpp", + "core_esp8266_waveform_phase.cpp", + ): + print(f"ESPHome: Excluding {filename} from build (waveform not required)") + return None + return node + + # Apply the filter to framework sources + env.AddBuildMiddleware(filter_waveform_from_core, "**/cores/esp8266/*.cpp") diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index 124df39ce3d..7a5ee08984b 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -98,10 +98,8 @@ void ESP8266GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -std::string ESP8266GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%u", pin_); - return buffer; +size_t ESP8266GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "GPIO%u", this->pin_); } bool ESP8266GPIOPin::digital_read() { diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index 213a5c54bed..ff149abfbe7 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -17,7 +17,7 @@ class ESP8266GPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return pin_; } diff --git a/esphome/components/esp8266/waveform_stubs.cpp b/esphome/components/esp8266/waveform_stubs.cpp new file mode 100644 index 00000000000..686e03c6a90 --- /dev/null +++ b/esphome/components/esp8266/waveform_stubs.cpp @@ -0,0 +1,34 @@ +#ifdef USE_ESP8266_WAVEFORM_STUBS + +// Stub implementations for Arduino waveform/PWM functions. +// +// When the waveform generator is not needed (no esp8266_pwm component), +// we exclude core_esp8266_waveform_pwm.cpp from the build to save ~596 bytes +// of RAM and 464 bytes of flash. +// +// These stubs satisfy calls from the Arduino GPIO code when the real +// waveform implementation is excluded. They must be in the global namespace +// with C linkage to match the Arduino core function declarations. + +#include + +// Empty namespace to satisfy linter - actual stubs must be at global scope +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +extern "C" { + +// Called by Arduino GPIO code to stop any waveform on a pin +int stopWaveform(uint8_t pin) { + (void) pin; + return 1; // Success (no waveform to stop) +} + +// Called by Arduino GPIO code to stop any PWM on a pin +bool _stopPWM(uint8_t pin) { + (void) pin; + return false; // No PWM was running +} + +} // extern "C" + +#endif // USE_ESP8266_WAVEFORM_STUBS diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 2ddf4b90147..a78831c516c 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import output +from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN @@ -34,7 +35,9 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config) -> None: + require_waveform() + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index a872cf70151..2135189bd53 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_SPEED, DEVICE_CLASS_SPEED, STATE_CLASS_MEASUREMENT, + STATE_CLASS_MEASUREMENT_ANGLE, UNIT_DEGREES, UNIT_KILOMETER_PER_HOUR, UNIT_METER, @@ -21,6 +22,7 @@ CONF_HDOP = "hdop" ICON_ALTIMETER = "mdi:altimeter" ICON_COMPASS = "mdi:compass" +ICON_CIRCLE_DOUBLE = "mdi:circle-double" ICON_LATITUDE = "mdi:latitude" ICON_LONGITUDE = "mdi:longitude" ICON_SATELLITE = "mdi:satellite-variant" @@ -50,7 +52,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_DEGREES, icon=ICON_LONGITUDE, accuracy_decimals=6, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_MEASUREMENT_ANGLE, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( unit_of_measurement=UNIT_KILOMETER_PER_HOUR, @@ -63,7 +65,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_DEGREES, icon=ICON_COMPASS, accuracy_decimals=2, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_MEASUREMENT_ANGLE, ), cv.Optional(CONF_ALTITUDE): sensor.sensor_schema( unit_of_measurement=UNIT_METER, @@ -72,11 +74,14 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_SATELLITES): sensor.sensor_schema( + # no unit_of_measurement icon=ICON_SATELLITE, accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_HDOP): sensor.sensor_schema( + # no unit_of_measurement + icon=ICON_CIRCLE_DOUBLE, accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT, ), diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index 73696bd2a53..70a05e4f72f 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -34,9 +34,9 @@ void HLW8012Component::setup() { } void HLW8012Component::dump_config() { ESP_LOGCONFIG(TAG, "HLW8012:"); - LOG_PIN(" SEL Pin: ", this->sel_pin_) - LOG_PIN(" CF Pin: ", this->cf_pin_) - LOG_PIN(" CF1 Pin: ", this->cf1_pin_) + LOG_PIN(" SEL Pin: ", this->sel_pin_); + LOG_PIN(" CF Pin: ", this->cf_pin_); + LOG_PIN(" CF1 Pin: ", this->cf1_pin_); ESP_LOGCONFIG(TAG, " Change measurement mode every %" PRIu32 "\n" " Current resistor: %.1f mΩ\n" diff --git a/esphome/components/host/gpio.cpp b/esphome/components/host/gpio.cpp index e46f158513a..f99b82bcc20 100644 --- a/esphome/components/host/gpio.cpp +++ b/esphome/components/host/gpio.cpp @@ -25,11 +25,7 @@ void HostGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::Interr } void HostGPIOPin::pin_mode(gpio::Flags flags) { ESP_LOGD(TAG, "Setting pin %d mode to %02X", pin_, (uint32_t) flags); } -std::string HostGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%u", pin_); - return buffer; -} +size_t HostGPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%u", this->pin_); } bool HostGPIOPin::digital_read() { return inverted_; } void HostGPIOPin::digital_write(bool value) { diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index ae677291b9e..ea6b13f436e 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -17,7 +17,7 @@ class HostGPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return pin_; } diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index b7d3be63fe8..cde68861098 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -7,6 +7,8 @@ namespace hte501 { static const char *const TAG = "hte501"; +static constexpr size_t HTE501_SERIAL_NUMBER_SIZE = 7; + void HTE501Component::setup() { uint8_t address[] = {0x70, 0x29}; uint8_t identification[9]; @@ -16,7 +18,10 @@ void HTE501Component::setup() { this->mark_failed(); return; } - ESP_LOGV(TAG, " Serial Number: 0x%s", format_hex(identification + 0, 7).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char serial_hex[format_hex_size(HTE501_SERIAL_NUMBER_SIZE)]; +#endif + ESP_LOGV(TAG, " Serial Number: 0x%s", format_hex_to(serial_hex, identification, HTE501_SERIAL_NUMBER_SIZE)); } void HTE501Component::dump_config() { diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 30f98aaa995..1b772d062ca 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -1,4 +1,5 @@ #include "kuntze.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -10,11 +11,17 @@ static const char *const TAG = "kuntze"; static const uint8_t CMD_READ_REG = 0x03; static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; +// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) +static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8; + void Kuntze::on_modbus_data(const std::vector &data) { auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); }; this->waiting_ = false; - ESP_LOGV(TAG, "Data: %s", format_hex_pretty(data).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(KUNTZE_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Data: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); float value = (float) get_16bit(0); for (int i = 0; i < data[3]; i++) diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 7a1e014ea4c..0b14c77cf27 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -63,10 +63,8 @@ void ArduinoInternalGPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags)); // NOLINT } -std::string ArduinoInternalGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u", pin_); - return buffer; +size_t ArduinoInternalGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u", this->pin_); } bool ArduinoInternalGPIOPin::digital_read() { diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 3674748c180..30c7c338697 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -16,7 +16,7 @@ class ArduinoInternalGPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return pin_; } diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index a377a1a192f..13fe5a53230 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -161,10 +161,8 @@ void MAX6956GPIOPin::setup() { pin_mode(flags_); } void MAX6956GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool MAX6956GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void MAX6956GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string MAX6956GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via Max6956", pin_); - return buffer; +size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via Max6956", this->pin_); } } // namespace max6956 diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 0a1fd5e4b59..0c609b0b436 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -76,7 +76,7 @@ class MAX6956GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(MAX6956 *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index be86cb22569..87c26689625 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -99,10 +99,8 @@ void MCP23016GPIOPin::setup() { pin_mode(flags_); } void MCP23016GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool MCP23016GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void MCP23016GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string MCP23016GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via MCP23016", pin_); - return buffer; +size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via MCP23016", this->pin_); } } // namespace mcp23016 diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 781c207de08..c2bc885c958 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -60,7 +60,7 @@ class MCP23016GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(MCP23016 *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index 81324e794fe..302f6b8280b 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -16,8 +16,8 @@ template bool MCP23XXXGPIOPin::digital_read() { template void MCP23XXXGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -template std::string MCP23XXXGPIOPin::dump_summary() const { - return str_snprintf("%u via MCP23XXX", 15, pin_); +template size_t MCP23XXXGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via MCP23XXX", this->pin_); } template class MCP23XXXGPIOPin<8>; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index cf0ef5d41cc..fb992466d5d 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -36,7 +36,7 @@ template class MCP23XXXGPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(MCP23XXXBase *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 5a8a8e7205f..4b358e384cc 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -153,10 +153,8 @@ void MPR121GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_ - 4, value != this->inverted_); } -std::string MPR121GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "ELE%u on MPR121", this->pin_); - return buffer; +size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "ELE%u on MPR121", this->pin_); } } // namespace mpr121 diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 6dd2c383090..085018fff06 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -109,7 +109,7 @@ class MPR121GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(MPR121Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index c0056e780bb..909bac5f054 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -180,10 +180,8 @@ void PCA6416AGPIOPin::setup() { pin_mode(flags_); } void PCA6416AGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCA6416AGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCA6416AGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string PCA6416AGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via PCA6416A", pin_); - return buffer; +size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via PCA6416A", this->pin_); } } // namespace pca6416a diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 10a4a64e9b0..138a51cc208 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -52,7 +52,7 @@ class PCA6416AGPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(PCA6416AComponent *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index e8d49f66e2a..a6f9c2396c0 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -129,10 +129,8 @@ void PCA9554GPIOPin::setup() { pin_mode(flags_); } void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string PCA9554GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via PCA9554", pin_); - return buffer; +size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via PCA9554", this->pin_); } } // namespace pca9554 diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 7b356b40688..bf752e50c99 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -59,7 +59,7 @@ class PCA9554GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(PCA9554Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 72d8865d7fa..15418bfee5f 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -104,10 +104,8 @@ void PCF8574GPIOPin::setup() { pin_mode(flags_); } void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string PCF8574GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via PCF8574", pin_); - return buffer; +size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via PCF8574", this->pin_); } } // namespace pcf8574 diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index fd1ea8af633..5203030142c 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -54,7 +54,7 @@ class PCF8574GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 517ca833e6d..f3a1f013d97 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -164,7 +164,9 @@ bool PI4IOE5V6408GPIOPin::digital_read() { return this->parent_->digital_read(th void PI4IOE5V6408GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string PI4IOE5V6408GPIOPin::dump_summary() const { return str_sprintf("%u via PI4IOE5V6408", this->pin_); } +size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via PI4IOE5V6408", this->pin_); +} } // namespace pi4ioe5v6408 } // namespace esphome diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 82b3076fab8..4dc31201ce4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -52,7 +52,7 @@ class PI4IOE5V6408GPIOPin : public GPIOPin, public Parentedpin_ = pin; } void set_inverted(bool inverted) { this->inverted_ = inverted; } diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2040/gpio.cpp index 3927815e466..2b1699f888a 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2040/gpio.cpp @@ -64,10 +64,8 @@ void RP2040GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -std::string RP2040GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%u", pin_); - return buffer; +size_t RP2040GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "GPIO%u", this->pin_); } bool RP2040GPIOPin::digital_read() { diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2040/gpio.h index 47a6fe17f21..a98e1dab140 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2040/gpio.h @@ -18,7 +18,7 @@ class RP2040GPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return pin_; } diff --git a/esphome/components/sn74hc165/sn74hc165.cpp b/esphome/components/sn74hc165/sn74hc165.cpp index 416d9db293d..718e0b86ed3 100644 --- a/esphome/components/sn74hc165/sn74hc165.cpp +++ b/esphome/components/sn74hc165/sn74hc165.cpp @@ -64,7 +64,9 @@ float SN74HC165Component::get_setup_priority() const { return setup_priority::IO bool SN74HC165GPIOPin::digital_read() { return this->parent_->digital_read_(this->pin_) != this->inverted_; } -std::string SN74HC165GPIOPin::dump_summary() const { return str_snprintf("%u via SN74HC165", 18, pin_); } +size_t SN74HC165GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via SN74HC165", this->pin_); +} } // namespace sn74hc165 } // namespace esphome diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 4684844687a..5a3f3fe8ef0 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -47,7 +47,7 @@ class SN74HC165GPIOPin : public GPIOPin, public Parented { void pin_mode(gpio::Flags flags) override {} bool digital_read() override; void digital_write(bool value) override{}; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_pin(uint16_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/sn74hc595/sn74hc595.cpp b/esphome/components/sn74hc595/sn74hc595.cpp index a9ada432e41..6b5c5d9fc4d 100644 --- a/esphome/components/sn74hc595/sn74hc595.cpp +++ b/esphome/components/sn74hc595/sn74hc595.cpp @@ -93,7 +93,9 @@ float SN74HC595Component::get_setup_priority() const { return setup_priority::IO void SN74HC595GPIOPin::digital_write(bool value) { this->parent_->digital_write_(this->pin_, value != this->inverted_); } -std::string SN74HC595GPIOPin::dump_summary() const { return str_snprintf("%u via SN74HC595", 18, pin_); } +size_t SN74HC595GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via SN74HC595", this->pin_); +} } // namespace sn74hc595 } // namespace esphome diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 181015b1e6d..1cf70c86b50 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -54,7 +54,7 @@ class SN74HC595GPIOPin : public GPIOPin, public Parented { void pin_mode(gpio::Flags flags) override {} bool digital_read() override { return false; } void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_pin(uint16_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index c4876d1a747..36344a6d38d 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -64,9 +64,9 @@ void SPIComponent::setup() { void SPIComponent::dump_config() { ESP_LOGCONFIG(TAG, "SPI bus:"); - LOG_PIN(" CLK Pin: ", this->clk_pin_) - LOG_PIN(" SDI Pin: ", this->sdi_pin_) - LOG_PIN(" SDO Pin: ", this->sdo_pin_) + LOG_PIN(" CLK Pin: ", this->clk_pin_); + LOG_PIN(" SDI Pin: ", this->sdi_pin_); + LOG_PIN(" SDO Pin: ", this->sdo_pin_); for (size_t i = 0; i != this->data_pins_.size(); i++) { ESP_LOGCONFIG(TAG, " Data pin %u: GPIO%d", i, this->data_pins_[i]); } diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index 256cbcc65fd..e237cf44f45 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -120,7 +120,11 @@ class NullPin : public GPIOPin { void digital_write(bool value) override {} - std::string dump_summary() const override { return std::string(); } + size_t dump_summary(char *buffer, size_t len) const override { + if (len > 0) + buffer[0] = '\0'; + return 0; + } protected: static GPIOPin *const NULL_PIN; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp index a74c8b60b8b..41a99eba4ba 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ b/esphome/components/sx1509/sx1509_gpio_pin.cpp @@ -12,10 +12,8 @@ void SX1509GPIOPin::setup() { pin_mode(flags_); } void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool SX1509GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void SX1509GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string SX1509GPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via sx1509", this->pin_); - return buffer; +size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via sx1509", this->pin_); } } // namespace sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index eb9207e8822..5903af9d12a 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -13,7 +13,7 @@ class SX1509GPIOPin : public GPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_parent(SX1509Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index c3449ce2546..376de6a3708 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -138,7 +138,9 @@ void TCA9555GPIOPin::setup() { this->pin_mode(this->flags_); } void TCA9555GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool TCA9555GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void TCA9555GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -std::string TCA9555GPIOPin::dump_summary() const { return str_sprintf("%u via TCA9555", this->pin_); } +size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via TCA9555", this->pin_); +} } // namespace tca9555 } // namespace esphome diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 0c236ae4e3e..9f7273b1e7e 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -48,7 +48,7 @@ class TCA9555GPIOPin : public GPIOPin, public Parented { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->inverted_ = inverted; } diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index d6513dbbe00..06481b628b2 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -7,6 +7,8 @@ namespace tee501 { static const char *const TAG = "tee501"; +static constexpr size_t TEE501_SERIAL_NUMBER_SIZE = 7; + void TEE501Component::setup() { uint8_t address[] = {0x70, 0x29}; uint8_t identification[9]; @@ -17,7 +19,10 @@ void TEE501Component::setup() { this->mark_failed(); return; } - ESP_LOGV(TAG, " Serial Number: 0x%s", format_hex(identification + 0, 7).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char serial_hex[format_hex_size(TEE501_SERIAL_NUMBER_SIZE)]; +#endif + ESP_LOGV(TAG, " Serial Number: 0x%s", format_hex_to(serial_hex, identification, TEE501_SERIAL_NUMBER_SIZE)); } void TEE501Component::dump_config() { diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 221f07c80e5..4c3a4b05dfa 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -8,6 +8,9 @@ namespace uponor_smatrix { static const char *const TAG = "uponor_smatrix"; +// Maximum bytes to log in verbose hex output +static constexpr size_t UPONOR_MAX_LOG_BYTES = 36; + void UponorSmatrixComponent::setup() { #ifdef USE_TIME if (this->time_id_ != nullptr) { @@ -97,8 +100,11 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { return false; } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_size(UPONOR_MAX_LOG_BYTES)]; +#endif ESP_LOGV(TAG, "Received packet: addr=%08X, data=%s, crc=%04X", device_address, - format_hex(&packet[4], packet_len - 6).c_str(), crc); + format_hex_to(hex_buf, &packet[4], packet_len - 6), crc); // Handle packet size_t data_len = (packet_len - 6) / 3; diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index e474dcfe176..b9496a08dec 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -8,6 +8,9 @@ namespace vbus { static const char *const TAG = "vbus"; +// Maximum bytes to log in verbose hex output (16 frames * 4 bytes = 64 bytes typical) +static constexpr size_t VBUS_MAX_LOG_BYTES = 64; + void VBus::dump_config() { ESP_LOGCONFIG(TAG, "VBus:"); check_uart_settings(9600); @@ -101,8 +104,11 @@ void VBus::loop() { this->buffer_.push_back(this->fbytes_[i]); if (++this->cframe_ < this->frames_) continue; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)]; +#endif ESP_LOGV(TAG, "P2 C%04x %04x->%04x: %s", this->command_, this->source_, this->dest_, - format_hex(this->buffer_).c_str()); + format_hex_to(hex_buf, this->buffer_.data(), this->buffer_.size())); for (auto &listener : this->listeners_) listener->on_message(this->command_, this->source_, this->dest_, this->buffer_); this->state_ = 0; diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 59c7ec8383a..d28c786dd80 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_ON_CLIENT_DISCONNECTED, CONF_ON_ERROR, CONF_ON_IDLE, + CONF_ON_START, CONF_SPEAKER, ) @@ -24,7 +25,6 @@ CONF_ON_INTENT_END = "on_intent_end" CONF_ON_INTENT_PROGRESS = "on_intent_progress" CONF_ON_INTENT_START = "on_intent_start" CONF_ON_LISTENING = "on_listening" -CONF_ON_START = "on_start" CONF_ON_STT_END = "on_stt_end" CONF_ON_STT_VAD_END = "on_stt_vad_end" CONF_ON_STT_VAD_START = "on_stt_vad_start" diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index 068cb91d313..b55f3c8d26d 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -177,10 +177,10 @@ uint32_t WaveshareEPaper2P13InV3::idle_timeout_() { return 5000; } void WaveshareEPaper2P13InV3::dump_config() { LOG_DISPLAY("", "Waveshare E-Paper", this) ESP_LOGCONFIG(TAG, " Model: 2.13inV3"); - LOG_PIN(" CS Pin: ", this->cs_) - LOG_PIN(" Reset Pin: ", this->reset_pin_) - LOG_PIN(" DC Pin: ", this->dc_pin_) - LOG_PIN(" Busy Pin: ", this->busy_pin_) + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index ebe987cc65e..3384a0572f1 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -245,10 +245,8 @@ void WeikaiGPIOPin::setup() { this->pin_mode(this->flags_); } -std::string WeikaiGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "%u via WeiKai %s", this->pin_, this->parent_->get_name()); - return buffer; +size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via WeiKai %s", this->pin_, this->parent_->get_name()); } /////////////////////////////////////////////////////////////////////////////// diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 987278213ad..a27c14106d5 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -278,7 +278,7 @@ class WeikaiGPIOPin : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } void setup() override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void pin_mode(gpio::Flags flags) override { this->parent_->set_pin_direction_(this->pin_, flags); } bool digital_read() override { return this->parent_->read_pin_val_(this->pin_) != this->inverted_; } void digital_write(bool value) override { this->parent_->write_pin_val_(this->pin_, value != this->inverted_); } diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 564870d74e9..9f250631330 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -12,6 +12,9 @@ namespace xiaomi_ble { static const char *const TAG = "xiaomi_ble"; +// Maximum bytes to log in very verbose hex output (covers largest packet of ~24 bytes) +static constexpr size_t XIAOMI_MAX_LOG_BYTES = 32; + bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_length, XiaomiParseResult &result) { // button pressed, 3 bytes, only byte 3 is used for supported devices so far if ((value_type == 0x1001) && (value_length == 3)) { @@ -263,7 +266,10 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address) { if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) { ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size()); - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty(raw.data(), raw.size()).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; +#endif + ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); return false; } @@ -320,12 +326,17 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(mac_address + 4, mac_reverse + 1, 1); memcpy(mac_address + 5, mac_reverse, 1); ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): authenticated decryption failed."); - ESP_LOGVV(TAG, " MAC address : %s", format_mac_address_pretty(mac_address).c_str()); - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty(raw.data(), raw.size()).c_str()); - ESP_LOGVV(TAG, " Key : %s", format_hex_pretty(vector.key, vector.keysize).c_str()); - ESP_LOGVV(TAG, " Iv : %s", format_hex_pretty(vector.iv, vector.ivsize).c_str()); - ESP_LOGVV(TAG, " Cipher : %s", format_hex_pretty(vector.ciphertext, vector.datasize).c_str()); - ESP_LOGVV(TAG, " Tag : %s", format_hex_pretty(vector.tag, vector.tagsize).c_str()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(mac_address, mac_buf); + char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; +#endif + ESP_LOGVV(TAG, " MAC address : %s", mac_buf); + ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); + ESP_LOGVV(TAG, " Key : %s", format_hex_pretty_to(hex_buf, vector.key, vector.keysize)); + ESP_LOGVV(TAG, " Iv : %s", format_hex_pretty_to(hex_buf, vector.iv, vector.ivsize)); + ESP_LOGVV(TAG, " Cipher : %s", format_hex_pretty_to(hex_buf, vector.ciphertext, vector.datasize)); + ESP_LOGVV(TAG, " Tag : %s", format_hex_pretty_to(hex_buf, vector.tag, vector.tagsize)); mbedtls_ccm_free(&ctx); return false; } @@ -341,8 +352,11 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c raw[0] &= ~0x08; ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): authenticated decryption passed."); - ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d", format_hex_pretty(raw.data() + cipher_pos, vector.datasize).c_str(), - static_cast(raw[4])); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; +#endif + ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d", + format_hex_pretty_to(hex_buf, raw.data() + cipher_pos, vector.datasize), static_cast(raw[4])); mbedtls_ccm_free(&ctx); return true; diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 958fc5eede4..dd6c8188ebd 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -110,7 +110,9 @@ void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { void XL9535GPIOPin::setup() { this->pin_mode(this->flags_); } -std::string XL9535GPIOPin::dump_summary() const { return str_snprintf("%u via XL9535", 15, this->pin_); } +size_t XL9535GPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "%u via XL9535", this->pin_); +} void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool XL9535GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 3b511fd9b3a..be0e2fbd820 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -39,7 +39,7 @@ class XL9535GPIOPin : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } void setup() override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 41b983535c0..8041c361cc1 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -85,10 +85,8 @@ void ZephyrGPIOPin::pin_mode(gpio::Flags flags) { } } -std::string ZephyrGPIOPin::dump_summary() const { - char buffer[32]; - snprintf(buffer, sizeof(buffer), "GPIO%u, P%u.%u", this->pin_, this->pin_ / 32, this->pin_ % 32); - return buffer; +size_t ZephyrGPIOPin::dump_summary(char *buffer, size_t len) const { + return snprintf(buffer, len, "GPIO%u, P%u.%u", this->pin_, this->pin_ / 32, this->pin_ % 32); } bool ZephyrGPIOPin::digital_read() { diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 6e8f81857a2..b405f385bcd 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -16,7 +16,7 @@ class ZephyrGPIOPin : public InternalGPIOPin { void pin_mode(gpio::Flags flags) override; bool digital_read() override; void digital_write(bool value) override; - std::string dump_summary() const override; + size_t dump_summary(char *buffer, size_t len) const override; void detach_interrupt() const override; ISRInternalGPIOPin to_isr() const override; uint8_t get_pin() const override { return this->pin_; } diff --git a/esphome/const.py b/esphome/const.py index 1d46e81f9d1..518247aa60b 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -710,6 +710,7 @@ CONF_ON_RELEASE = "on_release" CONF_ON_RESPONSE = "on_response" CONF_ON_SHUTDOWN = "on_shutdown" CONF_ON_SPEED_SET = "on_speed_set" +CONF_ON_START = "on_start" CONF_ON_STATE = "on_state" CONF_ON_SUCCESS = "on_success" CONF_ON_TAG = "on_tag" diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index b7616a9ad38..8508b93411a 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -9,7 +9,8 @@ static const char *const TAG = "entity_base"; // Entity Name const StringRef &EntityBase::get_name() const { return this->name_; } -void EntityBase::set_name(const char *name) { +void EntityBase::set_name(const char *name) { this->set_name(name, 0); } +void EntityBase::set_name(const char *name, uint32_t object_id_hash) { this->name_ = StringRef(name); if (this->name_.empty()) { #ifdef USE_DEVICES @@ -18,11 +19,29 @@ void EntityBase::set_name(const char *name) { } else #endif { - this->name_ = StringRef(App.get_friendly_name()); + // Bug-for-bug compatibility with OLD behavior: + // - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback) + // - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name + const std::string &friendly = App.get_friendly_name(); + if (App.is_name_add_mac_suffix_enabled()) { + // MAC suffix enabled - use friendly_name directly (even if empty) for compatibility + this->name_ = StringRef(friendly); + } else { + // No MAC suffix - fallback to device name if friendly_name is empty + this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name()); + } } this->flags_.has_own_name = false; + // Dynamic name - must calculate hash at runtime + this->calc_object_id_(); } else { this->flags_.has_own_name = true; + // Static name - use pre-computed hash if provided + if (object_id_hash != 0) { + this->object_id_hash_ = object_id_hash; + } else { + this->calc_object_id_(); + } } } @@ -45,69 +64,30 @@ void EntityBase::set_icon(const char *icon) { #endif } -// Check if the object_id is dynamic (changes with MAC suffix) -bool EntityBase::is_object_id_dynamic_() const { - return !this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled(); -} - -// Entity Object ID +// Entity Object ID - computed on-demand from name std::string EntityBase::get_object_id() const { - // Check if `App.get_friendly_name()` is constant or dynamic. - if (this->is_object_id_dynamic_()) { - // `App.get_friendly_name()` is dynamic. - return str_sanitize(str_snake_case(App.get_friendly_name())); - } - // `App.get_friendly_name()` is constant. - return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; -} -void EntityBase::set_object_id(const char *object_id) { - this->object_id_c_str_ = object_id; - this->calc_object_id_(); -} - -void EntityBase::set_name_and_object_id(const char *name, const char *object_id) { - this->set_name(name); - this->object_id_c_str_ = object_id; - this->calc_object_id_(); -} - -// Calculate Object ID Hash from Entity Name -void EntityBase::calc_object_id_() { char buf[OBJECT_ID_MAX_LEN]; - StringRef object_id = this->get_object_id_to(buf); - this->object_id_hash_ = fnv1_hash(object_id.c_str()); + size_t len = this->write_object_id_to(buf, sizeof(buf)); + return std::string(buf, len); } -// Format dynamic object_id: sanitized snake_case of friendly_name -static size_t format_dynamic_object_id(char *buf, size_t buf_size) { - const std::string &name = App.get_friendly_name(); - size_t len = std::min(name.size(), buf_size - 1); - for (size_t i = 0; i < len; i++) { - buf[i] = to_sanitized_char(to_snake_case_char(name[i])); - } - buf[len] = '\0'; - return len; +// Calculate Object ID Hash directly from name using snake_case + sanitize +void EntityBase::calc_object_id_() { + this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); } size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const { - if (this->is_object_id_dynamic_()) { - return format_dynamic_object_id(buf, buf_size); + size_t len = std::min(this->name_.size(), buf_size - 1); + for (size_t i = 0; i < len; i++) { + buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i])); } - const char *src = this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_; - size_t len = strlen(src); - if (len >= buf_size) - len = buf_size - 1; - memcpy(buf, src, len); buf[len] = '\0'; return len; } StringRef EntityBase::get_object_id_to(std::span buf) const { - if (this->is_object_id_dynamic_()) { - size_t len = format_dynamic_object_id(buf.data(), buf.size()); - return StringRef(buf.data(), len); - } - return this->object_id_c_str_ == nullptr ? StringRef() : StringRef(this->object_id_c_str_); + size_t len = this->write_object_id_to(buf.data(), buf.size()); + return StringRef(buf.data(), len); } uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index a5c69f132cc..a45c7795bf0 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -28,6 +28,9 @@ class EntityBase { // Get/set the name of this Entity const StringRef &get_name() const; void set_name(const char *name); + /// Set name with pre-computed object_id hash (avoids runtime hash calculation) + /// Use hash=0 for dynamic names that need runtime calculation + void set_name(const char *name, uint32_t object_id_hash); // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -43,10 +46,6 @@ class EntityBase { "which will remain available longer. get_object_id() will be removed in 2026.7.0", "2025.12.0") std::string get_object_id() const; - void set_object_id(const char *object_id); - - // Set both name and object_id in one call (reduces generated code size) - void set_name_and_object_id(const char *name, const char *object_id); // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); @@ -142,11 +141,7 @@ class EntityBase { protected: void calc_object_id_(); - /// Check if the object_id is dynamic (changes with MAC suffix) - bool is_object_id_dynamic_() const; - StringRef name_; - const char *object_id_c_str_{nullptr}; #ifdef USE_ENTITY_ICON const char *icon_c_str_{nullptr}; #endif diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f360b4d809e..c1801c0bdaa 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -15,7 +15,7 @@ from esphome.const import ( from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv -from esphome.helpers import sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata _LOGGER = logging.getLogger(__name__) @@ -75,34 +75,18 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: config: Configuration dictionary containing entity settings platform: The platform name (e.g., "sensor", "binary_sensor") """ - # Get device info - device_name: str | None = None - device_id_obj: ID | None + # Get device info if configured if device_id_obj := config.get(CONF_DEVICE_ID): device: MockObj = await get_variable(device_id_obj) add(var.set_device(device)) - # Get device name for object ID calculation - device_name = device_id_obj.id - # Calculate base object_id using the same logic as C++ - # This must match the C++ behavior in esphome/core/entity_base.cpp - base_object_id = get_base_entity_object_id( - config[CONF_NAME], CORE.friendly_name, device_name - ) - - if not config[CONF_NAME]: - _LOGGER.debug( - "Entity has empty name, using '%s' as object_id base", base_object_id - ) - - # Set both name and object_id in one call to reduce generated code size - add(var.set_name_and_object_id(config[CONF_NAME], base_object_id)) - _LOGGER.debug( - "Setting object_id '%s' for entity '%s' on platform '%s'", - base_object_id, - config[CONF_NAME], - platform, - ) + # Set the entity name with pre-computed object_id hash + # For named entities: pre-compute hash from entity name + # For empty-name entities: pass 0, C++ calculates hash at runtime from + # device name, friendly_name, or app name (bug-for-bug compatibility) + entity_name = config[CONF_NAME] + object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0 + add(var.set_name(entity_name, object_id_hash)) # Only set disabled_by_default if True (default is False) if config[CONF_DISABLED_BY_DEFAULT]: add(var.set_disabled_by_default(True)) diff --git a/esphome/core/gpio.cpp b/esphome/core/gpio.cpp new file mode 100644 index 00000000000..21e88b5b6d8 --- /dev/null +++ b/esphome/core/gpio.cpp @@ -0,0 +1,24 @@ +#include "esphome/core/gpio.h" +#include "esphome/core/log.h" + +namespace esphome { + +#ifdef USE_ESP8266 +void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin) { + if (pin == nullptr) + return; + static constexpr size_t LOG_PIN_PREFIX_MAX_LEN = 32; + char prefix_buf[LOG_PIN_PREFIX_MAX_LEN]; + strncpy_P(prefix_buf, reinterpret_cast(prefix), sizeof(prefix_buf) - 1); + prefix_buf[sizeof(prefix_buf) - 1] = '\0'; + log_pin_with_prefix(tag, prefix_buf, pin); +} +#else +void log_pin(const char *tag, const char *prefix, GPIOPin *pin) { + if (pin == nullptr) + return; + log_pin_with_prefix(tag, prefix, pin); +} +#endif + +} // namespace esphome diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index dd6f14fef93..f2f85e18bc9 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,13 +1,22 @@ #pragma once +#include #include +#include #include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + namespace esphome { -#define LOG_PIN(prefix, pin) \ - if ((pin) != nullptr) { \ - ESP_LOGCONFIG(TAG, prefix "%s", (pin)->dump_summary().c_str()); \ - } +/// Maximum buffer size for dump_summary output +inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; + +#ifdef USE_ESP8266 +#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) +#else +#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) +#endif // put GPIO flags in a namespace to not pollute esphome namespace namespace gpio { @@ -64,7 +73,17 @@ class GPIOPin { virtual void digital_write(bool value) = 0; - virtual std::string dump_summary() const = 0; + /// Write a summary of this pin to the provided buffer. + /// @param buffer The buffer to write to + /// @param len The size of the buffer (must be > 0) + /// @return The number of characters that would be written (excluding null terminator), + /// which may exceed len-1 if truncation occurred (snprintf semantics) + virtual size_t dump_summary(char *buffer, size_t len) const; + + /// Get a summary of this pin as a string. + /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. + ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") + virtual std::string dump_summary() const; virtual bool is_internal() { return false; } }; @@ -103,4 +122,41 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; +// Inline default implementations for GPIOPin virtual methods. +// These provide bridge functionality for backwards compatibility with external components. + +// Default implementation bridges to old std::string method for backwards compatibility. +inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { + if (len == 0) + return 0; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + std::string s = this->dump_summary(); +#pragma GCC diagnostic pop + size_t copy_len = std::min(s.size(), len - 1); + memcpy(buffer, s.c_str(), copy_len); + buffer[copy_len] = '\0'; + return s.size(); // Return would-be length (snprintf semantics) +} + +// Default implementation returns empty string. +// External components should override this if they haven't migrated to buffer-based version. +// Remove before 2026.7.0 +inline std::string GPIOPin::dump_summary() const { return {}; } + +// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp +inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { + char buffer[GPIO_SUMMARY_MAX_LEN]; + size_t len = pin->dump_summary(buffer, sizeof(buffer)); + len = std::min(len, sizeof(buffer) - 1); + esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); +} + +// log_pin function declarations - implementation in gpio.cpp +#ifdef USE_ESP8266 +void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); +#else +void log_pin(const char *tag, const char *prefix, GPIOPin *pin); +#endif + } // namespace esphome diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index ac7a96a8c8e..f7a14ed2ec9 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -529,6 +529,20 @@ constexpr char to_sanitized_char(char c) { /// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores. std::string str_sanitize(const std::string &str); +/// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. +/// This computes object_id hashes directly from names without creating an intermediate buffer. +/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. +/// If you modify this function, update the Python version and tests in both places. +inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { + uint32_t hash = FNV1_OFFSET_BASIS; + for (size_t i = 0; i < len; i++) { + hash *= FNV1_PRIME; + // Apply snake_case (space->underscore, uppercase->lowercase) then sanitize + hash ^= static_cast(to_sanitized_char(to_snake_case_char(str[i]))); + } + return hash; +} + /// snprintf-like function returning std::string of maximum length \p len (excluding null terminator). std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...); diff --git a/esphome/helpers.py b/esphome/helpers.py index d1623d1d3c5..ae142b7f8be 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -35,6 +35,10 @@ IS_MACOS = platform.system() == "Darwin" IS_WINDOWS = platform.system() == "Windows" IS_LINUX = platform.system() == "Linux" +# FNV-1 hash constants (must match C++ in esphome/core/helpers.h) +FNV1_OFFSET_BASIS = 2166136261 +FNV1_PRIME = 16777619 + def ensure_unique_string(preferred_string, current_strings): test_string = preferred_string @@ -49,8 +53,17 @@ def ensure_unique_string(preferred_string, current_strings): return test_string +def fnv1_hash(string: str) -> int: + """FNV-1 32-bit hash function (multiply then XOR).""" + hash_value = FNV1_OFFSET_BASIS + for char in string: + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF + hash_value ^= ord(char) + return hash_value + + def fnv1a_32bit_hash(string: str) -> int: - """FNV-1a 32-bit hash function. + """FNV-1a 32-bit hash function (XOR then multiply). Note: This uses 32-bit hash instead of 64-bit for several reasons: 1. ESPHome targets 32-bit microcontrollers with limited RAM (often <320KB) @@ -63,13 +76,22 @@ def fnv1a_32bit_hash(string: str) -> int: a handful of area_ids and device_ids (typically <10 areas and <100 devices), making collisions virtually impossible. """ - hash_value = 2166136261 + hash_value = FNV1_OFFSET_BASIS for char in string: hash_value ^= ord(char) - hash_value = (hash_value * 16777619) & 0xFFFFFFFF + hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF return hash_value +def fnv1_hash_object_id(name: str) -> int: + """Compute FNV-1 hash of name with snake_case + sanitize transformations. + + IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h. + Used for pre-computing entity object_id hashes at code generation time. + """ + return fnv1_hash(sanitize(snake_case(name))) + + def strip_accents(value: str) -> str: """Remove accents from a string.""" import unicodedata diff --git a/requirements.txt b/requirements.txt index d457be9cd24..833ccbb0ed4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,10 +12,10 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==43.9.1 +aioesphomeapi==43.10.0 zeroconf==0.148.0 puremagic==1.30 -ruamel.yaml==0.18.17 # dashboard_import +ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 5b68c6a3d2f..7293f2abbc8 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -362,12 +362,12 @@ def create_field_type_info( # Traditional fixed array approach with copy (takes priority) return FixedArrayBytesType(field, fixed_size) - # For SOURCE_CLIENT only messages (decode but no encode), use pointer + # For messages that decode (SOURCE_CLIENT or SOURCE_BOTH), use pointer # for zero-copy access to the receive buffer - if needs_decode and not needs_encode: + if needs_decode: return PointerToBytesBufferType(field, None) - # For SOURCE_BOTH/SOURCE_SERVER, explicit annotation is still needed + # For SOURCE_SERVER (encode only), explicit annotation is still needed if get_field_opt(field, pb.pointer_to_buffer, False): return PointerToBytesBufferType(field, None) diff --git a/script/ci-custom.py b/script/ci-custom.py index 609d89403f3..f0676d594b8 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -552,6 +552,8 @@ def convert_path_to_relative(abspath, current): exclude=[ "esphome/components/libretiny/generate_components.py", "esphome/components/web_server/__init__.py", + # const.py has absolute import in docstring example for external components + "esphome/components/esp8266/const.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 86e07050231..ce4e64681fe 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp + assert 'bs_1->set_name("test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index b21665288c2..797b6fb1a42 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp + assert 'wol_1->set_name("wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index bfc3131f6d3..6b047bc62fb 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp + assert 'it_1->set_name("test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 934ee67cef0..1593d0b6d8e 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,18 +25,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert ( - 'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");' - in main_cpp - ) - assert ( - 'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");' - in main_cpp - ) - assert ( - 'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");' - in main_cpp - ) + assert 'ts_1->set_name("Template Text Sensor 1",' in main_cpp + assert 'ts_2->set_name("Template Text Sensor 2",' in main_cpp + assert 'ts_3->set_name("Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/components/micronova/common.yaml b/tests/components/micronova/common.yaml index 660970350a0..5870d3726fb 100644 --- a/tests/components/micronova/common.yaml +++ b/tests/components/micronova/common.yaml @@ -41,7 +41,7 @@ sensor: switch: - platform: micronova stove: - name: Stove on/off + name: Stove text_sensor: - platform: micronova diff --git a/tests/components/opentherm/common.yaml b/tests/components/opentherm/common.yaml index 1e58a04bf0f..fb5fb39eb84 100644 --- a/tests/components/opentherm/common.yaml +++ b/tests/components/opentherm/common.yaml @@ -92,7 +92,7 @@ sensor: ch_pump_starts: name: "Boiler Number of starts CH pump" dhw_pump_valve_starts: - name: "Boiler Number of starts DHW pump/valve" + name: "Boiler Number of starts DHW pump valve" dhw_burner_starts: name: "Boiler Number of starts burner during DHW mode" burner_operation_hours: @@ -139,7 +139,7 @@ binary_sensor: dhw_present: name: "Boiler DHW present" control_type_on_off: - name: "Boiler Control type is on/off" + name: "Boiler Control type is on-off" cooling_supported: name: "Boiler Cooling supported" dhw_storage_tank: @@ -153,9 +153,9 @@ binary_sensor: max_ch_setpoint_transfer_enabled: name: "Boiler CH maximum setpoint transfer enabled" dhw_setpoint_rw: - name: "Boiler DHW setpoint read/write" + name: "Boiler DHW setpoint read-write" max_ch_setpoint_rw: - name: "Boiler CH maximum setpoint read/write" + name: "Boiler CH maximum setpoint read-write" switch: - platform: opentherm diff --git a/tests/components/zhlt01/common.yaml b/tests/components/zhlt01/common.yaml index d0fd531c877..483f9f3c4e5 100644 --- a/tests/components/zhlt01/common.yaml +++ b/tests/components/zhlt01/common.yaml @@ -1,4 +1,4 @@ climate: - platform: zhlt01 - name: ZH/LT-01 Climate + name: ZH-LT-01 Climate transmitter_id: xmitr diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 965363972f0..50e8d4122bf 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -51,6 +51,9 @@ if platform.system() == "Windows": import pty # not available on Windows +# Register assert rewrite for entity_utils so assertions have proper error messages +pytest.register_assert_rewrite("tests.integration.entity_utils") + def _get_platformio_env(cache_dir: Path) -> dict[str, str]: """Get environment variables for PlatformIO with shared cache.""" diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py new file mode 100644 index 00000000000..7596983ee23 --- /dev/null +++ b/tests/integration/entity_utils.py @@ -0,0 +1,145 @@ +"""Utilities for computing entity object_id in integration tests. + +This module contains the algorithm that aioesphomeapi will use to compute +object_id client-side from API data. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +if TYPE_CHECKING: + from aioesphomeapi import DeviceInfo, EntityInfo + + +def compute_object_id(name: str) -> str: + """Compute object_id from name using snake_case + sanitize.""" + return sanitize(snake_case(name)) + + +def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: + """Infer name_add_mac_suffix from device name ending with MAC suffix.""" + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + return device_info.name.endswith(f"-{mac_suffix}") + + +def _get_name_for_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> str: + """Get the name used for object_id computation. + + This is the algorithm that aioesphomeapi will use to determine which + name to use for computing object_id client-side from API data. + + Args: + entity: The entity to get name for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The name to use for object_id computation + """ + if entity.name: + # Named entity: use entity name + return entity.name + if entity.device_id != 0: + # Empty name on sub-device: use sub-device name + return device_id_to_name[entity.device_id] + if infer_name_add_mac_suffix(device_info) or device_info.friendly_name: + # Empty name on main device with MAC suffix or friendly_name: use friendly_name + # (even if empty - this is bug-for-bug compatibility for MAC suffix case) + return device_info.friendly_name + # Empty name on main device, no friendly_name: use device name + return device_info.name + + +def compute_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> str: + """Compute expected object_id for an entity. + + Args: + entity: The entity to compute object_id for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed object_id string + """ + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return compute_object_id(name_for_id) + + +def compute_entity_hash( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> int: + """Compute expected object_id hash for an entity. + + Args: + entity: The entity to compute hash for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed FNV-1 hash + """ + name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name) + return fnv1_hash_object_id(name_for_id) + + +def verify_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> None: + """Verify an entity's object_id and hash match the expected values. + + Args: + entity: The entity to verify + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Raises: + AssertionError: If object_id or hash doesn't match expected value + """ + expected_object_id = compute_entity_object_id( + entity, device_info, device_id_to_name + ) + assert entity.object_id == expected_object_id, ( + f"object_id mismatch for entity '{entity.name}': " + f"expected '{expected_object_id}', got '{entity.object_id}'" + ) + + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) + assert entity.key == expected_hash, ( + f"hash mismatch for entity '{entity.name}': " + f"expected {expected_hash:#x}, got {entity.key:#x}" + ) + + +def verify_all_entities( + entities: list[EntityInfo], + device_info: DeviceInfo, +) -> None: + """Verify all entities have correct object_id and hash values. + + Args: + entities: List of entities to verify + device_info: Device info from the API + + Raises: + AssertionError: If any entity's object_id or hash doesn't match + """ + # Build device_id -> name lookup from sub-devices + device_id_to_name = {d.device_id: d.name for d in device_info.devices} + + for entity in entities: + verify_entity_object_id(entity, device_info, device_id_to_name) diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml new file mode 100644 index 00000000000..2097b2fbf9c --- /dev/null +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -0,0 +1,76 @@ +esphome: + name: fnv1-hash-object-id-test + platformio_options: + build_flags: + - "-DDEBUG" + on_boot: + - lambda: |- + using esphome::fnv1_hash_object_id; + + // Test basic lowercase (hash matches Python fnv1_hash_object_id("foo")) + uint32_t hash_foo = fnv1_hash_object_id("foo", 3); + if (hash_foo == 0x408f5e13) { + ESP_LOGI("FNV1_OID", "foo PASSED"); + } else { + ESP_LOGE("FNV1_OID", "foo FAILED: 0x%08x != 0x408f5e13", hash_foo); + } + + // Test uppercase conversion (should match lowercase) + uint32_t hash_Foo = fnv1_hash_object_id("Foo", 3); + if (hash_Foo == 0x408f5e13) { + ESP_LOGI("FNV1_OID", "upper PASSED"); + } else { + ESP_LOGE("FNV1_OID", "upper FAILED: 0x%08x != 0x408f5e13", hash_Foo); + } + + // Test space to underscore conversion ("foo bar" -> "foo_bar") + uint32_t hash_space = fnv1_hash_object_id("foo bar", 7); + if (hash_space == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "space PASSED"); + } else { + ESP_LOGE("FNV1_OID", "space FAILED: 0x%08x != 0x3ae35aa1", hash_space); + } + + // Test underscore preserved ("foo_bar") + uint32_t hash_underscore = fnv1_hash_object_id("foo_bar", 7); + if (hash_underscore == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "underscore PASSED"); + } else { + ESP_LOGE("FNV1_OID", "underscore FAILED: 0x%08x != 0x3ae35aa1", hash_underscore); + } + + // Test hyphen preserved ("foo-bar") + uint32_t hash_hyphen = fnv1_hash_object_id("foo-bar", 7); + if (hash_hyphen == 0x438b12e3) { + ESP_LOGI("FNV1_OID", "hyphen PASSED"); + } else { + ESP_LOGE("FNV1_OID", "hyphen FAILED: 0x%08x != 0x438b12e3", hash_hyphen); + } + + // Test special chars become underscore ("foo!bar" -> "foo_bar") + uint32_t hash_special = fnv1_hash_object_id("foo!bar", 7); + if (hash_special == 0x3ae35aa1) { + ESP_LOGI("FNV1_OID", "special PASSED"); + } else { + ESP_LOGE("FNV1_OID", "special FAILED: 0x%08x != 0x3ae35aa1", hash_special); + } + + // Test complex name ("My Sensor Name" -> "my_sensor_name") + uint32_t hash_complex = fnv1_hash_object_id("My Sensor Name", 14); + if (hash_complex == 0x2760962a) { + ESP_LOGI("FNV1_OID", "complex PASSED"); + } else { + ESP_LOGE("FNV1_OID", "complex FAILED: 0x%08x != 0x2760962a", hash_complex); + } + + // Test empty string returns FNV1_OFFSET_BASIS + uint32_t hash_empty = fnv1_hash_object_id("", 0); + if (hash_empty == 0x811c9dc5) { + ESP_LOGI("FNV1_OID", "empty PASSED"); + } else { + ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty); + } + +host: +api: +logger: diff --git a/tests/integration/fixtures/object_id_api_verification.yaml b/tests/integration/fixtures/object_id_api_verification.yaml new file mode 100644 index 00000000000..386270fc2c1 --- /dev/null +++ b/tests/integration/fixtures/object_id_api_verification.yaml @@ -0,0 +1,125 @@ +esphome: + name: object-id-test + friendly_name: Test Device + # Enable MAC suffix - host MAC is 98:35:69:ab:f6:79, suffix is "abf679" + # friendly_name becomes "Test Device abf679" + name_add_mac_suffix: true + # Sub-devices for testing empty-name entities on devices + devices: + - id: sub_device_1 + name: Sub Device One + - id: sub_device_2 + name: Sub Device Two + +host: + +api: + +logger: + +sensor: + # Test 1: Basic name -> object_id = "temperature_sensor" + - platform: template + name: "Temperature Sensor" + id: sensor_basic + lambda: return 42.0; + update_interval: 60s + + # Test 2: Uppercase name -> object_id = "uppercase_name" + - platform: template + name: "UPPERCASE NAME" + id: sensor_uppercase + lambda: return 43.0; + update_interval: 60s + + # Test 3: Special characters -> object_id = "special__chars_" + - platform: template + name: "Special!@Chars#" + id: sensor_special + lambda: return 44.0; + update_interval: 60s + + # Test 4: Hyphen preserved -> object_id = "temp-sensor" + - platform: template + name: "Temp-Sensor" + id: sensor_hyphen + lambda: return 45.0; + update_interval: 60s + + # Test 5: Underscore preserved -> object_id = "temp_sensor" + - platform: template + name: "Temp_Sensor" + id: sensor_underscore + lambda: return 46.0; + update_interval: 60s + + # Test 6: Mixed case with spaces -> object_id = "living_room_temperature" + - platform: template + name: "Living Room Temperature" + id: sensor_mixed + lambda: return 47.0; + update_interval: 60s + + # Test 7: Empty name - uses friendly_name with MAC suffix + # friendly_name = "Test Device abf679" -> object_id = "test_device_abf679" + - platform: template + name: "" + id: sensor_empty_name + lambda: return 48.0; + update_interval: 60s + +binary_sensor: + # Test 8: Different platform same conversion rules + - platform: template + name: "Door Open" + id: binary_door + lambda: return true; + + # Test 9: Numbers in name -> object_id = "sensor_123" + - platform: template + name: "Sensor 123" + id: binary_numbers + lambda: return false; + +switch: + # Test 10: Long name with multiple spaces + - platform: template + name: "My Very Long Switch Name Here" + id: switch_long + lambda: return false; + turn_on_action: + - logger.log: "on" + turn_off_action: + - logger.log: "off" + +text_sensor: + # Test 11: Name starting with number (should work fine) + - platform: template + name: "123 Start" + id: text_num_start + lambda: return {"test"}; + update_interval: 60s + +button: + # Test 12: Named entity on sub-device -> object_id from entity name + - platform: template + name: "Device Button" + id: button_on_device + device_id: sub_device_1 + on_press: [] + + # Test 13: Empty name on sub-device -> object_id from device name + # Device name "Sub Device One" -> object_id = "sub_device_one" + - platform: template + name: "" + id: button_empty_on_device1 + device_id: sub_device_1 + on_press: [] + + # Test 14: Empty name on different sub-device + # Device name "Sub Device Two" -> object_id = "sub_device_two" + - platform: template + name: "" + id: button_empty_on_device2 + device_id: sub_device_2 + on_press: [] diff --git a/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml b/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml new file mode 100644 index 00000000000..7a86e37d083 --- /dev/null +++ b/tests/integration/fixtures/object_id_friendly_name_no_mac_suffix.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-device + # friendly_name set but NO MAC suffix + # Empty-name entity should use friendly_name for object_id + friendly_name: My Friendly Device + +host: + +api: + +logger: + +sensor: + # Empty name entity - should use friendly_name for object_id + # friendly_name = "My Friendly Device" -> object_id = "my_friendly_device" + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml b/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml new file mode 100644 index 00000000000..4a947e0f6af --- /dev/null +++ b/tests/integration/fixtures/object_id_no_friendly_name_no_mac_suffix.yaml @@ -0,0 +1,25 @@ +esphome: + name: test-device + # No friendly_name set, no MAC suffix + # OLD behavior: object_id = device name because Python pre-computed with fallback + +host: + +api: + +logger: + +sensor: + # Empty name entity - OLD behavior used device name as fallback + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml b/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml new file mode 100644 index 00000000000..ab12e670a03 --- /dev/null +++ b/tests/integration/fixtures/object_id_no_friendly_name_with_mac_suffix.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-device + # No friendly_name set, MAC suffix enabled + # OLD behavior: object_id = "" (empty) because is_object_id_dynamic_() used App.get_friendly_name() directly + name_add_mac_suffix: true + +host: + +api: + +logger: + +sensor: + # Empty name entity - OLD behavior produced empty object_id when MAC suffix enabled + - platform: template + name: "" + id: sensor_empty_name + lambda: return 42.0; + update_interval: 60s + + # Named entity for comparison + - platform: template + name: "Temperature" + id: sensor_named + lambda: return 43.0; + update_interval: 60s diff --git a/tests/integration/test_fnv1_hash_object_id.py b/tests/integration/test_fnv1_hash_object_id.py new file mode 100644 index 00000000000..23e8ca04c23 --- /dev/null +++ b/tests/integration/test_fnv1_hash_object_id.py @@ -0,0 +1,75 @@ +"""Integration test for fnv1_hash_object_id function. + +This test verifies that the C++ fnv1_hash_object_id() function in +esphome/core/helpers.h produces the same hash values as the Python +fnv1_hash_object_id() function in esphome/helpers.py. + +If this test fails, one of the implementations has diverged and needs +to be updated to match the other. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_fnv1_hash_object_id( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that C++ fnv1_hash_object_id matches Python implementation.""" + + test_results: dict[str, str] = {} + all_tests_complete = asyncio.Event() + expected_tests = { + "foo", + "upper", + "space", + "underscore", + "hyphen", + "special", + "complex", + "empty", + } + + def on_log_line(line: str) -> None: + """Capture log lines with test results.""" + # Strip ANSI escape codes + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + # Look for our test result messages + # Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED" + match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line) + if match: + test_name = match.group(1) + result = match.group(2) + test_results[test_name] = result + if set(test_results.keys()) >= expected_tests: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "fnv1-hash-object-id-test" + + # Wait for all tests to complete or timeout + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0) + except TimeoutError: + pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}") + + # Verify all tests passed + for test_name in expected_tests: + assert test_name in test_results, f"{test_name} test not found" + assert test_results[test_name] == "PASSED", ( + f"{test_name} test failed - C++ and Python hash mismatch" + ) diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py new file mode 100644 index 00000000000..c8603e06829 --- /dev/null +++ b/tests/integration/test_object_id_api_verification.py @@ -0,0 +1,176 @@ +"""Integration test to verify object_id from API matches Python computation. + +This test verifies a three-way match between: +1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char) +2. C++ hash generation (fnv1_hash_object_id in helpers.h) +3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id) + +The API response contains C++ computed values, so verifying API == Python +implicitly verifies C++ == Python == API for both object_id and hash. + +This is important for the planned migration to remove object_id from the API +protocol and have clients (like aioesphomeapi) compute it from the name. +See: https://github.com/esphome/backlog/issues/76 + +Test cases covered: +- Named entities with various characters (uppercase, special chars, hyphens, etc.) +- Empty-name entities on main device (uses device's friendly_name with MAC suffix) +- Empty-name entities on sub-devices (uses sub-device's name) +- Named entities on sub-devices (uses entity name, not device name) +- MAC suffix handling (name_add_mac_suffix modifies friendly_name at runtime) +- Both object_id string and hash (key) verification +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id + +from .entity_utils import compute_object_id, verify_all_entities +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +# Expected entities with their own names and expected object_ids +# Format: (entity_name, expected_object_id) +NAMED_ENTITIES = [ + # sensor platform + ("Temperature Sensor", "temperature_sensor"), + ("UPPERCASE NAME", "uppercase_name"), + ("Special!@Chars#", "special__chars_"), + ("Temp-Sensor", "temp-sensor"), + ("Temp_Sensor", "temp_sensor"), + ("Living Room Temperature", "living_room_temperature"), + # binary_sensor platform + ("Door Open", "door_open"), + ("Sensor 123", "sensor_123"), + # switch platform + ("My Very Long Switch Name Here", "my_very_long_switch_name_here"), + # text_sensor platform + ("123 Start", "123_start"), + # button platform - named entity on sub-device (uses entity name, not device name) + ("Device Button", "device_button"), +] + +# Sub-device names and their expected object_ids for empty-name entities +# Format: (device_name, expected_object_id) +SUB_DEVICE_EMPTY_NAME_ENTITIES = [ + ("Sub Device One", "sub_device_one"), + ("Sub Device Two", "sub_device_two"), +] + + +@pytest.mark.asyncio +async def test_object_id_api_verification( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that object_id from API matches Python computation. + + Tests: + 1. Named entities - object_id computed from entity name + 2. Empty-name entities - object_id computed from friendly_name (with MAC suffix) + 3. Hash verification - key can be computed from name + 4. Generic verification - all entities can have object_id computed from API data + """ + async with run_compiled(yaml_config), api_client_connected() as client: + # Get device info + device_info = await client.device_info() + assert device_info is not None + + # Device name should include MAC suffix (hyphen separator) + assert device_info.name == f"object-id-test-{MAC_SUFFIX}", ( + f"Device name mismatch: got '{device_info.name}'" + ) + # Friendly name should include MAC suffix (space separator) + expected_friendly_name = f"Test Device {MAC_SUFFIX}" + assert device_info.friendly_name == expected_friendly_name, ( + f"Friendly name mismatch: got '{device_info.friendly_name}'" + ) + + # Get all entities + entities, _ = await client.list_entities_services() + + # Create a map of entity names to entity info + entity_map = {} + for entity in entities: + entity_map[entity.name] = entity + + # === Test 1: Verify each named entity === + for entity_name, expected_object_id in NAMED_ENTITIES: + assert entity_name in entity_map, ( + f"Entity '{entity_name}' not found in API response. " + f"Available: {list(entity_map.keys())}" + ) + + entity = entity_map[entity_name] + + # Verify object_id matches expected + assert entity.object_id == expected_object_id, ( + f"Entity '{entity_name}': object_id mismatch. " + f"API returned '{entity.object_id}', expected '{expected_object_id}'" + ) + + # Verify Python computation matches + computed = compute_object_id(entity_name) + assert computed == expected_object_id, ( + f"Entity '{entity_name}': Python computation mismatch. " + f"Computed '{computed}', expected '{expected_object_id}'" + ) + + # Verify hash can be computed from the name + hash_from_name = fnv1_hash_object_id(entity_name) + assert hash_from_name == entity.key, ( + f"Entity '{entity_name}': hash mismatch. " + f"Python hash {hash_from_name:#x}, API key {entity.key:#x}" + ) + + # === Test 2: Verify empty-name entities === + # Empty-name entities have name="" in API, object_id comes from: + # - Main device: friendly_name (with MAC suffix) + # - Sub-device: device name + + # Get all empty-name entities + empty_name_entities = [e for e in entities if e.name == ""] + # We expect 3: 1 on main device, 2 on sub-devices + assert len(empty_name_entities) == 3, ( + f"Expected 3 empty-name entities, got {len(empty_name_entities)}" + ) + + # Build device_id -> device_name map from device_info + device_id_to_name = {d.device_id: d.name for d in device_info.devices} + + # Verify each empty-name entity + for entity in empty_name_entities: + if entity.device_id == 0: + # Main device - uses friendly_name with MAC suffix + expected_name = expected_friendly_name + else: + # Sub-device - uses device name + assert entity.device_id in device_id_to_name, ( + f"Entity device_id {entity.device_id} not found in devices" + ) + expected_name = device_id_to_name[entity.device_id] + + expected_object_id = compute_object_id(expected_name) + assert entity.object_id == expected_object_id, ( + f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. " + f"API: '{entity.object_id}', expected: '{expected_object_id}' " + f"(from name '{expected_name}')" + ) + + # Verify hash matches + expected_hash = fnv1_hash_object_id(expected_name) + assert entity.key == expected_hash, ( + f"Empty-name entity (device_id={entity.device_id}): hash mismatch. " + f"API key: {entity.key:#x}, expected: {expected_hash:#x}" + ) + + # === Test 3: Verify ALL entities using the algorithm from entity_utils === + # This uses the algorithm that aioesphomeapi will use to compute object_id + # client-side from API data. + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py new file mode 100644 index 00000000000..7199a2b3719 --- /dev/null +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -0,0 +1,81 @@ +"""Integration test for object_id with friendly_name but no MAC suffix. + +This test covers Branch 4 of the algorithm: +- Empty name on main device +- NO MAC suffix enabled +- friendly_name IS set +- Result: use friendly_name for object_id +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id + +from .entity_utils import ( + compute_object_id, + infer_name_add_mac_suffix, + verify_all_entities, +) +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_object_id_friendly_name_no_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name is set but no MAC suffix. + + This covers Branch 4 of the algorithm: + - Empty name entity + - name_add_mac_suffix = false (or not set) + - friendly_name = "My Friendly Device" + - Expected: object_id = "my_friendly_device" + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should NOT include MAC suffix + assert device_info.name == "test-device" + + # Friendly name should be set + assert device_info.friendly_name == "My Friendly Device" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # Should use friendly_name for object_id (Branch 4) + expected_object_id = compute_object_id("My Friendly Device") + assert expected_object_id == "my_friendly_device" # Verify our expectation + assert entity.object_id == expected_object_id, ( + f"Expected object_id '{expected_object_id}' from friendly_name, " + f"got '{entity.object_id}'" + ) + + # Hash should match friendly_name + expected_hash = fnv1_hash_object_id("My Friendly Device") + assert entity.key == expected_hash, ( + f"Expected hash {expected_hash:#x}, got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" + + # Verify our inference: no MAC suffix in this test + assert not infer_name_add_mac_suffix(device_info), ( + "Device name should NOT have MAC suffix" + ) + + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py new file mode 100644 index 00000000000..b548f02fde2 --- /dev/null +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -0,0 +1,140 @@ +"""Integration tests for object_id when friendly_name is not set. + +These tests verify bug-for-bug compatibility with the old behavior: + +1. With MAC suffix enabled + no friendly_name: + - OLD: is_object_id_dynamic_() was true, used App.get_friendly_name() directly + - OLD: object_id = "" (empty) because friendly_name was empty + - NEW: Must maintain same behavior for compatibility + +2. Without MAC suffix + no friendly_name: + - OLD: is_object_id_dynamic_() was false, used pre-computed object_id_c_str_ + - OLD: Python computed object_id with fallback to device name + - NEW: Must maintain same behavior (object_id = device name) +""" + +from __future__ import annotations + +import pytest + +from esphome.helpers import fnv1_hash_object_id + +from .entity_utils import compute_object_id, verify_all_entities +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + +# FNV1 offset basis - hash of empty string +FNV1_OFFSET_BASIS = 2166136261 + + +@pytest.mark.asyncio +async def test_object_id_no_friendly_name_with_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name not set but MAC suffix enabled. + + OLD behavior (bug-for-bug compatibility): + - is_object_id_dynamic_() returned true (no own name AND mac suffix enabled) + - format_dynamic_object_id() used App.get_friendly_name() directly + - Since friendly_name was empty, object_id was empty + + This was arguably a bug, but we maintain it for compatibility. + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should include MAC suffix + expected_device_name = f"test-device-{MAC_SUFFIX}" + assert device_info.name == expected_device_name + + # Friendly name should be empty (not set in config) + assert device_info.friendly_name == "" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # OLD behavior: object_id was empty because App.get_friendly_name() was empty + # This is bug-for-bug compatibility + assert entity.object_id == "", ( + f"Expected empty object_id for bug-for-bug compatibility, " + f"got '{entity.object_id}'" + ) + + # Hash should be FNV1_OFFSET_BASIS (hash of empty string) + assert entity.key == FNV1_OFFSET_BASIS, ( + f"Expected hash of empty string ({FNV1_OFFSET_BASIS:#x}), " + f"got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" + + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) + + +@pytest.mark.asyncio +async def test_object_id_no_friendly_name_no_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test object_id when friendly_name not set and no MAC suffix. + + OLD behavior: + - is_object_id_dynamic_() returned false (mac suffix not enabled) + - Used object_id_c_str_ which was pre-computed in Python + - Python used get_base_entity_object_id() with fallback to CORE.name + + Result: object_id = sanitize(snake_case(device_name)) + """ + async with run_compiled(yaml_config), api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Device name should NOT include MAC suffix + assert device_info.name == "test-device" + + # Friendly name should be empty (not set in config) + assert device_info.friendly_name == "" + + entities, _ = await client.list_entities_services() + + # Find the empty-name entity + empty_name_entities = [e for e in entities if e.name == ""] + assert len(empty_name_entities) == 1 + + entity = empty_name_entities[0] + + # OLD behavior: object_id was computed from device name + expected_object_id = compute_object_id("test-device") + assert entity.object_id == expected_object_id, ( + f"Expected object_id '{expected_object_id}' from device name, " + f"got '{entity.object_id}'" + ) + + # Hash should match device name + expected_hash = fnv1_hash_object_id("test-device") + assert entity.key == expected_hash, ( + f"Expected hash {expected_hash:#x}, got {entity.key:#x}" + ) + + # Named entity should work normally + named_entities = [e for e in entities if e.name == "Temperature"] + assert len(named_entities) == 1 + assert named_entities[0].object_id == "temperature" + + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 01de0f27f9c..a58d4784cee 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -27,13 +27,9 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex patterns for extracting object IDs from expressions -# Matches both old format: .set_object_id("obj_id") -# and new format: .set_name_and_object_id("name", "obj_id") -OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') -COMBINED_PATTERN = re.compile( - r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)' -) +# Pre-compiled regex pattern for extracting names from set_name calls +# Matches: .set_name("name", hash) or .set_name("name") +SET_NAME_PATTERN = re.compile(r'\.set_name\(["\']([^"\']*)["\']') FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -276,14 +272,21 @@ def setup_test_environment() -> Generator[list[str], None, None]: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: - """Extract the object ID that was set from the generated expressions.""" + """Extract the object ID that would be computed from set_name calls. + + Since object_id is now computed from the name (via snake_case + sanitize), + we extract the name from set_name() calls and compute the expected object_id. + For empty names, we fall back to CORE.friendly_name or CORE.name. + """ for expr in expressions: - # First try new combined format: .set_name_and_object_id("name", "obj_id") - if match := COMBINED_PATTERN.search(expr): - return match.group(1) - # Fall back to old format: .set_object_id("obj_id") - if match := OBJECT_ID_PATTERN.search(expr): - return match.group(1) + if match := SET_NAME_PATTERN.search(expr): + name = match.group(1) + if name: + return sanitize(snake_case(name)) + # Empty name - fall back to friendly_name or device name + if CORE.friendly_name: + return sanitize(snake_case(CORE.friendly_name)) + return sanitize(snake_case(CORE.name)) if CORE.name else None return None @@ -757,3 +760,140 @@ def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None: r"Each entity on a device must have a unique name within its platform\.$", ): validator(config2) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_device( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty entity name on a sub-device. + + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from the device's actual name. + """ + added_expressions = setup_test_environment + + # Mock get_variable to return a mock device + original_get_variable = entity_helpers.get_variable + + async def mock_get_variable(id_: ID) -> MockObj: + return MockObj("sub_device_1") + + entity_helpers.get_variable = mock_get_variable + + var = MockObj("sensor1") + device_id = ID("sub_device_1", type="Device") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + CONF_DEVICE_ID: device_id, + } + + await setup_entity(var, config, "sensor") + + entity_helpers.get_variable = original_get_variable + + # Check that set_device was called + assert any("sensor1.set_device" in expr for expr in added_expressions) + + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_mac_suffix( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name and MAC suffix enabled. + + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from friendly_name (bug-for-bug compatibility). + """ + added_expressions = setup_test_environment + + # Set up CORE.config with name_add_mac_suffix enabled + CORE.config = {"name_add_mac_suffix": True} + # Set friendly_name to a specific value + CORE.friendly_name = "My Device" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name, MAC suffix enabled, but no friendly_name. + + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime. In this case C++ will hash the empty friendly_name + (bug-for-bug compatibility). + """ + added_expressions = setup_test_environment + + # Set up CORE.config with name_add_mac_suffix enabled + CORE.config = {"name_add_mac_suffix": True} + # Set friendly_name to empty + CORE.friendly_name = "" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) + + +@pytest.mark.asyncio +async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name( + setup_test_environment: list[str], +) -> None: + """Test setup_entity with empty name, no MAC suffix, and no friendly_name. + + For empty-name entities, Python passes 0 and C++ calculates the hash + at runtime from the device name. + """ + added_expressions = setup_test_environment + + # No MAC suffix (either not set or False) + CORE.config = {} + # No friendly_name + CORE.friendly_name = "" + # Device name is set + CORE.name = "my-test-device" + + var = MockObj("sensor1") + + config = { + CONF_NAME: "", + CONF_DISABLED_BY_DEFAULT: False, + } + + await setup_entity(var, config, "sensor") + + # For empty-name entities, Python passes 0 - C++ calculates hash at runtime + assert any('set_name("", 0)' in expr for expr in added_expressions), ( + f"Expected set_name with hash 0, got {added_expressions}" + ) diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 47b945e0eb0..159d3230ab8 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -279,6 +279,77 @@ def test_sanitize(text, expected): assert actual == expected +@pytest.mark.parametrize( + ("name", "expected_hash"), + ( + # Basic strings - hash of sanitize(snake_case(name)) + ("foo", 0x408F5E13), + ("Foo", 0x408F5E13), # Same as "foo" (lowercase) + ("FOO", 0x408F5E13), # Same as "foo" (lowercase) + # Spaces become underscores + ("foo bar", 0x3AE35AA1), # Transforms to "foo_bar" + ("Foo Bar", 0x3AE35AA1), # Same (lowercase + underscore) + # Already snake_case + ("foo_bar", 0x3AE35AA1), + # Special chars become underscores + ("foo!bar", 0x3AE35AA1), # Transforms to "foo_bar" + ("foo@bar", 0x3AE35AA1), # Transforms to "foo_bar" + # Hyphens are preserved + ("foo-bar", 0x438B12E3), + # Numbers are preserved + ("foo123", 0xF3B0067D), + # Empty string + ("", 0x811C9DC5), # FNV1_OFFSET_BASIS (no chars processed) + # Single char + ("a", 0x050C5D7E), + # Mixed case and spaces + ("My Sensor Name", 0x2760962A), # Transforms to "my_sensor_name" + ), +) +def test_fnv1_hash_object_id(name, expected_hash): + """Test fnv1_hash_object_id produces expected hashes. + + These expected values were computed to match the C++ implementation + in esphome/core/helpers.h. If this test fails after modifying either + implementation, ensure both Python and C++ versions stay in sync. + """ + actual = helpers.fnv1_hash_object_id(name) + + assert actual == expected_hash + + +def _fnv1_hash_py(s: str) -> int: + """Python implementation of FNV-1 hash for verification.""" + hash_val = 2166136261 # FNV1_OFFSET_BASIS + for c in s: + hash_val = (hash_val * 16777619) & 0xFFFFFFFF # FNV1_PRIME + hash_val ^= ord(c) + return hash_val + + +@pytest.mark.parametrize( + "name", + ( + "Simple", + "With Space", + "MixedCase", + "special!@#chars", + "already_snake_case", + "123numbers", + ), +) +def test_fnv1_hash_object_id_matches_manual_calculation(name): + """Verify fnv1_hash_object_id matches snake_case + sanitize + standard FNV-1.""" + # Manual calculation: snake_case -> sanitize -> fnv1_hash + transformed = helpers.sanitize(helpers.snake_case(name)) + expected = _fnv1_hash_py(transformed) + + # Direct calculation via fnv1_hash_object_id + actual = helpers.fnv1_hash_object_id(name) + + assert actual == expected + + @pytest.mark.parametrize( "text, expected", ((["127.0.0.1", "fe80::1", "2001::2"], ["2001::2", "127.0.0.1", "fe80::1"]),),