From 159194587b7a7f033d5ad361ed9d89b19a79623e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:08:21 -0600 Subject: [PATCH 01/40] [core] Move Color::gradient to cpp to avoid duplicate code (#12348) --- esphome/core/color.cpp | 14 ++++++++++++++ esphome/core/color.h | 14 +++----------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index 7e390b2354..14c41c2b0d 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,4 +6,18 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); +Color Color::gradient(const Color &to_color, uint8_t amnt) { + Color new_color; + float amnt_f = float(amnt) / 255.0f; + new_color.r = amnt_f * (to_color.r - this->r) + this->r; + new_color.g = amnt_f * (to_color.g - this->g) + this->g; + new_color.b = amnt_f * (to_color.b - this->b) + this->b; + new_color.w = amnt_f * (to_color.w - this->w) + this->w; + return new_color; +} + +Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } + +Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } + } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 4b0ae5b57a..32d63b1856 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,17 +174,9 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt) { - Color new_color; - float amnt_f = float(amnt) / 255.0f; - new_color.r = amnt_f * (to_color.r - (*this).r) + (*this).r; - new_color.g = amnt_f * (to_color.g - (*this).g) + (*this).g; - new_color.b = amnt_f * (to_color.b - (*this).b) + (*this).b; - new_color.w = amnt_f * (to_color.w - (*this).w) + (*this).w; - return new_color; - } - Color fade_to_white(uint8_t amnt) { return (*this).gradient(Color::WHITE, amnt); } - Color fade_to_black(uint8_t amnt) { return (*this).gradient(Color::BLACK, amnt); } + Color gradient(const Color &to_color, uint8_t amnt); + Color fade_to_white(uint8_t amnt); + Color fade_to_black(uint8_t amnt); Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } From 93a85d7979f7d0a23c93aa520a7af8c1996da561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:08:46 -0600 Subject: [PATCH 02/40] [wifi_signal] Update signal strength immediately on WiFi connect/disconnect (#12347) --- esphome/components/wifi_signal/sensor.py | 3 ++- .../components/wifi_signal/wifi_signal_sensor.cpp | 6 ++---- esphome/components/wifi_signal/wifi_signal_sensor.h | 12 +++++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/esphome/components/wifi_signal/sensor.py b/esphome/components/wifi_signal/sensor.py index 99b51adea0..82cb90c745 100644 --- a/esphome/components/wifi_signal/sensor.py +++ b/esphome/components/wifi_signal/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import sensor +from esphome.components import sensor, wifi import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, @@ -25,5 +25,6 @@ CONFIG_SCHEMA = sensor.sensor_schema( async def to_code(config): + wifi.request_wifi_listeners() var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.cpp b/esphome/components/wifi_signal/wifi_signal_sensor.cpp index 4347295421..11d816a909 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.cpp +++ b/esphome/components/wifi_signal/wifi_signal_sensor.cpp @@ -2,13 +2,11 @@ #ifdef USE_WIFI #include "esphome/core/log.h" -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { static const char *const TAG = "wifi_signal.sensor"; void WiFiSignalSensor::dump_config() { LOG_SENSOR("", "WiFi Signal", this); } -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5cfd19b523..cc951e8dd7 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -5,17 +5,19 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { public: + void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + // WiFiConnectStateListener interface - update RSSI immediately on connect + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override { this->update(); } }; -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif From 53ddd1a1cd8a5fc5f73550eadf14c2f5ac6c9c86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 13:43:48 +0100 Subject: [PATCH 03/40] [wifi_signal] Add ifdef guards for clang-tidy compatibility (#12362) --- esphome/components/wifi_signal/wifi_signal_sensor.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index cc951e8dd7..5d7f4b4562 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -7,16 +7,24 @@ #ifdef USE_WIFI namespace esphome::wifi_signal { +#ifdef USE_WIFI_LISTENERS class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +#else +class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +#endif public: +#ifdef USE_WIFI_LISTENERS void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } +#endif void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } +#ifdef USE_WIFI_LISTENERS // WiFiConnectStateListener interface - update RSSI immediately on connect void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override { this->update(); } +#endif }; } // namespace esphome::wifi_signal From 2515f1c0806d1641d8b04b3688f771c145cc4f48 Mon Sep 17 00:00:00 2001 From: Berik Visschers Date: Mon, 8 Dec 2025 14:37:59 +0100 Subject: [PATCH 04/40] Add seeed_xiao_esp32c6 board definition (#12307) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32/boards.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 7107874a5b..514d674b55 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1218,6 +1218,28 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + "seeed_xiao_esp32c6": { + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 21, + "D4": 22, + "D5": 23, + "D6": 16, + "D7": 17, + "D8": 19, + "D9": 20, + "D10": 18, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "LED": 8, + "LED_BUILTIN": 8, + "RF_SWITCH_EN": 3, + "RF_ANT_SELECT": 14, + }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, "tinypico": {}, From 95efb3704524cc1e9f9aefefc2b4c189c3f0aca2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 14:39:43 +0100 Subject: [PATCH 05/40] [micronova] Set the write bit automatically (#12318) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/micronova/__init__.py | 6 ++++-- .../components/micronova/button/__init__.py | 2 +- esphome/components/micronova/micronova.cpp | 8 ++++++-- esphome/components/micronova/micronova.h | 1 - .../components/micronova/number/__init__.py | 20 +------------------ .../micronova/number/micronova_number.cpp | 2 +- .../micronova/number/micronova_number.h | 6 ------ .../components/micronova/switch/__init__.py | 2 +- tests/components/micronova/common.yaml | 2 +- 9 files changed, 15 insertions(+), 34 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 11213e82c5..637d0eb168 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -63,12 +63,14 @@ def MICRONOVA_ADDRESS_SCHEMA( schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), + # On write requests the write bit (0x80) is added automatically to the location + # Therefore no locations >= 0x80 are allowed cv.Optional( CONF_MEMORY_LOCATION, default=default_memory_location - ): cv.hex_int_range(), + ): cv.hex_int_range(min=0x00, max=0x79), cv.Optional( CONF_MEMORY_ADDRESS, default=default_memory_address - ): cv.hex_int_range(), + ): cv.hex_int_range(min=0x00, max=0xFF), } ) if is_polling_component: diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index dd57c9ec4f..38fee2f561 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -25,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0xA0, + default_memory_location=0x20, default_memory_address=0x7D, is_polling_component=False, ) diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 7343bc90ba..22daef4fe6 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,6 +3,9 @@ namespace esphome::micronova { +static const int STOVE_REPLY_DELAY = 60; +static const uint8_t WRITE_BIT = 1 << 7; // 0x80 + void MicroNovaBaseListener::dump_base_config() { ESP_LOGCONFIG(TAG, " Memory Location: %02X\n" @@ -125,7 +128,8 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { uint16_t checksum = 0; if (this->reply_pending_mutex_.try_lock()) { - write_data[0] = location; + uint8_t write_location = location | WRITE_BIT; + write_data[0] = write_location; write_data[1] = address; write_data[2] = data; @@ -140,7 +144,7 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { this->enable_rx_pin_->digital_write(false); this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; + this->current_transmission_.memory_location = write_location; this->current_transmission_.memory_address = address; this->current_transmission_.reply_pending = true; this->current_transmission_.initiating_listener = nullptr; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index c5a5ba4f4e..a2eee81be8 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -11,7 +11,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; -static const int STOVE_REPLY_DELAY = 60; enum class MicroNovaFunctions { STOVE_FUNCTION_VOID = 0, diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index ec994a5aa5..07023e618c 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -17,7 +17,6 @@ ICON_FLASH = "mdi:flash" CONF_THERMOSTAT_TEMPERATURE = "thermostat_temperature" CONF_POWER_LEVEL = "power_level" -CONF_MEMORY_WRITE_LOCATION = "memory_write_location" MicroNovaNumber = micronova_ns.class_( "MicroNovaNumber", number.Number, MicroNovaListener @@ -40,25 +39,18 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( { - cv.Optional( - CONF_MEMORY_WRITE_LOCATION, default=0xA0 - ): cv.hex_int_range(), cv.Optional(CONF_STEP, default=1.0): cv.float_range(min=0.1, max=10.0), } ), cv.Optional(CONF_POWER_LEVEL): number.number_schema( MicroNovaNumber, icon=ICON_FLASH, - ) - .extend( + ).extend( MICRONOVA_ADDRESS_SCHEMA( default_memory_location=0x20, default_memory_address=0x7F, is_polling_component=True, ) - ) - .extend( - {cv.Optional(CONF_MEMORY_WRITE_LOCATION, default=0xA0): cv.hex_int_range()} ), } ) @@ -81,11 +73,6 @@ async def to_code(config): MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, ) cg.add(numb.set_micronova_object(mv)) - cg.add( - numb.set_memory_write_location( - thermostat_temperature_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -98,8 +85,3 @@ async def to_code(config): mv, numb, power_level_config, MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL ) cg.add(numb.set_micronova_object(mv)) - cg.add( - numb.set_memory_write_location( - power_level_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 66e81e98b7..c71d819ad6 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -36,7 +36,7 @@ void MicroNovaNumber::control(float value) { default: break; } - this->micronova_->write_address(this->memory_write_location_, this->memory_address_, new_number); + this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); this->micronova_->request_update_listeners(); } diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index e1545bb35a..391765b730 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -18,12 +18,6 @@ class MicroNovaNumber : public number::Number, public MicroNovaListener { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; - - void set_memory_write_location(uint8_t l) { this->memory_write_location_ = l; } - uint8_t get_memory_write_location() { return this->memory_write_location_; } - - protected: - uint8_t memory_write_location_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 006ada92aa..c6897d8e5c 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -28,7 +28,7 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x80, + default_memory_location=0x00, default_memory_address=0x21, is_polling_component=False, ) diff --git a/tests/components/micronova/common.yaml b/tests/components/micronova/common.yaml index 73456aa199..660970350a 100644 --- a/tests/components/micronova/common.yaml +++ b/tests/components/micronova/common.yaml @@ -5,7 +5,7 @@ button: - platform: micronova custom_button: name: Custom Micronova Button - memory_location: 0xA0 + memory_location: 0x20 memory_address: 0x7D memory_data: 0x0F From c7382fc494796dc89f19967c50c61d3c9319e27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Kub=C3=AD=C4=8Dek?= Date: Mon, 8 Dec 2025 15:07:10 +0100 Subject: [PATCH 06/40] [hlw8032] Single-phase metering IC (#7241) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hlw8032/__init__.py | 1 + esphome/components/hlw8032/hlw8032.cpp | 194 ++++++++++++++++++ esphome/components/hlw8032/hlw8032.h | 44 ++++ esphome/components/hlw8032/sensor.py | 93 +++++++++ tests/components/hlw8032/common.yaml | 17 ++ tests/components/hlw8032/test.esp32-idf.yaml | 4 + .../components/hlw8032/test.esp8266-ard.yaml | 4 + tests/components/hlw8032/test.rp2040-ard.yaml | 4 + 9 files changed, 362 insertions(+) create mode 100644 esphome/components/hlw8032/__init__.py create mode 100644 esphome/components/hlw8032/hlw8032.cpp create mode 100644 esphome/components/hlw8032/hlw8032.h create mode 100644 esphome/components/hlw8032/sensor.py create mode 100644 tests/components/hlw8032/common.yaml create mode 100644 tests/components/hlw8032/test.esp32-idf.yaml create mode 100644 tests/components/hlw8032/test.esp8266-ard.yaml create mode 100644 tests/components/hlw8032/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 1fb6e111b7..2cd1453e12 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal esphome/components/hlk_fm22x/* @OnFreund +esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter diff --git a/esphome/components/hlw8032/__init__.py b/esphome/components/hlw8032/__init__.py new file mode 100644 index 0000000000..4908e10037 --- /dev/null +++ b/esphome/components/hlw8032/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@rici4kubicek"] diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp new file mode 100644 index 0000000000..55e6664a8b --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -0,0 +1,194 @@ +#include "hlw8032.h" +#include "esphome/core/log.h" +#include + +namespace esphome::hlw8032 { + +static const char *const TAG = "hlw8032"; + +static constexpr uint8_t STATE_REG_OFFSET = 0; +static constexpr uint8_t VOLTAGE_PARAM_OFFSET = 2; +static constexpr uint8_t VOLTAGE_REG_OFFSET = 5; +static constexpr uint8_t CURRENT_PARAM_OFFSET = 8; +static constexpr uint8_t CURRENT_REG_OFFSET = 11; +static constexpr uint8_t POWER_PARAM_OFFSET = 14; +static constexpr uint8_t POWER_REG_OFFSET = 17; +static constexpr uint8_t DATA_UPDATE_REG_OFFSET = 20; +static constexpr uint8_t CHECKSUM_REG_OFFSET = 23; +static constexpr uint8_t PARAM_REG_USABLE_BIT = (1 << 0); +static constexpr uint8_t POWER_OVERFLOW_BIT = (1 << 1); +static constexpr uint8_t CURRENT_OVERFLOW_BIT = (1 << 2); +static constexpr uint8_t VOLTAGE_OVERFLOW_BIT = (1 << 3); +static constexpr uint8_t HAVE_POWER_BIT = (1 << 4); +static constexpr uint8_t HAVE_CURRENT_BIT = (1 << 5); +static constexpr uint8_t HAVE_VOLTAGE_BIT = (1 << 6); +static constexpr uint8_t CHECK_REG = 0x5A; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_NORMAL = 0x55; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_FAIL = 0xAA; +static constexpr uint8_t STATE_REG_CORRECTION_MASK = 0xF0; +static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; +static constexpr uint8_t PACKET_LENGTH = 24; + +void HLW8032Component::loop() { + while (this->available()) { + uint8_t data = this->read(); + if (!this->header_found_) { + if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || + (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + this->header_found_ = true; + this->raw_data_[0] = data; + } + } else if (data == CHECK_REG) { + this->raw_data_[1] = data; + this->raw_data_index_ = 2; + this->check_ = 0; + } else if (this->raw_data_index_ >= 2 && this->raw_data_index_ < PACKET_LENGTH) { + this->raw_data_[this->raw_data_index_++] = data; + if (this->raw_data_index_ < PACKET_LENGTH) { + this->check_ += data; + } else if (this->raw_data_index_ == PACKET_LENGTH) { + if (this->check_ == this->raw_data_[CHECKSUM_REG_OFFSET]) { + this->parse_data_(); + } else { + ESP_LOGW(TAG, "Invalid checksum: 0x%02X != 0x%02X", this->check_, this->raw_data_[CHECKSUM_REG_OFFSET]); + } + this->raw_data_index_ = 0; + this->header_found_ = false; + memset(this->raw_data_, 0, PACKET_LENGTH); + } + } + } +} + +uint32_t HLW8032Component::read_uint24_(uint8_t offset) { + return encode_uint24(this->raw_data_[offset], this->raw_data_[offset + 1], this->raw_data_[offset + 2]); +} + +void HLW8032Component::parse_data_() { + // Parse header + uint8_t state_reg = this->raw_data_[STATE_REG_OFFSET]; + + if (state_reg == STATE_REG_CORRECTION_FUNC_FAIL) { + ESP_LOGE(TAG, "The chip's function of error correction fails."); + return; + } + + // Parse data frame + uint32_t voltage_parameter = this->read_uint24_(VOLTAGE_PARAM_OFFSET); + uint32_t voltage_reg = this->read_uint24_(VOLTAGE_REG_OFFSET); + uint32_t current_parameter = this->read_uint24_(CURRENT_PARAM_OFFSET); + uint32_t current_reg = this->read_uint24_(CURRENT_REG_OFFSET); + uint32_t power_parameter = this->read_uint24_(POWER_PARAM_OFFSET); + uint32_t power_reg = this->read_uint24_(POWER_REG_OFFSET); + uint8_t data_update_register = this->raw_data_[DATA_UPDATE_REG_OFFSET]; + + bool have_power = data_update_register & HAVE_POWER_BIT; + bool have_current = data_update_register & HAVE_CURRENT_BIT; + bool have_voltage = data_update_register & HAVE_VOLTAGE_BIT; + + bool power_cycle_exceeds_range = false; + bool parameter_regs_usable = true; + + if ((state_reg & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + if (state_reg & STATE_REG_OVERFLOW_MASK) { + if (state_reg & VOLTAGE_OVERFLOW_BIT) { + have_voltage = false; + } + if (state_reg & CURRENT_OVERFLOW_BIT) { + have_current = false; + } + if (state_reg & POWER_OVERFLOW_BIT) { + have_power = false; + } + if (state_reg & PARAM_REG_USABLE_BIT) { + parameter_regs_usable = false; + } + + ESP_LOGW(TAG, + "Reports: (0x%02X)\n" + " Voltage REG overflows: %s\n" + " Current REG overflows: %s\n" + " Power REG overflows: %s\n" + " Voltage/Current/Power Parameter REGs not usable: %s\n", + state_reg, YESNO(!have_voltage), YESNO(!have_current), YESNO(!have_power), + YESNO(!parameter_regs_usable)); + + if (!parameter_regs_usable) { + return; + } + } + power_cycle_exceeds_range = have_power; + } + + ESP_LOGVV(TAG, + "Parsed data:\n" + " Voltage: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Current: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Power: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Data Update: REG 0x%02" PRIX8 "\n", + voltage_parameter, voltage_reg, current_parameter, current_reg, power_parameter, power_reg, + data_update_register); + + const float current_multiplier = 1 / (this->current_resistor_ * 1000); + + float voltage = 0.0f; + if (have_voltage && voltage_reg) { + voltage = float(voltage_parameter) * this->voltage_divider_ / float(voltage_reg); + } + if (this->voltage_sensor_ != nullptr) { + this->voltage_sensor_->publish_state(voltage); + } + + float power = 0.0f; + if (have_power && power_reg && !power_cycle_exceeds_range) { + power = (float(power_parameter) / float(power_reg)) * this->voltage_divider_ * current_multiplier; + } + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + + float current = 0.0f; + if (have_current && current_reg) { + current = float(current_parameter) * current_multiplier / float(current_reg); + } + if (this->current_sensor_ != nullptr) { + this->current_sensor_->publish_state(current); + } + + float pf = NAN; + const float apparent_power = voltage * current; + if (have_voltage && have_current) { + if (have_power || power_cycle_exceeds_range) { + if (apparent_power > 0) { + pf = power / apparent_power; + if (pf < 0 || pf > 1) { + ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); + pf = NAN; + } + } else if (apparent_power == 0 && power == 0) { + // No load, report ideal power factor + pf = 1.0f; + } + } + } + if (this->apparent_power_sensor_ != nullptr) { + this->apparent_power_sensor_->publish_state(apparent_power); + } + if (this->power_factor_sensor_ != nullptr) { + this->power_factor_sensor_->publish_state(pf); + } +} + +void HLW8032Component::dump_config() { + ESP_LOGCONFIG(TAG, + "Configuration:\n" + " Current resistor: %.1f mΩ\n" + " Voltage Divider: %.3f", + this->current_resistor_ * 1000.0f, this->voltage_divider_); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Power", this->power_sensor_); + LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_); +} +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h new file mode 100644 index 0000000000..d4c7dbd26c --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::hlw8032 { + +class HLW8032Component : public Component, public uart::UARTDevice { + public: + void loop() override; + void dump_config() override; + + void set_current_resistor(float current_resistor) { this->current_resistor_ = current_resistor; } + void set_voltage_divider(float voltage_divider) { this->voltage_divider_ = voltage_divider; } + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } + void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } + void set_power_sensor(sensor::Sensor *power_sensor) { this->power_sensor_ = power_sensor; } + void set_apparent_power_sensor(sensor::Sensor *apparent_power_sensor) { + this->apparent_power_sensor_ = apparent_power_sensor; + } + void set_power_factor_sensor(sensor::Sensor *power_factor_sensor) { + this->power_factor_sensor_ = power_factor_sensor; + } + + protected: + void parse_data_(); + uint32_t read_uint24_(uint8_t offset); + + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + + float current_resistor_{0.001f}; + float voltage_divider_{1.720f}; + uint8_t raw_data_[24]{}; + uint8_t check_{0}; + uint8_t raw_data_index_{0}; + bool header_found_{false}; +}; + +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py new file mode 100644 index 0000000000..96800e46f4 --- /dev/null +++ b/esphome/components/hlw8032/sensor.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_APPARENT_POWER, + CONF_CURRENT, + CONF_CURRENT_RESISTOR, + CONF_ID, + CONF_POWER, + CONF_POWER_FACTOR, + CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) + +DEPENDENCIES = ["uart"] + +hlw8032_ns = cg.esphome_ns.namespace("hlw8032") +HLW8032Component = hlw8032_ns.class_("HLW8032Component", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(HLW8032Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_RESISTOR, default=0.001): cv.resistance, + cv.Optional(CONF_VOLTAGE_DIVIDER, default=1.720): cv.positive_float, + } +).extend(uart.UART_DEVICE_SCHEMA) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hlw8032", baud_rate=4800, require_rx=True, data_bits=8, parity="EVEN" +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if voltage_config := config.get(CONF_VOLTAGE): + sens = await sensor.new_sensor(voltage_config) + cg.add(var.set_voltage_sensor(sens)) + if current_config := config.get(CONF_CURRENT): + sens = await sensor.new_sensor(current_config) + cg.add(var.set_current_sensor(sens)) + if power_config := config.get(CONF_POWER): + sens = await sensor.new_sensor(power_config) + cg.add(var.set_power_sensor(sens)) + if apparent_power_config := config.get(CONF_APPARENT_POWER): + sens = await sensor.new_sensor(apparent_power_config) + cg.add(var.set_apparent_power_sensor(sens)) + if power_factor_config := config.get(CONF_POWER_FACTOR): + sens = await sensor.new_sensor(power_factor_config) + cg.add(var.set_power_factor_sensor(sens)) + cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) + cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) diff --git a/tests/components/hlw8032/common.yaml b/tests/components/hlw8032/common.yaml new file mode 100644 index 0000000000..1b4e537576 --- /dev/null +++ b/tests/components/hlw8032/common.yaml @@ -0,0 +1,17 @@ +sensor: + - platform: hlw8032 + voltage: + name: HLW8032 Voltage + id: hlw8032_voltage + current: + name: HLW8032 Current + id: hlw8032_current + power: + name: HLW8032 Power + id: hlw8032_power + apparent_power: + name: HLW8032 Apparent Power + id: hlw8032_apparent_power + power_factor: + name: HLW8032 Power Factor + id: hlw8032_power_factor diff --git a/tests/components/hlw8032/test.esp32-idf.yaml b/tests/components/hlw8032/test.esp32-idf.yaml new file mode 100644 index 0000000000..911b867708 --- /dev/null +++ b/tests/components/hlw8032/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.esp8266-ard.yaml b/tests/components/hlw8032/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9c1c11c6a1 --- /dev/null +++ b/tests/components/hlw8032/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.rp2040-ard.yaml b/tests/components/hlw8032/test.rp2040-ard.yaml new file mode 100644 index 0000000000..40b6e81bb2 --- /dev/null +++ b/tests/components/hlw8032/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/rp2040-ard.yaml + +<<: !include common.yaml From 4466c4c69fdec95113e8449b4420938373b636be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 15:09:04 +0100 Subject: [PATCH 07/40] [libretiny] Fix WiFi scan timeout loop when scan fails (#12356) --- esphome/components/wifi/wifi_component_libretiny.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 1a6f037a87..d6bc8e53da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -445,6 +445,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } void WiFiComponent::wifi_scan_done_callback_() { this->scan_result_.clear(); + this->scan_done_ = true; int16_t num = WiFi.scanComplete(); if (num < 0) @@ -463,7 +464,6 @@ void WiFiComponent::wifi_scan_done_callback_() { ssid.length() == 0); } WiFi.scanDelete(); - this->scan_done_ = true; #ifdef USE_WIFI_LISTENERS for (auto *listener : this->scan_results_listeners_) { listener->on_wifi_scan_results(this->scan_result_); From 5144154f911f6ac06a7017f0b47b26d024d9e2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Blanchet?= <120399978+arno1801@users.noreply.github.com> Date: Mon, 8 Dec 2025 09:31:05 -0500 Subject: [PATCH 08/40] [hub75] fix id conflict (#12365) --- tests/components/hub75/test.esp32-idf.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml index c275d24187..9f6bd57292 100644 --- a/tests/components/hub75/test.esp32-idf.yaml +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -25,15 +25,15 @@ display: oe_pin: GPIO15 clk_pin: GPIO16 pages: - - id: page1 + - id: page1_hub75 lambda: |- it.rectangle(0, 0, it.get_width(), it.get_height()); - - id: page2 + - id: page2_hub75 lambda: |- it.rectangle(0, 0, it.get_width(), it.get_height()); on_page_change: - from: page1 - to: page2 + from: page1_hub75 + to: page2_hub75 then: lambda: |- ESP_LOGD("display", "1 -> 2"); From eda743ee481a75424933e35ed591dc1d933198de Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 8 Dec 2025 08:50:23 -0600 Subject: [PATCH 09/40] [usb_cdc_acm] New component (#11687) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/usb_cdc_acm/__init__.py | 76 +++ .../components/usb_cdc_acm/usb_cdc_acm.cpp | 495 ++++++++++++++++++ esphome/components/usb_cdc_acm/usb_cdc_acm.h | 135 +++++ .../usb_cdc_acm/test.esp32-p4-idf.yaml | 5 + .../usb_cdc_acm/test.esp32-s2-idf.yaml | 5 + .../usb_cdc_acm/test.esp32-s3-idf.yaml | 6 + .../usb_cdc_acm/tinyusb_common.yaml | 8 + 8 files changed, 731 insertions(+) create mode 100644 esphome/components/usb_cdc_acm/__init__.py create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm.cpp create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm.h create mode 100644 tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml create mode 100644 tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml create mode 100644 tests/components/usb_cdc_acm/tinyusb_common.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2cd1453e12..af926d2d61 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -524,6 +524,7 @@ esphome/components/ufire_ise/* @pvizeli esphome/components/ultrasonic/* @OttoWinter esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py new file mode 100644 index 0000000000..6693d8e75e --- /dev/null +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -0,0 +1,76 @@ +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.esp32 import ( + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + add_idf_sdkconfig_option, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["uart"] +DEPENDENCIES = ["tinyusb"] + +CONF_INTERFACES = "interfaces" + +usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") +USBCDCACMComponent = usb_cdc_acm_ns.class_("USBCDCACMComponent", cg.Component) +USBCDCACMInstance = usb_cdc_acm_ns.class_( + "USBCDCACMInstance", uart.UARTComponent, cg.Parented.template(USBCDCACMComponent) +) + + +# Schema for individual CDC ACM interface instances +INTERFACE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + } +) + +# Main component schema +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMComponent), + cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_TX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_INTERFACES, default=[{}]): cv.All( + cv.ensure_list(INTERFACE_SCHEMA), + cv.Length(min=1, max=2), # At least 1, at most 2 interfaces + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Create and register interface instances + for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): + interface = cg.new_Pvariable(interface_conf[CONF_ID]) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(interface_index)) + cg.add(var.add_interface(interface)) + + # Configure TinyUSB with the correct number of CDC interfaces + num_interfaces = len(config[CONF_INTERFACES]) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp new file mode 100644 index 0000000000..1cf614286f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -0,0 +1,495 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/ringbuf.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "tusb.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const char *TAG = "usb_cdc_acm"; + +static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; +static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; + +// Global component instance for managing USB device +USBCDCACMComponent *global_usb_cdc_component = nullptr; + +static USBCDCACMInstance *get_instance_by_itf(int itf) { + if (global_usb_cdc_component == nullptr) { + return nullptr; + } + return global_usb_cdc_component->get_interface_by_number(itf); +} + +static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "RX callback: invalid interface %d", itf); + return; + } + + size_t rx_size = 0; + static uint8_t rx_buf[CONFIG_TINYUSB_CDC_RX_BUFSIZE] = {0}; + + // read from USB + esp_err_t ret = + tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); + ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); + ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty(rx_buf, rx_size).c_str()); + + if (ret == ESP_OK && rx_size > 0) { + RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); + if (rx_ringbuf != nullptr) { + BaseType_t send_res = xRingbufferSend(rx_ringbuf, rx_buf, rx_size, 0); + if (send_res != pdTRUE) { + ESP_LOGE(TAG, "USB RX itf=%d: buffer full, %u bytes lost", itf, rx_size); + } else { + ESP_LOGV(TAG, "USB RX itf=%d: queued %u bytes", itf, rx_size); + } + } + } +} + +static void tinyusb_cdc_line_state_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line state callback: invalid interface %d", itf); + return; + } + + int dtr = event->line_state_changed_data.dtr; + int rts = event->line_state_changed_data.rts; + ESP_LOGV(TAG, "Line state itf=%d: DTR=%d, RTS=%d", itf, dtr, rts); + + // Queue event for processing in main loop + instance->queue_line_state_event(dtr != 0, rts != 0); +} + +static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line coding callback: invalid interface %d", itf); + return; + } + + uint32_t bit_rate = event->line_coding_changed_data.p_line_coding->bit_rate; + uint8_t stop_bits = event->line_coding_changed_data.p_line_coding->stop_bits; + uint8_t parity = event->line_coding_changed_data.p_line_coding->parity; + uint8_t data_bits = event->line_coding_changed_data.p_line_coding->data_bits; + ESP_LOGV(TAG, "Line coding itf=%d: bit_rate=%" PRIu32 " stop_bits=%u parity=%u data_bits=%u", itf, bit_rate, + stop_bits, parity, data_bits); + + // Queue event for processing in main loop + instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); +} + +static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t xTicksToWait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + + return ESP_OK; +} + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== + +void USBCDCACMInstance::setup() { + this->usb_tx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_TX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_tx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB TX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + this->usb_rx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_RX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_rx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB RX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + // Configure this CDC interface + const tinyusb_config_cdcacm_t acm_cfg = { + .usb_dev = TINYUSB_USBDEV_0, + .cdc_port = this->itf_, + .callback_rx = &tinyusb_cdc_rx_callback, + .callback_rx_wanted_char = NULL, + .callback_line_state_changed = &tinyusb_cdc_line_state_changed_callback, + .callback_line_coding_changed = &tinyusb_cdc_line_coding_changed_callback, + }; + + esp_err_t result = tusb_cdc_acm_init(&acm_cfg); + if (result != ESP_OK) { + ESP_LOGE(TAG, "tusb_cdc_acm_init failed: %d", result); + this->parent_->mark_failed(); + return; + } + + // Use a larger stack size for (very) verbose logging + const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE; + + // Create a simple, unique task name per interface + char task_name[] = "usb_tx_0"; + task_name[sizeof(task_name) - 1] = format_hex_char(static_cast(this->itf_)); + xTaskCreate(usb_tx_task_fn, task_name, stack_size, this, 4, &this->usb_tx_task_handle_); + + if (this->usb_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create USB TX task for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } +} + +void USBCDCACMInstance::loop() { + // Process events from the lock-free queue + this->process_events_(); +} + +void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line state event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_STATE_CHANGED; + event->data.line_state.dtr = dtr; + event->data.line_state.rts = rts; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line state event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, + uint8_t data_bits) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line coding event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_CODING_CHANGED; + event->data.line_coding.bit_rate = bit_rate; + event->data.line_coding.stop_bits = stop_bits; + event->data.line_coding.parity = parity; + event->data.line_coding.data_bits = data_bits; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line coding event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::process_events_() { + // Process all pending events from the queue + CDCEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + switch (event->type) { + case CDC_EVENT_LINE_STATE_CHANGED: { + bool dtr = event->data.line_state.dtr; + bool rts = event->data.line_state.rts; + + // Invoke user callback in main loop context + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + break; + } + case CDC_EVENT_LINE_CODING_CHANGED: { + uint32_t bit_rate = event->data.line_coding.bit_rate; + uint8_t stop_bits = event->data.line_coding.stop_bits; + uint8_t parity = event->data.line_coding.parity; + uint8_t data_bits = event->data.line_coding.data_bits; + + // Update UART configuration based on CDC line coding + this->baud_rate_ = bit_rate; + this->data_bits_ = data_bits; + + // Convert CDC stop bits to UART stop bits format + // CDC: 0=1 stop bit, 1=1.5 stop bits, 2=2 stop bits + this->stop_bits_ = (stop_bits == 0) ? 1 : (stop_bits == 1) ? 1 : 2; + + // Convert CDC parity to UART parity format + // CDC: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + switch (parity) { + case 0: + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + case 1: + this->parity_ = uart::UART_CONFIG_PARITY_ODD; + break; + case 2: + this->parity_ = uart::UART_CONFIG_PARITY_EVEN; + break; + default: + // Mark and Space parity are not commonly supported, default to None + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + } + + // Invoke user callback in main loop context + if (this->line_coding_callback_ != nullptr) { + this->line_coding_callback_(bit_rate, stop_bits, parity, data_bits); + } + break; + } + } + // Return event to pool for reuse + this->event_pool_.release(event); + } +} + +void USBCDCACMInstance::usb_tx_task_fn(void *arg) { + auto *instance = static_cast(arg); + instance->usb_tx_task(); +} + +void USBCDCACMInstance::usb_tx_task() { + uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + size_t tx_data_size = 0; + + while (1) { + // Wait for a notification from the bridge component + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + // When we do wake up, we can be sure there is data in the ring buffer + esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); + + if (ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: RingBuf read failed", this->itf_); + continue; + } else if (tx_data_size == 0) { + ESP_LOGD(TAG, "USB TX itf=%d: RingBuf empty, skipping", this->itf_); + continue; + } + + ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); + ESP_LOGVV(TAG, "data = %s", format_hex_pretty(data, tx_data_size).c_str()); + + // Serial data will be split up into 64 byte chunks to be sent over USB so this + // usually will take multiple iterations + uint8_t *data_head = &data[0]; + + while (tx_data_size > 0) { + size_t queued = tinyusb_cdcacm_write_queue(this->itf_, data_head, tx_data_size); + ESP_LOGV(TAG, "USB TX itf=%d: enqueued: size=%d, queued=%u", this->itf_, tx_data_size, queued); + + tx_data_size -= queued; + data_head += queued; + + ESP_LOGV(TAG, "USB TX itf=%d: waiting 10ms for flush", this->itf_); + esp_err_t flush_ret = tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(10)); + + if (flush_ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); + tud_cdc_n_write_clear(this->itf_); + break; + } + } + } +} + +//============================================================================== +// UARTComponent Interface Implementation +//============================================================================== + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (len == 0) { + return; + } + + // Write data to TX ring buffer + BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); + if (send_res != pdTRUE) { + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + return; + } + + // Notify TX task that data is available + if (this->usb_tx_task_handle_ != nullptr) { + xTaskNotifyGive(this->usb_tx_task_handle_); + } +} + +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + + size_t original_len = len; + size_t bytes_read = 0; + + // First, use the peek buffer if available + if (this->has_peek_) { + data[0] = this->peek_buffer_; + this->has_peek_ = false; + bytes_read = 1; + data++; + if (--len == 0) { // Decrement len first, then check it... + return true; // No more to read + } + } + + // Read remaining bytes from RX ring buffer + size_t rx_size = 0; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + data += rx_size; + len -= rx_size; + if (len == 0) { + return true; // No more to read + } + + // Buffer's data may wrap around, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + + return bytes_read == original_len; +} + +int USBCDCACMInstance::available() { + UBaseType_t waiting = 0; + if (this->usb_rx_ringbuf_ != nullptr) { + vRingbufferGetInfo(this->usb_rx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + } + return static_cast(waiting) + (this->has_peek_ ? 1 : 0); +} + +void USBCDCACMInstance::flush() { + // Wait for TX ring buffer to be empty + if (this->usb_tx_ringbuf_ == nullptr) { + return; + } + + UBaseType_t waiting = 1; + while (waiting > 0) { + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + if (waiting > 0) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + } + + // Also wait for USB to finish transmitting + tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(100)); +} + +//============================================================================== +// USBCDCACMComponent Implementation +//============================================================================== + +USBCDCACMComponent::USBCDCACMComponent() { global_usb_cdc_component = this; } + +void USBCDCACMComponent::setup() { + // Setup all registered interfaces + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->setup(); + } + } +} + +void USBCDCACMComponent::loop() { + // Call loop() on all registered interfaces to process events + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->loop(); + } + } +} + +void USBCDCACMComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "USB CDC-ACM:\n" + " Number of Interfaces: %d", + this->interfaces_[MAX_USB_CDC_INSTANCES - 1] != nullptr ? MAX_USB_CDC_INSTANCES : 1); +} + +void USBCDCACMComponent::add_interface(USBCDCACMInstance *interface) { + uint8_t itf_num = static_cast(interface->get_itf()); + if (itf_num < MAX_USB_CDC_INSTANCES) { + this->interfaces_[itf_num] = interface; + } else { + ESP_LOGE(TAG, "Interface number must be less than %u", MAX_USB_CDC_INSTANCES); + } +} + +USBCDCACMInstance *USBCDCACMComponent::get_interface_by_number(uint8_t itf) { + for (auto interface : this->interfaces_) { + if ((interface != nullptr) && (interface->get_itf() == static_cast(itf))) { + return interface; + } + } + return nullptr; +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h new file mode 100644 index 0000000000..8c00f5d52f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -0,0 +1,135 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "esphome/components/uart/uart_component.h" + +#include +#include "freertos/ringbuf.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const uint8_t EVENT_QUEUE_SIZE = 12; +static const uint8_t MAX_USB_CDC_INSTANCES = 2; + +// Callback types for line coding and line state changes +using LineCodingCallback = std::function; +using LineStateCallback = std::function; + +// Event types +enum CDCEventType : uint8_t { + CDC_EVENT_LINE_STATE_CHANGED, + CDC_EVENT_LINE_CODING_CHANGED, +}; + +// Event structure for the queue +struct CDCEvent { + CDCEventType type; + union { + struct { + bool dtr; + bool rts; + } line_state; + struct { + uint32_t bit_rate; + uint8_t stop_bits; + uint8_t parity; + uint8_t data_bits; + } line_coding; + } data; + + // Required by EventPool - called before returning to pool + void release() { + // No dynamic memory to clean up, data is stored inline + } +}; + +// Forward declaration +class USBCDCACMComponent; + +/// Represents a single CDC ACM interface instance +class USBCDCACMInstance : public uart::UARTComponent, public Parented { + public: + void set_interface_number(uint8_t itf) { this->itf_ = static_cast(itf); } + + void setup(); + void loop(); + + // Get the CDC port number for this instance + tinyusb_cdcacm_itf_t get_itf() const { return this->itf_; } + + // Ring buffer accessors for bridge components + RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } + RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } + + // Task handle accessor for notifying TX task + TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } + + // Callback registration for line coding and line state changes + void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } + void set_line_state_callback(LineStateCallback callback) { this->line_state_callback_ = std::move(callback); } + + // Called from TinyUSB task context (SPSC producer) - queues event for processing in main loop + void queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, uint8_t data_bits); + void queue_line_state_event(bool dtr, bool rts); + + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + + // UARTComponent interface implementation + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + int available() override; + void flush() override; + + protected: + void check_logger_conflict() override {} + + // Process queued events and invoke callbacks (called from main loop) + void process_events_(); + + TaskHandle_t usb_tx_task_handle_{nullptr}; + tinyusb_cdcacm_itf_t itf_{TINYUSB_CDC_ACM_0}; + + RingbufHandle_t usb_tx_ringbuf_{nullptr}; + RingbufHandle_t usb_rx_ringbuf_{nullptr}; + + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; + + // Lock-free queue and event pool for cross-task event passing + EventPool event_pool_; + LockFreeQueue event_queue_; + + // RX buffer for peek functionality + uint8_t peek_buffer_{0}; + bool has_peek_{false}; +}; + +/// Main USB CDC ACM component that manages the USB device and all CDC interfaces +class USBCDCACMComponent : public Component { + public: + USBCDCACMComponent(); + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + // Interface management + void add_interface(USBCDCACMInstance *interface); + USBCDCACMInstance *get_interface_by_number(uint8_t itf); + + protected: + std::array interfaces_{nullptr, nullptr}; +}; + +extern USBCDCACMComponent *global_usb_cdc_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..4786c96bcc --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..f159b38ff6 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..6913fe21d5 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml @@ -0,0 +1,6 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/tinyusb_common.yaml b/tests/components/usb_cdc_acm/tinyusb_common.yaml new file mode 100644 index 0000000000..cb3f48836a --- /dev/null +++ b/tests/components/usb_cdc_acm/tinyusb_common.yaml @@ -0,0 +1,8 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 From 7e486b1c259b5a1304160ebb716cbdc626fe7b10 Mon Sep 17 00:00:00 2001 From: Johannes Nau Date: Mon, 8 Dec 2025 16:34:26 +0100 Subject: [PATCH 10/40] [pca9685] Allow to disable the phase balancer for PCA9685 (#9792) --- esphome/components/pca9685/__init__.py | 17 ++++++++++++++++- esphome/components/pca9685/pca9685_output.cpp | 13 ++++++++++++- esphome/components/pca9685/pca9685_output.h | 7 +++++++ tests/components/pca9685/common.yaml | 1 + 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 50f58cdfb9..56101c2d62 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_EXTERNAL_CLOCK_INPUT, CONF_FREQUENCY, CONF_ID +from esphome.const import ( + CONF_EXTERNAL_CLOCK_INPUT, + CONF_FREQUENCY, + CONF_ID, + CONF_PHASE_BALANCER, +) DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -9,6 +14,12 @@ MULTI_CONF = True pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) +phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) +PHASE_BALANCERS = { + "none": phase_balancer.NONE, + "linear": phase_balancer.LINEAR, +} + def validate_frequency(config): if config[CONF_EXTERNAL_CLOCK_INPUT]: @@ -30,6 +41,9 @@ CONFIG_SCHEMA = cv.All( cv.frequency, cv.Range(min=23.84, max=1525.88) ), cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean, + cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum( + PHASE_BALANCERS + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -43,5 +57,6 @@ async def to_code(config): if CONF_FREQUENCY in config: cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_extclk(config[CONF_EXTERNAL_CLOCK_INPUT])) + cg.add(var.set_phase_balancer(config[CONF_PHASE_BALANCER])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 6df708ac84..77e3d5a6c6 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -105,7 +105,18 @@ void PCA9685Output::loop() { const uint16_t num_channels = this->max_channel_ - this->min_channel_ + 1; const uint16_t phase_delta_begin = 4096 / num_channels; for (uint8_t channel = this->min_channel_; channel <= this->max_channel_; channel++) { - uint16_t phase_begin = (channel - this->min_channel_) * phase_delta_begin; + uint16_t phase_begin; + switch (this->balancer_) { + case PhaseBalancer::NONE: + phase_begin = 0; + break; + case PhaseBalancer::LINEAR: + phase_begin = (channel - this->min_channel_) * phase_delta_begin; + break; + default: + ESP_LOGE(TAG, "Unknown phase balancer %d", static_cast(this->balancer_)); + return; + } uint16_t phase_end; uint16_t amount = this->pwm_amounts_[channel]; if (amount == 0) { diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 8e547d0032..288c923d4c 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -7,6 +7,11 @@ namespace esphome { namespace pca9685 { +enum class PhaseBalancer { + NONE = 0x00, + LINEAR = 0x01, +}; + /// Inverts polarity of channel output signal extern const uint8_t PCA9685_MODE_INVERTED; /// Channel update happens upon ACK (post-set) rather than on STOP (endTransmission) @@ -47,6 +52,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { void loop() override; void set_extclk(bool extclk) { this->extclk_ = extclk; } void set_frequency(float frequency) { this->frequency_ = frequency; } + void set_phase_balancer(PhaseBalancer balancer) { this->balancer_ = balancer; } protected: friend PCA9685Channel; @@ -60,6 +66,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { float frequency_; uint8_t mode_; bool extclk_ = false; + PhaseBalancer balancer_ = PhaseBalancer::LINEAR; uint8_t min_channel_{0xFF}; uint8_t max_channel_{0x00}; diff --git a/tests/components/pca9685/common.yaml b/tests/components/pca9685/common.yaml index 2e238b481c..9e2de6257a 100644 --- a/tests/components/pca9685/common.yaml +++ b/tests/components/pca9685/common.yaml @@ -2,6 +2,7 @@ pca9685: i2c_id: i2c_bus frequency: 500 address: 0x0 + phase_balancer: linear output: - platform: pca9685 From d635892ecf672c9077aa872f6acf03bf61b9aa26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 16:36:13 +0100 Subject: [PATCH 11/40] [core] Use StringRef for get_comment and get_compilation_time to avoid allocations (#12219) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/mqtt/mqtt_component.cpp | 2 +- esphome/components/sen5x/sen5x.cpp | 2 +- esphome/components/sgp30/sgp30.cpp | 2 +- esphome/components/sgp4x/sgp4x.cpp | 2 +- esphome/components/version/version_text_sensor.cpp | 2 +- esphome/components/web_server/web_server.cpp | 2 +- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/core/application.h | 2 ++ esphome/core/string_ref.h | 11 +++++++++++ 9 files changed, 20 insertions(+), 7 deletions(-) diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 1cd818964e..5d2bedae79 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time() + ")"; + device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 3298a5b8db..ffb9e2bc02 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -157,7 +157,7 @@ void SEN5XComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(combined_serial)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(combined_serial)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 9e8d6b332c..fa548ce94e 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -75,7 +75,7 @@ void SGP30Component::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->store_baseline_ && this->pref_.load(&this->baselines_storage_)) { diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 99d88006f7..a0c957d608 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -59,7 +59,7 @@ void SGP4xComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 65dbfd27cf..78d0fb501b 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -13,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time().c_str())); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index ca3aa21a95..0c22c2f08d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -287,7 +287,7 @@ std::string WebServer::get_config_json() { JsonObject root = builder.root(); root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root[ESPHOME_F("comment")] = App.get_comment(); + root[ESPHOME_F("comment")] = App.get_comment_ref(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ff33a81fcf..d46916bfd9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -360,7 +360,7 @@ void WiFiComponent::start() { get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); - uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL; + uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/core/application.h b/esphome/core/application.h index 14e800342e..8e2035b7c5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -256,6 +256,8 @@ class Application { /// Get the comment of this Application set by pre_setup(). std::string get_comment() const { return this->comment_; } + /// Get the comment as StringRef (avoids allocation) + StringRef get_comment_ref() const { return StringRef(this->comment_); } bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index efaa17181d..505fdd906a 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -128,6 +128,17 @@ inline std::string operator+(const StringRef &lhs, const char *rhs) { return str; } +inline std::string operator+(const StringRef &lhs, const std::string &rhs) { + auto str = lhs.str(); + str.append(rhs); + return str; +} + +inline std::string operator+(const std::string &lhs, const StringRef &rhs) { + std::string str(lhs); + str.append(rhs.c_str(), rhs.size()); + return str; +} #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } From 801d1135ab750ae6b88425786da598170d845d10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 16:37:51 +0100 Subject: [PATCH 12/40] [select] Add zero-copy support for API select commands (#12329) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 7 +++++-- esphome/components/api/api_pb2.h | 5 +++-- esphome/components/api/api_pb2_dump.cpp | 4 +++- esphome/components/select/select.cpp | 6 ++---- esphome/components/select/select.h | 5 +++-- esphome/components/select/select_call.cpp | 10 +++------- esphome/components/select/select_call.h | 10 ++++++---- 9 files changed, 27 insertions(+), 24 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3fc2e1fed8..2534ad0b1f 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1195,7 +1195,7 @@ message SelectCommandRequest { option (base_class) = "CommandProtoMessage"; fixed32 key = 1; - string state = 2; + string state = 2 [(pointer_to_buffer) = true]; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index f0428546de..18d80c46df 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -902,7 +902,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * } void APIConnection::select_command(const SelectCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(select::Select, select, select) - call.set_option(msg.state); + call.set_option(reinterpret_cast(msg.state), msg.state_len); call.perform(); } #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a3da6591f4..128f82fe7f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1569,9 +1569,12 @@ bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->state = value.as_string(); + case 2: { + // Use raw data directly to avoid allocation + this->state = value.data(); + this->state_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e41cd8a22..49f1ea3c52 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1604,11 +1604,12 @@ class SelectStateResponse final : public StateResponseProtoMessage { class SelectCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 54; - static constexpr uint8_t ESTIMATED_SIZE = 18; + static constexpr uint8_t ESTIMATED_SIZE = 28; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_command_request"; } #endif - std::string state{}; + const uint8_t *state{nullptr}; + uint16_t state_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 59fc1367fe..ca69d1ff00 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1453,7 +1453,9 @@ void SelectStateResponse::dump_to(std::string &out) const { void SelectCommandRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "SelectCommandRequest"); dump_field(out, "key", this->key); - dump_field(out, "state", this->state); + out.append(" state: "); + out.append(format_hex_pretty(this->state, this->state_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 3ec413f167..4fc4d79b08 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -56,12 +56,10 @@ size_t Select::size() const { return options.size(); } -optional Select::index_of(const std::string &option) const { return this->index_of(option.c_str()); } - -optional Select::index_of(const char *option) const { +optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { - if (strcmp(options[i], option) == 0) { + if (strncmp(options[i], option, len) == 0 && options[i][len] == '\0') { return i; } } diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index c4d7412d50..63707f6bd6 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -62,8 +62,9 @@ class Select : public EntityBase { size_t size() const; /// Find the (optional) index offset of the provided option value. - optional index_of(const std::string &option) const; - optional index_of(const char *option) const; + optional index_of(const char *option, size_t len) const; + optional index_of(const std::string &option) const { return this->index_of(option.data(), option.size()); } + optional index_of(const char *option) const { return this->index_of(option, strlen(option)); } /// Return the (optional) index offset of the currently active option. optional active_index() const; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index aecfed0d64..2ff99c961d 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -6,9 +6,7 @@ namespace esphome::select { static const char *const TAG = "select"; -SelectCall &SelectCall::set_option(const std::string &option) { return this->with_option(option); } - -SelectCall &SelectCall::set_option(const char *option) { return this->with_option(option); } +SelectCall &SelectCall::set_option(const char *option, size_t len) { return this->with_option(option, len); } SelectCall &SelectCall::set_index(size_t index) { return this->with_index(index); } @@ -32,12 +30,10 @@ SelectCall &SelectCall::with_cycle(bool cycle) { return *this; } -SelectCall &SelectCall::with_option(const std::string &option) { return this->with_option(option.c_str()); } - -SelectCall &SelectCall::with_option(const char *option) { +SelectCall &SelectCall::with_option(const char *option, size_t len) { this->operation_ = SELECT_OP_SET; // Find the option index - this validates the option exists - this->index_ = this->parent_->index_of(option); + this->index_ = this->parent_->index_of(option, len); return *this; } diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index b31d890ef6..c9abbc69a0 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -20,8 +20,9 @@ class SelectCall { explicit SelectCall(Select *parent) : parent_(parent) {} void perform(); - SelectCall &set_option(const std::string &option); - SelectCall &set_option(const char *option); + SelectCall &set_option(const char *option, size_t len); + SelectCall &set_option(const std::string &option) { return this->set_option(option.data(), option.size()); } + SelectCall &set_option(const char *option) { return this->set_option(option, strlen(option)); } SelectCall &set_index(size_t index); SelectCall &select_next(bool cycle); @@ -31,8 +32,9 @@ class SelectCall { SelectCall &with_operation(SelectOperation operation); SelectCall &with_cycle(bool cycle); - SelectCall &with_option(const std::string &option); - SelectCall &with_option(const char *option); + SelectCall &with_option(const char *option, size_t len); + SelectCall &with_option(const std::string &option) { return this->with_option(option.data(), option.size()); } + SelectCall &with_option(const char *option) { return this->with_option(option, strlen(option)); } SelectCall &with_index(size_t index); protected: From 9f60aed9b0b7a921c6f637142c8b36c5146b36ea Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 17:18:44 +0100 Subject: [PATCH 13/40] [micronova] Make stove switch entity independent (#12355) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/micronova/micronova.h | 20 ------------ .../components/micronova/switch/__init__.py | 17 +++++----- .../micronova/switch/micronova_switch.cpp | 31 +++++++++++++++---- .../micronova/switch/micronova_switch.h | 17 +++++----- .../text_sensor/micronova_text_sensor.cpp | 9 ------ 5 files changed, 44 insertions(+), 50 deletions(-) diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index a2eee81be8..1b2c06f07f 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -75,17 +75,6 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent bool needs_update_ = false; }; -class MicroNovaSwitchListener : public MicroNovaBaseListener { - public: - MicroNovaSwitchListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void set_stove_state(bool v) = 0; - virtual bool get_stove_state() = 0; - - protected: - uint8_t memory_data_on_ = 0; - uint8_t memory_data_off_ = 0; -}; - class MicroNovaButtonListener : public MicroNovaBaseListener { public: MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} @@ -112,15 +101,7 @@ class MicroNova : public Component, public uart::UARTDevice { void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } - void set_current_stove_state(uint8_t s) { this->current_stove_state_ = s; } - uint8_t get_current_stove_state() { return this->current_stove_state_; } - - void set_stove(MicroNovaSwitchListener *s) { this->stove_switch_ = s; } - MicroNovaSwitchListener *get_stove_switch() { return this->stove_switch_; } - protected: - uint8_t current_stove_state_ = 0; - GPIOPin *enable_rx_pin_{nullptr}; struct MicroNovaSerialTransmission { @@ -135,7 +116,6 @@ class MicroNova : public Component, public uart::UARTDevice { MicroNovaSerialTransmission current_transmission_; std::vector micronova_listeners_{}; - MicroNovaSwitchListener *stove_switch_{nullptr}; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index c6897d8e5c..62a8a0f008 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -4,20 +4,22 @@ import esphome.config_validation as cv from esphome.const import ICON_POWER from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) CONF_STOVE = "stove" CONF_MEMORY_DATA_ON = "memory_data_on" CONF_MEMORY_DATA_OFF = "memory_data_off" -MicroNovaSwitch = micronova_ns.class_("MicroNovaSwitch", switch.Switch, cg.Component) +MicroNovaSwitch = micronova_ns.class_( + "MicroNovaSwitch", switch.Switch, MicroNovaListener +) CONFIG_SCHEMA = cv.Schema( { @@ -30,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( MICRONOVA_ADDRESS_SCHEMA( default_memory_location=0x00, default_memory_address=0x21, - is_polling_component=False, + is_polling_component=True, ) ) .extend( @@ -48,9 +50,8 @@ async def to_code(config): if stove_config := config.get(CONF_STOVE): sw = await switch.new_switch(stove_config, mv) - cg.add(mv.set_stove(sw)) - cg.add(sw.set_memory_location(stove_config[CONF_MEMORY_LOCATION])) - cg.add(sw.set_memory_address(stove_config[CONF_MEMORY_ADDRESS])) + await to_code_micronova_listener( + mv, sw, stove_config, MicroNovaFunctions.STOVE_FUNCTION_SWITCH + ) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF])) - cg.add(sw.set_function(MicroNovaFunctions.STOVE_FUNCTION_SWITCH)) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 3777b6029d..76ef04da8a 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -4,27 +4,46 @@ namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: + case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { if (state) { // Only send power-on when current state is Off - if (this->micronova_->get_current_stove_state() == 0) { + if (this->raw_state_ == 0) { this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); this->publish_state(true); } else { - ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", micronova_->get_current_stove_state()); + ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); } } else { // don't send power-off when status is Off or Final cleaning - if (this->micronova_->get_current_stove_state() != 0 && micronova_->get_current_stove_state() != 6) { + if (this->raw_state_ != 0 && this->raw_state_ != 6) { this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); this->publish_state(false); } else { - ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", micronova_->get_current_stove_state()); + ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } } - this->micronova_->request_update_listeners(); + this->set_needs_update(true); break; + } + default: + break; + } +} +void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { + this->raw_state_ = value_from_stove; + if (value_from_stove == -1) { + ESP_LOGE(TAG, "Error reading stove state"); + return; + } + + switch (this->get_function()) { + case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { + // set the stove switch to on for any value but 0 + bool state = value_from_stove != 0; + this->publish_state(state); + break; + } default: break; } diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index ab83973ef7..96c2c14e9e 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,25 +6,28 @@ namespace esphome::micronova { -class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { +class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: - MicroNovaSwitch(MicroNova *m) : MicroNovaSwitchListener(m) {} + MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override { LOG_SWITCH("", "Micronova switch", this); this->dump_base_config(); } - - void set_stove_state(bool v) override { this->publish_state(v); } - bool get_stove_state() override { return this->state; } + void request_value_from_stove() override { + this->micronova_->request_address(this->memory_location_, this->memory_address_, this); + } + void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } - uint8_t get_memory_data_on() { return this->memory_data_on_; } void set_memory_data_off(uint8_t f) { this->memory_data_off_ = f; } - uint8_t get_memory_data_off() { return this->memory_data_off_; } protected: void write_state(bool state) override; + + uint8_t memory_data_on_ = 0; + uint8_t memory_data_off_ = 0; + uint8_t raw_state_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index b62fb1afce..d1c03f66c3 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -10,16 +10,7 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { switch (this->get_function()) { case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE: - this->micronova_->set_current_stove_state(value_from_stove); this->publish_state(STOVE_STATES[value_from_stove]); - // set the stove switch to on for any value but 0 - if (value_from_stove != 0 && this->micronova_->get_stove_switch() != nullptr && - !this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(true); - } else if (value_from_stove == 0 && this->micronova_->get_stove_switch() != nullptr && - this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(false); - } break; default: break; From 7a20c85eec2a25a40e4e146e320234cc6510592f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Blanchet?= <120399978+arno1801@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:12:15 -0500 Subject: [PATCH 14/40] [i2c] Fix port logic with ESP-IDF (#12063) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/esp32/gpio_esp32_c5.py | 15 ++++- esphome/components/esp32/gpio_esp32_c6.py | 15 ++++- esphome/components/esp32/gpio_esp32_p4.py | 16 ++++- esphome/components/i2c/__init__.py | 71 ++++++++++++++++++++++ esphome/components/i2c/i2c_bus_esp_idf.cpp | 41 ++++++++----- esphome/components/i2c/i2c_bus_esp_idf.h | 6 ++ esphome/const.py | 1 + 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index ada426771c..fa2ce1a689 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h +_ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} + _ESP32C5_SPI_PSRAM_PINS = { 16: "SPICS0", 17: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c5_validate_supports(value): check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c5_validate_lp_i2c(value): + lp_sda_pin = _ESP32C5_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C5_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C5" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index d466adb994..5d679dede2 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h +_ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} + _ESP32C6_SPI_PSRAM_PINS = { 24: "SPICS0", 25: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c6_validate_supports(value): check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c6_validate_lp_i2c(value): + lp_sda_pin = _ESP32C6_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C6_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C6" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 34d1b3139d..b98b567da2 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf +_ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + _ESP32P4_USB_JTAG_PINS = {24, 25} _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} @@ -36,3 +39,14 @@ def esp32_p4_validate_supports(value): pass check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value + + +def esp32_p4_validate_lp_i2c(value): + if ( + int(value[CONF_SDA]) not in _ESP32P4_LP_PINS + or int(value[CONF_SCL]) not in _ESP32P4_LP_PINS + ): + raise cv.Invalid( + f"Low power i2c interface for ESP32-P4 is only supported on low power interface GPIO{min(_ESP32P4_LP_PINS)} - GPIO{max(_ESP32P4_LP_PINS)}" + ) + return value diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 738568cd3c..9e7c9d702c 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -2,6 +2,23 @@ import logging from esphome import pins import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + get_esp32_variant, +) +from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -16,6 +33,7 @@ from esphome.const import ( CONF_I2C, CONF_I2C_ID, CONF_ID, + CONF_LOW_POWER_MODE, CONF_SCAN, CONF_SCL, CONF_SDA, @@ -40,6 +58,25 @@ IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") +ESP32_I2C_CAPABILITIES = { + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/soc_caps.h + VARIANT_ESP32: {"NUM": 2, "HP": 2}, + VARIANT_ESP32C2: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C3: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C5: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, + VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, + VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, +} +VALIDATE_LP_I2C = { + VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, + VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, + VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, +} +LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) CONF_SDA_PULLUP_ENABLED = "sda_pullup_enabled" CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" @@ -91,6 +128,13 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period, ), cv.Optional(CONF_SCAN, default=True): cv.boolean, + cv.Optional(CONF_LOW_POWER_MODE): cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=LP_I2C_VARIANT, msg_prefix="Low power i2c" + ), + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), @@ -102,6 +146,31 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.using_esp_idf and get_esp32_variant() in ESP32_I2C_CAPABILITIES: + variant = get_esp32_variant() + max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] + if len(full_config) > max_num: + raise cv.Invalid( + f"The maximum number of i2c interfaces for {variant} is {max_num}" + ) + if variant in LP_I2C_VARIANT: + max_lp_num = ESP32_I2C_CAPABILITIES[variant]["LP"] + max_hp_num = ESP32_I2C_CAPABILITIES[variant]["HP"] + lp_num = sum( + CONF_LOW_POWER_MODE in conf and conf[CONF_LOW_POWER_MODE] + for conf in full_config + ) + hp_num = len(full_config) - lp_num + if CONF_LOW_POWER_MODE in config and config[CONF_LOW_POWER_MODE]: + VALIDATE_LP_I2C[variant](config) + if lp_num > max_lp_num: + raise cv.Invalid( + f"The maximum number of low power i2c interfaces for {variant} is {max_lp_num}" + ) + if hp_num > max_hp_num: + raise cv.Invalid( + f"The maximum number of high power i2c interfaces for {variant} is {max_hp_num}" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -155,6 +224,8 @@ async def to_code(config): cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) if CORE.using_arduino and not CORE.is_esp32: cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index c22db51c68..486dc0b7d8 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -16,13 +16,10 @@ namespace i2c { static const char *const TAG = "i2c.idf"; void IDFI2CBus::setup() { - static i2c_port_t next_port = I2C_NUM_0; - this->port_ = next_port; - if (this->port_ == I2C_NUM_MAX) { - ESP_LOGE(TAG, "No more than %u buses supported", I2C_NUM_MAX); - this->mark_failed(); - return; - } + static i2c_port_t next_hp_port = I2C_NUM_0; +#if SOC_LP_I2C_SUPPORTED + static i2c_port_t next_lp_port = LP_I2C_NUM_0; +#endif if (this->timeout_ > 13000) { ESP_LOGW(TAG, "Using max allowed timeout: 13 ms"); @@ -31,23 +28,35 @@ void IDFI2CBus::setup() { this->recover_(); - next_port = (i2c_port_t) (next_port + 1); - i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); bus_conf.sda_io_num = gpio_num_t(sda_pin_); bus_conf.scl_io_num = gpio_num_t(scl_pin_); - bus_conf.i2c_port = this->port_; bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED - if (this->port_ < SOC_HP_I2C_NUM) { - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; - } else { + if (this->lp_mode_) { + if ((next_lp_port - LP_I2C_NUM_0) == SOC_LP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u LP buses supported", SOC_LP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_lp_port; + next_lp_port = (i2c_port_t) (next_lp_port + 1); bus_conf.lp_source_clk = LP_I2C_SCLK_DEFAULT; - } -#else - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; + } else { #endif + if (next_hp_port == SOC_HP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u HP buses supported", SOC_HP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_hp_port; + next_hp_port = (i2c_port_t) (next_hp_port + 1); + bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; +#if SOC_LP_I2C_SUPPORTED + } +#endif + bus_conf.i2c_port = this->port_; bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 63fe8b701c..84f4616967 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -30,6 +30,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { void set_scl_pullup_enabled(bool scl_pullup_enabled) { this->scl_pullup_enabled_ = scl_pullup_enabled; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } +#if SOC_LP_I2C_SUPPORTED + void set_lp_mode(bool lp_mode) { this->lp_mode_ = lp_mode; } +#endif int get_port() const override { return this->port_; } @@ -48,6 +51,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { uint32_t frequency_{}; uint32_t timeout_ = 0; bool initialized_ = false; +#if SOC_LP_I2C_SUPPORTED + bool lp_mode_ = false; +#endif }; } // namespace i2c diff --git a/esphome/const.py b/esphome/const.py index 59bf0e8b8a..8fa2d8da16 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -559,6 +559,7 @@ CONF_LOGS = "logs" CONF_LONGITUDE = "longitude" CONF_LOOP_TIME = "loop_time" CONF_LOW = "low" +CONF_LOW_POWER_MODE = "low_power_mode" CONF_LOW_VOLTAGE_REFERENCE = "low_voltage_reference" CONF_MAC_ADDRESS = "mac_address" CONF_MAGNITUDE = "magnitude" From 4c31961ae9c9a1654255db226c5e330f93bac378 Mon Sep 17 00:00:00 2001 From: smarthome-10 Date: Mon, 8 Dec 2025 20:37:45 +0100 Subject: [PATCH 15/40] Update URLs (#12369) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- esphome/components/absolute_humidity/absolute_humidity.cpp | 2 +- esphome/components/api/__init__.py | 2 +- esphome/components/bedjet/climate/__init__.py | 2 +- esphome/components/esp32/__init__.py | 2 +- esphome/components/kalman_combinator/sensor.py | 2 +- esphome/components/pn532/__init__.py | 2 +- esphome/components/sgp40/sensor.py | 2 +- esphome/components/web_server/web_server.h | 2 +- esphome/components/web_server/web_server_v1.cpp | 5 ++--- esphome/core/config.py | 2 +- esphome/espota2.py | 2 +- esphome/pins.py | 2 +- esphome/util.py | 2 +- esphome/wizard.py | 6 ++---- tests/components/qr_code/common.yaml | 2 +- 16 files changed, 18 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303b548310..66ad3ed599 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ We welcome contributions to the ESPHome suite of code and documentation! -Please read our [contributing guide](https://esphome.io/guides/contributing.html) if you wish to contribute to the +Please read our [contributing guide](https://developers.esphome.io/contributing/code/) if you wish to contribute to the project and be sure to join us on [Discord](https://discord.gg/KhAMKrd). **See also:** diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index d16a024d86..74d675b80b 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -163,7 +163,7 @@ float AbsoluteHumidityComponent::es_wobus(float t) { } // From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/ -// H/T to https://esphome.io/cookbook/bme280_environment.html +// H/T to https://esphome.io/cookbook/bme280_environment/ // H/T to https://carnotcycle.wordpress.com/2012/08/04/how-to-convert-relative-humidity-to-absolute-humidity/ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) { // es = saturated vapor pressure (kPa) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index d349cf3867..88618acef4 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -246,7 +246,7 @@ def _validate_api_config(config: ConfigType) -> ConfigType: _LOGGER.warning( "API 'password' authentication has been deprecated since May 2022 and will be removed in version 2026.1.0. " "Please migrate to the 'encryption' configuration. " - "See https://esphome.io/components/api.html#configuration-variables" + "See https://esphome.io/components/api/#configuration-variables" ) return config diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index e9c5510256..0da2107d43 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -44,7 +44,7 @@ CONFIG_SCHEMA = ( cv.Optional(ble_client.CONF_BLE_CLIENT_ID): cv.invalid( "The 'ble_client_id' option has been removed. Please migrate " "to the new `bedjet_id` option in the `bedjet` component.\n" - "See https://esphome.io/components/climate/bedjet.html" + "See https://esphome.io/components/climate/bedjet/" ), cv.Optional(CONF_TIME_ID): cv.invalid( "The 'time_id' option has been moved to the `bedjet` component." diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 94280308bd..3dc5e4bbaa 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -764,7 +764,7 @@ def _show_framework_migration_message(name: str, variant: str) -> None: + "Need help? Check out the migration guide:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html", + "https://esphome.io/guides/esp32_arduino_to_idf/", ) ) _LOGGER.warning(message) diff --git a/esphome/components/kalman_combinator/sensor.py b/esphome/components/kalman_combinator/sensor.py index c19a17462d..d30a41d6bf 100644 --- a/esphome/components/kalman_combinator/sensor.py +++ b/esphome/components/kalman_combinator/sensor.py @@ -2,5 +2,5 @@ import esphome.config_validation as cv CONFIG_SCHEMA = cv.invalid( "The kalman_combinator sensor has moved.\nPlease use the combination platform instead with type: kalman.\n" - "See https://esphome.io/components/sensor/combination.html" + "See https://esphome.io/components/sensor/combination/" ) diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 3f04e8e1cc..6f679ed10a 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -55,7 +55,7 @@ def CONFIG_SCHEMA(conf): if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " - "instructions. https://esphome.io/components/binary_sensor/pn532.html" + "instructions. https://esphome.io/components/binary_sensor/pn532/" ) diff --git a/esphome/components/sgp40/sensor.py b/esphome/components/sgp40/sensor.py index ad9de6fe24..b16151ec1f 100644 --- a/esphome/components/sgp40/sensor.py +++ b/esphome/components/sgp40/sensor.py @@ -4,5 +4,5 @@ CODEOWNERS = ["@SenexCrenshaw"] CONFIG_SCHEMA = cv.invalid( "SGP40 is deprecated.\nPlease use the SGP4x platform instead.\nSGP4x supports both SPG40 and SGP41.\n" - " See https://esphome.io/components/sensor/sgp4x.html" + " See https://esphome.io/components/sensor/sgp4x/" ) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 52cf0bedea..bb69d57872 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -171,7 +171,7 @@ class DeferredUpdateEventSourceList : public std::listprint( - ESPHOME_F("

See ESPHome Web API for " - "REST API documentation.

")); + stream->print(ESPHOME_F("

See ESPHome Web API for " + "REST API documentation.

")); #if defined(USE_WEBSERVER_OTA) && !defined(USE_WEBSERVER_OTA_DISABLED) // Show OTA form only if web_server OTA is not explicitly disabled // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal diff --git a/esphome/core/config.py b/esphome/core/config.py index 0a239c5f5e..3adaf7eb9e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -87,7 +87,7 @@ def validate_hostname(config): _LOGGER.warning( "'%s': Using the '_' (underscore) character in the hostname is discouraged " "as it can cause problems with some DHCP and local name services. " - "For more information, see https://esphome.io/guides/faq.html#why-shouldn-t-i-use-underscores-in-my-device-name", + "For more information, see https://esphome.io/guides/faq/#why-shouldnt-i-use-underscores-in-my-device-name", config[CONF_NAME], ) return config diff --git a/esphome/espota2.py b/esphome/espota2.py index 2b1b9a8328..c29506224c 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -402,7 +402,7 @@ def run_ota_impl_( ) _LOGGER.error( "(If this error persists, please set a static IP address: " - "https://esphome.io/components/wifi.html#manual-ips)" + "https://esphome.io/components/wifi/#manual-ips)" ) raise OTAError(err) from err diff --git a/esphome/pins.py b/esphome/pins.py index 601c05880a..bdaa0e28ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -274,7 +274,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): logger.warning( f"GPIO{num} is a strapping PIN and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", + "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/util.py b/esphome/util.py index d41800dc20..7b896de27e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -375,6 +375,6 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "For detailed migration instructions, see:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + "https://esphome.io/guides/esp32_arduino_to_idf/\n\n", ) ) diff --git a/esphome/wizard.py b/esphome/wizard.py index 97343eea99..d77450b04d 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -411,9 +411,7 @@ def wizard(path: Path) -> int: "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) elif platform == "RP2040": - board_link = ( - "https://www.raspberrypi.com/documentation/microcontrollers/rp2040.html" - ) + board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" else: @@ -555,7 +553,7 @@ def wizard(path: Path) -> int: safe_print("Next steps:") safe_print(" > Follow the rest of the getting started guide:") safe_print( - " > https://esphome.io/guides/getting_started_command_line.html#adding-some-features" + " > https://esphome.io/guides/getting_started_command_line/#adding-some-features" ) safe_print(" > to learn how to customize ESPHome and install it to your device.") return 0 diff --git a/tests/components/qr_code/common.yaml b/tests/components/qr_code/common.yaml index 5fec26c1cc..15b4e387c6 100644 --- a/tests/components/qr_code/common.yaml +++ b/tests/components/qr_code/common.yaml @@ -16,4 +16,4 @@ display: qr_code: - id: qr_code_homepage_qr - value: https://esphome.io/index.html + value: https://esphome.io/ From 3eaa9f164b90a79bc5f7763f10b18c0630cdb6ba Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 8 Dec 2025 20:38:13 +0100 Subject: [PATCH 16/40] [micronova] Remove MicroNovaFunctions (#12363) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/micronova/__init__.py | 19 +------ .../components/micronova/button/__init__.py | 2 - .../micronova/button/micronova_button.cpp | 8 +-- esphome/components/micronova/micronova.h | 20 ------- .../components/micronova/number/__init__.py | 13 ++--- .../micronova/number/micronova_number.cpp | 32 ++++-------- .../micronova/number/micronova_number.h | 5 ++ .../components/micronova/sensor/__init__.py | 23 ++++---- .../micronova/sensor/micronova_sensor.cpp | 26 +++------- .../micronova/sensor/micronova_sensor.h | 11 ++-- .../components/micronova/switch/__init__.py | 5 +- .../micronova/switch/micronova_switch.cpp | 52 +++++++------------ .../micronova/text_sensor/__init__.py | 5 +- .../text_sensor/micronova_text_sensor.cpp | 8 +-- 14 files changed, 68 insertions(+), 161 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 637d0eb168..52fbae2da2 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -17,22 +17,6 @@ DEFAULT_POLLING_INTERVAL = "60s" micronova_ns = cg.esphome_ns.namespace(DOMAIN) -MicroNovaFunctions = micronova_ns.enum("MicroNovaFunctions", is_class=True) -MICRONOVA_FUNCTIONS_ENUM = { - "STOVE_FUNCTION_SWITCH": MicroNovaFunctions.STOVE_FUNCTION_SWITCH, - "STOVE_FUNCTION_ROOM_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, - "STOVE_FUNCTION_THERMOSTAT_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, - "STOVE_FUNCTION_FUMES_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, - "STOVE_FUNCTION_STOVE_POWER": MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, - "STOVE_FUNCTION_FAN_SPEED": MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED, - "STOVE_FUNCTION_STOVE_STATE": MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE, - "STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR": MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, - "STOVE_FUNCTION_WATER_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, - "STOVE_FUNCTION_WATER_PRESSURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, - "STOVE_FUNCTION_POWER_LEVEL": MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL, - "STOVE_FUNCTION_CUSTOM": MicroNovaFunctions.STOVE_FUNCTION_CUSTOM, -} - MicroNova = micronova_ns.class_("MicroNova", cg.Component, uart.UARTDevice) MicroNovaListener = micronova_ns.class_("MicroNovaListener", cg.PollingComponent) @@ -78,12 +62,11 @@ def MICRONOVA_ADDRESS_SCHEMA( return schema -async def to_code_micronova_listener(mv, var, config, micronova_function): +async def to_code_micronova_listener(mv, var, config): await cg.register_component(var, config) cg.add(mv.register_micronova_listener(var)) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) - cg.add(var.set_function(micronova_function)) async def to_code(config): diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 38fee2f561..2eda887443 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -8,7 +8,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, micronova_ns, ) @@ -43,4 +42,3 @@ async def to_code(config): cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION))) cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS))) cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) - cg.add(bt.set_function(MicroNovaFunctions.STOVE_FUNCTION_CUSTOM)) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index c78b4024f9..3f49d4b5b3 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -3,13 +3,7 @@ namespace esphome::micronova { void MicroNovaButton::press_action() { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_CUSTOM: - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - break; - default: - break; - } + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); this->micronova_->request_update_listeners(); } diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 1b2c06f07f..a70f355ead 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -12,22 +12,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; -enum class MicroNovaFunctions { - STOVE_FUNCTION_VOID = 0, - STOVE_FUNCTION_SWITCH = 1, - STOVE_FUNCTION_ROOM_TEMPERATURE = 2, - STOVE_FUNCTION_THERMOSTAT_TEMPERATURE = 3, - STOVE_FUNCTION_FUMES_TEMPERATURE = 4, - STOVE_FUNCTION_STOVE_POWER = 5, - STOVE_FUNCTION_FAN_SPEED = 6, - STOVE_FUNCTION_STOVE_STATE = 7, - STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR = 8, - STOVE_FUNCTION_WATER_TEMPERATURE = 9, - STOVE_FUNCTION_WATER_PRESSURE = 10, - STOVE_FUNCTION_POWER_LEVEL = 11, - STOVE_FUNCTION_CUSTOM = 12 -}; - class MicroNova; ////////////////////////////////////////////////////////////////////// @@ -39,9 +23,6 @@ class MicroNovaBaseListener { void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_function(MicroNovaFunctions f) { this->function_ = f; } - MicroNovaFunctions get_function() { return this->function_; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } @@ -52,7 +33,6 @@ class MicroNovaBaseListener { protected: MicroNova *micronova_{nullptr}; - MicroNovaFunctions function_ = MicroNovaFunctions::STOVE_FUNCTION_VOID; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index 07023e618c..ef6cc0f7d7 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -7,7 +7,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -66,13 +65,9 @@ async def to_code(config): max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) - await to_code_micronova_listener( - mv, - numb, - thermostat_temperature_config, - MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, - ) + await to_code_micronova_listener(mv, numb, thermostat_temperature_config) cg.add(numb.set_micronova_object(mv)) + cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -81,7 +76,5 @@ async def to_code(config): max_value=5, step=1, ) - await to_code_micronova_listener( - mv, numb, power_level_config, MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL - ) + await to_code_micronova_listener(mv, numb, power_level_config) cg.add(numb.set_micronova_object(mv)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index c71d819ad6..8027947468 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -3,38 +3,24 @@ namespace esphome::micronova { void MicroNovaNumber::process_value_from_stove(int value_from_stove) { - float new_sensor_value = 0; - if (value_from_stove == -1) { this->publish_state(NAN); return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_sensor_value = ((float) value_from_stove) * this->traits.get_step(); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_sensor_value = (float) value_from_stove; - break; - default: - break; + float new_value = static_cast(value_from_stove); + if (this->use_step_scaling_) { + new_value *= this->traits.get_step(); } - this->publish_state(new_sensor_value); + this->publish_state(new_value); } void MicroNovaNumber::control(float value) { - uint8_t new_number = 0; - - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_number = (uint8_t) (value / this->traits.get_step()); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_number = (uint8_t) value; - break; - default: - break; + uint8_t new_number; + if (this->use_step_scaling_) { + new_number = static_cast(value / this->traits.get_step()); + } else { + new_number = static_cast(value); } this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); this->micronova_->request_update_listeners(); diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 391765b730..3fc5838a4f 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -18,6 +18,11 @@ class MicroNovaNumber : public number::Number, public MicroNovaListener { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; + + void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } + + protected: + bool use_step_scaling_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 77bdacd5da..55318a7fff 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -13,7 +13,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -131,21 +130,21 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) - for key, fn in { - CONF_ROOM_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, - CONF_FUMES_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, - CONF_STOVE_POWER: MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, - CONF_MEMORY_ADDRESS_SENSOR: MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, - CONF_WATER_TEMPERATURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, - CONF_WATER_PRESSURE: MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, + for key, divisor in { + CONF_ROOM_TEMPERATURE: 2, + CONF_FUMES_TEMPERATURE: None, + CONF_STOVE_POWER: None, + CONF_MEMORY_ADDRESS_SENSOR: None, + CONF_WATER_TEMPERATURE: 2, + CONF_WATER_PRESSURE: 10, }.items(): if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config, mv) - await to_code_micronova_listener(mv, sens, sensor_config, fn) + await to_code_micronova_listener(mv, sens, sensor_config) + if divisor: + cg.add(sens.set_divisor(divisor)) if fan_speed_config := config.get(CONF_FAN_SPEED): sens = await sensor.new_sensor(fan_speed_config, mv) - await to_code_micronova_listener( - mv, sens, fan_speed_config, MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED - ) + await to_code_micronova_listener(mv, sens, fan_speed_config) cg.add(sens.set_fan_speed_offset(fan_speed_config[CONF_FAN_RPM_OFFSET])) diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 9fd8832f29..d845e0ab3c 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -8,25 +8,15 @@ void MicroNovaSensor::process_value_from_stove(int value_from_stove) { return; } - float new_sensor_value = (float) value_from_stove; - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_ROOM_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - break; - case MicroNovaFunctions::STOVE_FUNCTION_FAN_SPEED: - new_sensor_value = new_sensor_value == 0 ? 0 : (new_sensor_value * 10) + this->fan_speed_offset_; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_PRESSURE: - new_sensor_value = new_sensor_value / 10; - break; - default: - break; + float new_sensor_value = static_cast(value_from_stove); + + // Fan speed has special calculation: value * 10 + offset (when non-zero) + if (this->is_fan_speed_) { + new_sensor_value = value_from_stove == 0 ? 0.0f : (new_sensor_value * 10) + this->fan_speed_offset_; + } else if (this->divisor_ > 1) { + new_sensor_value = new_sensor_value / this->divisor_; } + this->publish_state(new_sensor_value); } diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 119e5eb155..a2f232c7dc 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -18,11 +18,16 @@ class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { } void process_value_from_stove(int value_from_stove) override; - void set_fan_speed_offset(uint8_t f) { this->fan_speed_offset_ = f; } - uint8_t get_set_fan_speed_offset() { return this->fan_speed_offset_; } + void set_divisor(uint8_t d) { this->divisor_ = d; } + void set_fan_speed_offset(uint8_t offset) { + this->is_fan_speed_ = true; + this->fan_speed_offset_ = offset; + } protected: - int fan_speed_offset_ = 0; + uint8_t divisor_ = 1; + uint8_t fan_speed_offset_ = 0; + bool is_fan_speed_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 62a8a0f008..c937a4cac9 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -7,7 +7,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -50,8 +49,6 @@ async def to_code(config): if stove_config := config.get(CONF_STOVE): sw = await switch.new_switch(stove_config, mv) - await to_code_micronova_listener( - mv, sw, stove_config, MicroNovaFunctions.STOVE_FUNCTION_SWITCH - ) + await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF])) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 76ef04da8a..9b9ad61018 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -3,31 +3,24 @@ namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { - if (state) { - // Only send power-on when current state is Off - if (this->raw_state_ == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); - } else { - ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); - } - } else { - // don't send power-off when status is Off or Final cleaning - if (this->raw_state_ != 0 && this->raw_state_ != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); - } else { - ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); - } - } - this->set_needs_update(true); - break; + if (state) { + // Only send power-on when current state is Off + if (this->raw_state_ == 0) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); + this->publish_state(true); + } else { + ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); + } + } else { + // don't send power-off when status is Off or Final cleaning + if (this->raw_state_ != 0 && this->raw_state_ != 6) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); + this->publish_state(false); + } else { + ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } - default: - break; } + this->set_needs_update(true); } void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { @@ -37,16 +30,9 @@ void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: { - // set the stove switch to on for any value but 0 - bool state = value_from_stove != 0; - this->publish_state(state); - break; - } - default: - break; - } + // set the stove switch to on for any value but 0 + bool state = value_from_stove != 0; + this->publish_state(state); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index e54b9e280a..33d0779eae 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -6,7 +6,6 @@ from .. import ( CONF_MICRONOVA_ID, MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, MicroNovaListener, micronova_ns, to_code_micronova_listener, @@ -39,6 +38,4 @@ async def to_code(config): if stove_state_config := config.get(CONF_STOVE_STATE): sens = await text_sensor.new_text_sensor(stove_state_config, mv) - await to_code_micronova_listener( - mv, sens, stove_state_config, MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE - ) + await to_code_micronova_listener(mv, sens, stove_state_config) diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index d1c03f66c3..2217ed6d6f 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -8,13 +8,7 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE: - this->publish_state(STOVE_STATES[value_from_stove]); - break; - default: - break; - } + this->publish_state(STOVE_STATES[value_from_stove]); } } // namespace esphome::micronova From fcae13836cdfb08ec239aaa1d89dc63a354e9040 Mon Sep 17 00:00:00 2001 From: Mirko Vogt Date: Tue, 9 Dec 2025 04:50:07 +0100 Subject: [PATCH 17/40] [sx1509] Change setup priority from HARDWARE to IO (#12373) Co-authored-by: Your Name --- esphome/components/sx1509/sx1509.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 2afd0d0e4e..f98fc0a44f 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -40,7 +40,7 @@ class SX1509Component : public Component, void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::HARDWARE; } + float get_setup_priority() const override { return setup_priority::IO; } void loop() override; uint16_t read_key_data(); From 6945b44af59107a183004c0880c525c1179bf7ce Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:38:16 +1100 Subject: [PATCH 18/40] [psram] Fix boot failure with 120MHz Octal flash (#12377) --- esphome/components/psram/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index fcbe9ed043..39afb407f1 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -197,7 +197,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) if config[CONF_MODE] == TYPE_OCTAL and speed == 120: add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - add_idf_sdkconfig_option("CONFIG_BOOTLOADER_FLASH_DC_AWARE", True) if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True From 750f4ea797703ed6301c3a99a3acb6ea648f251b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:40:58 +1100 Subject: [PATCH 19/40] [pio] Rationalise library definitions in platformio.ini (#12374) --- .clang-tidy.hash | 2 +- platformio.ini | 26 ++++++++++++-------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 7dabee48f1..a3322ba731 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -c01eec15857a784dd603c0afd194ab3b29a632422fe6f6b0a806ad4d81b5efc0 +766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07 diff --git a/platformio.ini b/platformio.ini index 9095d27af8..d37c798c05 100644 --- a/platformio.ini +++ b/platformio.ini @@ -32,20 +32,24 @@ build_flags = ; This are common settings for all environments. [common] -lib_deps = - esphome/noise-c@0.1.10 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv +; Base dependencies for all environments +lib_deps_base = bblanchon/ArduinoJson@7.4.2 ; json wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - kikuchan98/pngle@1.1.0 ; online_image https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps +; This is using the repository until a new release is published to PlatformIO + https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library + lvgl/lvgl@8.4.0 ; lvgl + +lib_deps = + ${common.lib_deps_base} + esphome/noise-c@0.1.10 ; api + improv/Improv@1.2.4 ; improv_serial / esp32_improv + kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image - ; This is using the repository until a new release is published to PlatformIO - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -236,13 +240,7 @@ build_flags = -DUSE_ZEPHYR -DUSE_NRF52 lib_deps = - bblanchon/ArduinoJson@7.4.2 ; json - wjtje/qr-code-generator-library@1.7.0 ; qr_code - pavlodn/HaierProtocol@0.9.31 ; haier - functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 - https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl + ${common.lib_deps_base} ; All the actual environments are defined below. From 861ed8dd41c2966494ebcaaca5843a672e239072 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:27:12 +0100 Subject: [PATCH 20/40] [scheduler] Avoid std::string allocation in RetryArgs (#12311) --- esphome/core/component.cpp | 9 +++++++ esphome/core/component.h | 4 ++++ esphome/core/scheduler.cpp | 49 ++++++++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b7c0cedb76..97ab2edb5a 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -138,10 +138,19 @@ void Component::set_retry(const std::string &name, uint32_t initial_wait_time, u App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); } +void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function &&f, float backoff_increase_factor) { // NOLINT + App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +} + bool Component::cancel_retry(const std::string &name) { // NOLINT return App.scheduler.cancel_retry(this, name); } +bool Component::cancel_retry(const char *name) { // NOLINT + return App.scheduler.cancel_retry(this, name); +} + void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 3d45a020c4..32f594d6f8 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -367,6 +367,9 @@ class Component { void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT + std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT float backoff_increase_factor = 1.0f); // NOLINT @@ -376,6 +379,7 @@ class Component { * @return Whether a retry function was deleted. */ bool cancel_retry(const std::string &name); // NOLINT + bool cancel_retry(const char *name); // NOLINT /** Set a timeout function with a unique name. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index f84495950c..8b713523b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -204,13 +204,21 @@ bool HOT Scheduler::cancel_interval(Component *component, const char *name) { } struct RetryArgs { + // Ordered to minimize padding on 32-bit systems std::function func; - uint8_t retry_countdown; - uint32_t current_interval; Component *component; - std::string name; // Keep as std::string since retry uses it dynamically - float backoff_increase_factor; Scheduler *scheduler; + const char *name; // Points to static string or owned copy + uint32_t current_interval; + float backoff_increase_factor; + uint8_t retry_countdown; + bool name_is_dynamic; // True if name needs delete[] + + ~RetryArgs() { + if (this->name_is_dynamic && this->name) { + delete[] this->name; + } + } }; void retry_handler(const std::shared_ptr &args) { @@ -218,8 +226,10 @@ void retry_handler(const std::shared_ptr &args) { if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) return; // second execution of `func` happens after `initial_wait_time` + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, + args->component, Scheduler::SchedulerItem::TIMEOUT, true, args->name, args->current_interval, [args]() { retry_handler(args); }, /* is_retry= */ true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; @@ -246,16 +256,35 @@ void HOT Scheduler::set_retry_common_(Component *component, bool is_static_strin auto args = std::make_shared(); args->func = std::move(func); - args->retry_countdown = max_attempts; - args->current_interval = initial_wait_time; args->component = component; - args->name = name_cstr ? name_cstr : ""; // Convert to std::string for RetryArgs - args->backoff_increase_factor = backoff_increase_factor; args->scheduler = this; + args->current_interval = initial_wait_time; + args->backoff_increase_factor = backoff_increase_factor; + args->retry_countdown = max_attempts; + + // Store name - either as static pointer or owned copy + if (name_cstr == nullptr || name_cstr[0] == '\0') { + // Empty or null name - use empty string literal + args->name = ""; + args->name_is_dynamic = false; + } else if (is_static_string) { + // Static string - just store the pointer + args->name = name_cstr; + args->name_is_dynamic = false; + } else { + // Dynamic string - make a copy + size_t len = strlen(name_cstr); + char *copy = new char[len + 1]; + memcpy(copy, name_cstr, len + 1); + args->name = copy; + args->name_is_dynamic = true; + } // First execution of `func` immediately - use set_timer_common_ with is_retry=true + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem this->set_timer_common_( - component, SchedulerItem::TIMEOUT, false, &args->name, 0, [args]() { retry_handler(args); }, + component, SchedulerItem::TIMEOUT, true, args->name, 0, [args]() { retry_handler(args); }, /* is_retry= */ true); } From f9aa48295c46e74968fd909c5348df18944cb697 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:33:23 +0100 Subject: [PATCH 21/40] [mdns] Reduce RAM usage by eliminating MAC address heap allocation (#12073) --- esphome/components/mdns/__init__.py | 10 +++--- esphome/components/mdns/mdns_component.cpp | 6 ++-- esphome/components/mdns/mdns_component.h | 42 ++++++++++++++++++++-- esphome/components/mdns/mdns_esp32.cpp | 14 +++----- esphome/components/mdns/mdns_esp8266.cpp | 12 ++----- esphome/components/mdns/mdns_host.cpp | 9 +++++ esphome/components/mdns/mdns_libretiny.cpp | 12 ++----- esphome/components/mdns/mdns_rp2040.cpp | 12 ++----- esphome/core/defines.h | 3 +- esphome/core/helpers.cpp | 6 ++-- esphome/core/helpers.h | 15 +++++--- 11 files changed, 86 insertions(+), 55 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 1daac93a2e..99b728b249 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -184,10 +184,8 @@ async def to_code(config): # Calculate compile-time dynamic TXT value count # Dynamic values are those that cannot be stored in flash at compile time + # Note: MAC address is now stored in a fixed char[13] buffer, not dynamic storage dynamic_txt_count = 0 - if "api" in CORE.config: - # Always: get_mac_address() - dynamic_txt_count += 1 # User-provided templatable TXT values (only lambdas, not static strings) dynamic_txt_count += sum( 1 @@ -196,8 +194,10 @@ async def to_code(config): if cg.is_template(txt_value) ) - # Ensure at least 1 to avoid zero-size array - cg.add_define("MDNS_DYNAMIC_TXT_COUNT", max(1, dynamic_txt_count)) + # Only add define if we actually need dynamic storage + if dynamic_txt_count > 0: + cg.add_define("USE_MDNS_DYNAMIC_TXT") + cg.add_define("MDNS_DYNAMIC_TXT_COUNT", dynamic_txt_count) # Enable storage if verbose logging is enabled (for dump_config) if get_logger_level() in ("VERBOSE", "VERY_VERBOSE"): diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4655907983..47db92610a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -35,7 +35,7 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services) { +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -86,7 +86,9 @@ void MDNSComponent::compile_records_(StaticVectoradd_dynamic_txt_value(get_mac_address()))}); + + // MAC address: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES) + txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}); #ifdef USE_ESP8266 MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266"); diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 691c45b7df..f696cfff1c 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -60,22 +60,58 @@ class MDNSComponent : public Component { void on_shutdown() override; +#ifdef USE_MDNS_DYNAMIC_TXT /// Add a dynamic TXT value and return pointer to it for use in MDNSTXTRecord const char *add_dynamic_txt_value(const std::string &value) { this->dynamic_txt_values_.push_back(value); return this->dynamic_txt_values_[this->dynamic_txt_values_.size() - 1].c_str(); } +#endif - /// Storage for runtime-generated TXT values (MAC address, user lambdas) + protected: + /// Helper to set up services and MAC buffers, then call platform-specific registration + using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); + + void setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + get_mac_address_into_buffer(mac_address); + char *mac_ptr = mac_address; +#endif +#else + char *mac_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr); + platform_register(this, services); + } + +#ifdef USE_MDNS_DYNAMIC_TXT + /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. /// Static/compile-time values (version, board, etc.) are stored directly in flash and don't use this. StaticVector dynamic_txt_values_; +#endif - protected: +#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) + /// Fixed buffer for MAC address (only needed when services are stored) + char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; +#endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif - void compile_records_(StaticVector &services); + void compile_records_(StaticVector &services, char *mac_address_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 5547a2524b..e6b43e59cb 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -11,19 +11,11 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp32(MDNSComponent *comp, StaticVector &services) { esp_err_t err = mdns_init(); if (err != ESP_OK) { ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err)); - this->mark_failed(); + comp->mark_failed(); return; } @@ -50,6 +42,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } + void MDNSComponent::on_shutdown() { mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 06f905884c..dcbe5ebd52 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp8266(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -44,6 +36,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 64b8c8f54b..4d902319b8 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -9,6 +9,15 @@ namespace esphome::mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES +#ifdef USE_API + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char *mac_ptr = nullptr; +#endif + this->compile_records_(this->services_, mac_ptr); +#endif // Host platform doesn't have actual mDNS implementation } diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a049fe2109..986099fa1f 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_libretiny(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_libretiny); } + void MDNSComponent::on_shutdown() {} } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a102e0b6c3..e4a9b60cdb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_rp2040(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_rp2040); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 021240cc40..a5170d73ff 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -90,7 +90,8 @@ #define USE_MDNS #define USE_MDNS_STORE_SERVICES #define MDNS_SERVICE_COUNT 3 -#define MDNS_DYNAMIC_TXT_COUNT 3 +#define USE_MDNS_DYNAMIC_TXT +#define MDNS_DYNAMIC_TXT_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_NEXTION_TFT_UPLOAD diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1f675563c7..77102c8db2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -642,17 +642,17 @@ std::string get_mac_address() { } std::string get_mac_address_pretty() { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; return std::string(get_mac_address_pretty_into_buffer(buf)); } -void get_mac_address_into_buffer(std::span buf) { +void get_mac_address_into_buffer(std::span buf) { uint8_t mac[6]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } -const char *get_mac_address_pretty_into_buffer(std::span buf) { +const char *get_mac_address_pretty_into_buffer(std::span buf) { uint8_t mac[6]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 83a12b9bf0..6054f03353 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1056,6 +1056,12 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) +/// Buffer size for MAC address in lowercase hex notation (12 hex chars + null terminator) +constexpr size_t MAC_ADDRESS_BUFFER_SIZE = 13; + +/// Buffer size for MAC address in colon-separated uppercase hex notation (17 chars + null terminator) +constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = 18; + /// Get the device MAC address as a string, in lowercase hex notation. std::string get_mac_address(); @@ -1063,13 +1069,14 @@ std::string get_mac_address(); std::string get_mac_address_pretty(); /// Get the device MAC address into the given buffer, in lowercase hex notation. -/// Assumes buffer length is 13 (12 digits for hexadecimal representation followed by null terminator). -void get_mac_address_into_buffer(std::span buf); +/// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null +/// terminator). +void get_mac_address_into_buffer(std::span buf); /// Get the device MAC address into the given buffer, in colon-separated uppercase hex notation. -/// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). +/// Buffer must be exactly MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). /// Returns pointer to the buffer for convenience. -const char *get_mac_address_pretty_into_buffer(std::span buf); +const char *get_mac_address_pretty_into_buffer(std::span buf); #ifdef USE_ESP32 /// Set the MAC address to use from the provided byte array (6 bytes). From 74f509c754b9d7e92f867d789b4e11be4372e7bc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:42:06 -0500 Subject: [PATCH 22/40] [core] Add PR template instruction to AI instructions (#12375) Co-authored-by: Claude --- .ai/instructions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 9d48f467cb..994d517f75 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -276,12 +276,12 @@ This document provides essential context for AI models interacting with this pro ## 7. Specific Instructions for AI Collaboration * **Contribution Workflow (Pull Request Process):** - 1. **Fork & Branch:** Create a new branch in your fork. + 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. 4. **Lint:** Run `pre-commit` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made with the PULL_REQUEST_TEMPLATE.md template filled out correctly. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome-docs` repository. From 27e031c25785c3ff745941e6a3d41df339f8f22e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:43:47 -0500 Subject: [PATCH 23/40] [mqtt] Fix logger method case sensitivity error (#12379) Co-authored-by: Claude --- esphome/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 0d50edbc2c..042df12d67 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -192,7 +192,7 @@ def get_esphome_device_ip( data = json.loads(payload) if "name" not in data or data["name"] != dev_name: - _LOGGER.Warn("Wrong device answer") + _LOGGER.warning("Wrong device answer") return dev_ip = [] From e1afd65fae3c951dfc19121962d9788906f8099d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 15:59:27 +0100 Subject: [PATCH 24/40] [api] Store device info strings in flash on ESP8266 (#12173) --- esphome/components/api/api_connection.cpp | 50 +++++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 18d80c46df..d63d6eb2c5 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -14,6 +14,9 @@ #include #include #include +#ifdef USE_ESP8266 +#include +#endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" @@ -1471,35 +1474,64 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_compilation_time(App.get_compilation_time_ref()); - // Compile-time StringRef constants for manufacturers + // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) - static constexpr auto MANUFACTURER = StringRef::from_lit("Espressif"); +#define ESPHOME_MANUFACTURER "Espressif" #elif defined(USE_RP2040) - static constexpr auto MANUFACTURER = StringRef::from_lit("Raspberry Pi"); +#define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Beken"); +#define ESPHOME_MANUFACTURER "Beken" #elif defined(USE_LN882X) - static constexpr auto MANUFACTURER = StringRef::from_lit("Lightning"); +#define ESPHOME_MANUFACTURER "Lightning" #elif defined(USE_NRF52) - static constexpr auto MANUFACTURER = StringRef::from_lit("Nordic Semiconductor"); +#define ESPHOME_MANUFACTURER "Nordic Semiconductor" #elif defined(USE_RTL87XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Realtek"); +#define ESPHOME_MANUFACTURER "Realtek" #elif defined(USE_HOST) - static constexpr auto MANUFACTURER = StringRef::from_lit("Host"); +#define ESPHOME_MANUFACTURER "Host" #endif - resp.set_manufacturer(MANUFACTURER); +#ifdef USE_ESP8266 + // ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility + static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER; + char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)]; + memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM)); + resp.set_manufacturer(StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1)); +#else + static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); + resp.set_manufacturer(MANUFACTURER); +#endif +#undef ESPHOME_MANUFACTURER + +#ifdef USE_ESP8266 + static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD; + char model_buf[sizeof(MODEL_PROGMEM)]; + memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM)); + resp.set_model(StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1)); +#else static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD); resp.set_model(MODEL); +#endif #ifdef USE_DEEP_SLEEP resp.has_deep_sleep = deep_sleep::global_has_deep_sleep; #endif #ifdef ESPHOME_PROJECT_NAME +#ifdef USE_ESP8266 + static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME; + static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION; + char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)]; + char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)]; + memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM)); + memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM)); + resp.set_project_name(StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1)); + resp.set_project_version(StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1)); +#else static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME); static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION); resp.set_project_name(PROJECT_NAME); resp.set_project_version(PROJECT_VERSION); #endif +#endif #ifdef USE_WEBSERVER resp.webserver_port = USE_WEBSERVER_PORT; #endif From 88a2e75989822faad0914cf6dce6dc7cec68d910 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Tue, 9 Dec 2025 16:04:10 +0100 Subject: [PATCH 25/40] [packages] Add more information and deprecation deadline for "single package" includes (#12280) --- esphome/components/packages/__init__.py | 11 ++++++++++- tests/component_tests/packages/test_packages.py | 5 +---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 67fd2770e9..15ab11d6b0 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -91,7 +91,16 @@ def validate_source_shorthand(value): def deprecate_single_package(config): _LOGGER.warning( - "Including a single package under `packages:` is deprecated. Use a list instead." + """ + Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. + This method for including packages will go away in 2026.7.0 + Please use a list instead: + + packages: + - !include mypackage.yaml + + See https://github.com/esphome/esphome/pull/12116 + """ ) return config diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index ac4e211fe6..34760587df 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -176,10 +176,7 @@ def test_single_package( assert actual == expected - assert ( - "Including a single package under `packages:` is deprecated. Use a list instead." - in caplog.text - ) + assert "This method for including packages will go away in 2026.7.0" in caplog.text def test_package_append(basic_wifi, basic_esphome): From 443f9c3f57ff93b223046043ae0162c17dfae15c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:10:43 +0100 Subject: [PATCH 26/40] [api] Use StringRef for ActionResponse error message to avoid copy (#12240) --- .../components/api/homeassistant_service.h | 16 +++++--- .../fixtures/api_homeassistant.yaml | 22 +++++++++++ tests/integration/test_api_homeassistant.py | 39 ++++++++++++++++++- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index d00e9e6257..397520fa2e 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -12,6 +12,7 @@ #endif #include "esphome/core/automation.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome::api { @@ -55,14 +56,16 @@ template class TemplatableKeyValuePair { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES // Represents the response data from a Home Assistant action +// Note: This class holds a StringRef to the error_message from the protobuf message. +// The protobuf message must outlive the ActionResponse (which is guaranteed since +// the callback is invoked synchronously while the message is on the stack). class ActionResponse { public: - ActionResponse(bool success, std::string error_message = "") - : success_(success), error_message_(std::move(error_message)) {} + ActionResponse(bool success, const std::string &error_message) : success_(success), error_message_(error_message) {} #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ActionResponse(bool success, std::string error_message, const uint8_t *data, size_t data_len) - : success_(success), error_message_(std::move(error_message)) { + ActionResponse(bool success, const std::string &error_message, const uint8_t *data, size_t data_len) + : success_(success), error_message_(error_message) { if (data == nullptr || data_len == 0) return; this->json_document_ = json::parse_json(data, data_len); @@ -70,7 +73,8 @@ class ActionResponse { #endif bool is_success() const { return this->success_; } - const std::string &get_error_message() const { return this->error_message_; } + // Returns reference to error message - can be implicitly converted to std::string if needed + const StringRef &get_error_message() const { return this->error_message_; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON // Get data as parsed JSON object (const version returns read-only view) @@ -79,7 +83,7 @@ class ActionResponse { protected: bool success_; - std::string error_message_; + StringRef error_message_; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON JsonDocument json_document_; #endif diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index ce8628977a..8fe23b9a19 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -17,6 +17,7 @@ api: - button.press: test_all_empty_service - button.press: test_rapid_service_calls - button.press: test_read_ha_states + - button.press: test_action_response_error - number.set: id: ha_number value: 42.5 @@ -309,3 +310,24 @@ button: } else { ESP_LOGI("test", "HA Empty State has no value (expected)"); } + + # Test 9: Action response error handling (tests StringRef error message) + - platform: template + name: "Test Action Response Error" + id: test_action_response_error + on_press: + - logger.log: "Testing action response error handling" + - homeassistant.action: + action: nonexistent.action_for_error_test + data: + test_field: "test_value" + on_error: + - lambda: |- + // This tests that StringRef error message works correctly + // The error variable is std::string (converted from StringRef) + ESP_LOGI("test", "Action error received: %s", error.c_str()); + - logger.log: + format: "Action failed with error message length: %d" + args: ['error.size()'] + on_success: + - logger.log: "Action succeeded unexpectedly" diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index 98901fb3f9..1343691f5f 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -81,8 +81,15 @@ async def test_api_homeassistant( "input_number.set_value": loop.create_future(), # ha_number_service_call "switch.turn_on": loop.create_future(), # ha_switch_on_service_call "switch.turn_off": loop.create_future(), # ha_switch_off_service_call + "nonexistent.action_for_error_test": loop.create_future(), # error_test_call } + # Future for error message test + action_error_received_future = loop.create_future() + + # Store client reference for use in callback + client_ref: list = [] # Use list to allow modification in nested function + def on_service_call(service_call: HomeassistantServiceCall) -> None: """Capture HomeAssistant service calls.""" ha_service_calls.append(service_call) @@ -93,6 +100,17 @@ async def test_api_homeassistant( if not future.done(): future.set_result(service_call) + # Immediately respond to the error test call so the test can proceed + # This needs to happen synchronously so ESPHome receives the response + # before logging "=== All tests completed ===" + if service_call.service == "nonexistent.action_for_error_test" and client_ref: + test_error_message = "Test error: action not found" + client_ref[0].send_homeassistant_action_response( + call_id=service_call.call_id, + success=False, + error_message=test_error_message, + ) + def check_output(line: str) -> None: """Check log output for expected messages.""" log_lines.append(line) @@ -131,7 +149,12 @@ async def test_api_homeassistant( if match: ha_number_future.set_result(match.group(1)) - elif not tests_complete_future.done() and tests_complete_pattern.search(line): + # Check for action error message (tests StringRef -> std::string conversion) + # Use separate if (not elif) since this can come after tests_complete + if not action_error_received_future.done() and "Action error received:" in line: + action_error_received_future.set_result(line) + + if not tests_complete_future.done() and tests_complete_pattern.search(line): tests_complete_future.set_result(True) # Run with log monitoring @@ -144,6 +167,9 @@ async def test_api_homeassistant( assert device_info is not None assert device_info.name == "test-ha-api" + # Store client reference for use in service call callback + client_ref.append(client) + # Subscribe to HomeAssistant service calls client.subscribe_service_calls(on_service_call) @@ -292,6 +318,17 @@ async def test_api_homeassistant( assert switch_off_call.service == "switch.turn_off" assert switch_off_call.data["entity_id"] == "switch.test_switch" + # 9. Action response error test (tests StringRef error message) + # The error response is sent automatically in on_service_call callback + # Wait for the error to be logged (proves StringRef -> std::string works) + error_log_line = await asyncio.wait_for( + action_error_received_future, timeout=2.0 + ) + test_error_message = "Test error: action not found" + assert test_error_message in error_log_line, ( + f"Expected error message '{test_error_message}' not found in: {error_log_line}" + ) + except TimeoutError as e: # Show recent log lines for debugging recent_logs = "\n".join(log_lines[-20:]) From 72c74bc0b303d491829e1d12d2e2a0e338a0dd24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:26:11 +0100 Subject: [PATCH 27/40] [api] Store Home Assistant state subscriptions in flash instead of heap (#12008) --- esphome/components/api/api_connection.cpp | 11 +++- esphome/components/api/api_server.cpp | 57 ++++++++++++++----- esphome/components/api/api_server.h | 22 ++++++- .../homeassistant_binary_sensor.cpp | 13 ++--- .../homeassistant_binary_sensor.h | 8 +-- .../number/homeassistant_number.cpp | 23 ++++---- .../number/homeassistant_number.h | 4 +- .../sensor/homeassistant_sensor.cpp | 15 +++-- .../sensor/homeassistant_sensor.h | 8 +-- .../switch/homeassistant_switch.cpp | 6 +- .../switch/homeassistant_switch.h | 4 +- .../text_sensor/homeassistant_text_sensor.cpp | 13 ++--- .../text_sensor/homeassistant_text_sensor.h | 8 +-- .../fixtures/api_custom_services.yaml | 1 + .../custom_api_device_component.cpp | 9 +++ .../custom_api_device_component.h | 3 + tests/integration/test_api_custom_services.py | 11 ++++ 17 files changed, 144 insertions(+), 72 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index d63d6eb2c5..4b10610281 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1580,7 +1580,12 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { for (auto &it : this->parent_->get_state_subs()) { - if (it.entity_id == msg.entity_id && it.attribute.value() == msg.attribute) { + // Compare entity_id and attribute with message fields + bool entity_match = (strcmp(it.entity_id, msg.entity_id.c_str()) == 0); + bool attribute_match = (it.attribute != nullptr && strcmp(it.attribute, msg.attribute.c_str()) == 0) || + (it.attribute == nullptr && msg.attribute.empty()); + + if (entity_match && attribute_match) { it.callback(msg.state); } } @@ -1959,8 +1964,8 @@ void APIConnection::process_state_subscriptions_() { SubscribeHomeAssistantStateResponse resp; resp.set_entity_id(StringRef(it.entity_id)); - // Avoid string copy by directly using the optional's value if it exists - resp.set_attribute(it.attribute.has_value() ? StringRef(it.attribute.value()) : StringRef("")); + // Avoid string copy by using the const char* pointer if it exists + resp.set_attribute(it.attribute != nullptr ? StringRef(it.attribute) : StringRef("")); resp.once = it.once; if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1921ca95d4..b1a5ee5d57 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -419,25 +419,56 @@ void APIServer::handle_action_response(uint32_t call_id, bool success, const std #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_API_HOMEASSISTANT_STATES +// Helper to add subscription (reduces duplication) +void APIServer::add_state_subscription_(const char *entity_id, const char *attribute, + std::function f, bool once) { + this->state_subs_.push_back(HomeAssistantStateSubscription{ + .entity_id = entity_id, .attribute = attribute, .callback = std::move(f), .once = once, + // entity_id_dynamic_storage and attribute_dynamic_storage remain nullptr (no heap allocation) + }); +} + +// Helper to add subscription with heap-allocated strings (reduces duplication) +void APIServer::add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once) { + HomeAssistantStateSubscription sub; + // Allocate heap storage for the strings + sub.entity_id_dynamic_storage = std::make_unique(std::move(entity_id)); + sub.entity_id = sub.entity_id_dynamic_storage->c_str(); + + if (attribute.has_value()) { + sub.attribute_dynamic_storage = std::make_unique(std::move(attribute.value())); + sub.attribute = sub.attribute_dynamic_storage->c_str(); + } else { + sub.attribute = nullptr; + } + + sub.callback = std::move(f); + sub.once = once; + this->state_subs_.push_back(std::move(sub)); +} + +// New const char* overload (for internal components - zero allocation) +void APIServer::subscribe_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), false); +} + +void APIServer::get_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), true); +} + +// Existing std::string overload (for custom_api_device.h - heap allocation) void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = false, - }); + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), false); } void APIServer::get_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = true, - }); -}; + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), true); +} const std::vector &APIServer::get_state_subs() const { return this->state_subs_; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 2175d047eb..ad7d8bf63d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -190,16 +190,27 @@ class APIServer : public Component, #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { - std::string entity_id; - optional attribute; + const char *entity_id; // Pointer to flash (internal) or heap (external) + const char *attribute; // Pointer to flash or nullptr (nullptr means no attribute) std::function callback; bool once; + + // Dynamic storage for external components using std::string API (custom_api_device.h) + // These are only allocated when using the std::string overload (nullptr for const char* overload) + std::unique_ptr entity_id_dynamic_storage; + std::unique_ptr attribute_dynamic_storage; }; + // New const char* overload (for internal components - zero allocation) + void subscribe_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + void get_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + + // Existing std::string overload (for custom_api_device.h - heap allocation) void subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f); void get_home_assistant_state(std::string entity_id, optional attribute, std::function f); + const std::vector &get_state_subs() const; #endif #ifdef USE_API_USER_DEFINED_ACTIONS @@ -220,6 +231,13 @@ class APIServer : public Component, bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, const psk_t &active_psk, bool make_active); #endif // USE_API_NOISE +#ifdef USE_API_HOMEASSISTANT_STATES + // Helper methods to reduce code duplication + void add_state_subscription_(const char *entity_id, const char *attribute, std::function f, + bool once); + void add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once); +#endif // USE_API_HOMEASSISTANT_STATES // Pointers and pointer-like types first (4 bytes each) std::unique_ptr socket_ = nullptr; #ifdef USE_API_CLIENT_CONNECTED_TRIGGER diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index a36fcb204a..5652e7d603 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -19,11 +19,10 @@ void HomeassistantBinarySensor::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_.c_str(), - this->attribute_.value().c_str(), ONOFF(new_state)); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_, this->attribute_, ONOFF(new_state)); } else { - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); } if (this->initial_) { this->publish_initial_state(new_state); @@ -37,9 +36,9 @@ void HomeassistantBinarySensor::setup() { } void HomeassistantBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Homeassistant Binary Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantBinarySensor::get_setup_priority() const { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 7026496295..9aec61a370 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; bool initial_{true}; }; diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 9963f3431d..1ca90180eb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -12,21 +12,21 @@ static const char *const TAG = "homeassistant.number"; void HomeassistantNumber::state_changed_(const std::string &state) { auto number_value = parse_number(state); if (!number_value.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } if (this->state == number_value.value()) { return; } - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); this->publish_state(number_value.value()); } void HomeassistantNumber::min_retrieved_(const std::string &min) { auto min_value = parse_number(min); if (!min_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_.c_str(), min.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); return; } ESP_LOGD(TAG, "'%s': Min retrieved: %s", get_name().c_str(), min.c_str()); @@ -36,7 +36,7 @@ void HomeassistantNumber::min_retrieved_(const std::string &min) { void HomeassistantNumber::max_retrieved_(const std::string &max) { auto max_value = parse_number(max); if (!max_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_.c_str(), max.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); return; } ESP_LOGD(TAG, "'%s': Max retrieved: %s", get_name().c_str(), max.c_str()); @@ -46,7 +46,7 @@ void HomeassistantNumber::max_retrieved_(const std::string &max) { void HomeassistantNumber::step_retrieved_(const std::string &step) { auto step_value = parse_number(step); if (!step_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_.c_str(), step.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_, step.c_str()); return; } ESP_LOGD(TAG, "'%s': Step Retrieved %s", get_name().c_str(), step.c_str()); @@ -55,22 +55,19 @@ void HomeassistantNumber::step_retrieved_(const std::string &step) { void HomeassistantNumber::setup() { api::global_api_server->subscribe_home_assistant_state( - this->entity_id_, nullopt, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); + this->entity_id_, nullptr, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("min"), - std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "min", std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("max"), - std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "max", std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("step"), - std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "step", std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); } void HomeassistantNumber::dump_config() { LOG_NUMBER("", "Homeassistant Number", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantNumber::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index 0860b4e91c..0dffc108cb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -11,7 +11,7 @@ namespace homeassistant { class HomeassistantNumber : public number::Number, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; @@ -25,7 +25,7 @@ class HomeassistantNumber : public number::Number, public Component { void control(float value) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant } // namespace esphome diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 35e660f7c1..78da47f9a1 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -12,25 +12,24 @@ void HomeassistantSensor::setup() { this->entity_id_, this->attribute_, [this](const std::string &state) { auto val = parse_number(state); if (!val.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_.c_str(), - this->attribute_.value().c_str(), *val); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_.c_str(), *val); + ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); } void HomeassistantSensor::dump_config() { LOG_SENSOR("", "Homeassistant Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index 53b288d7d4..d89fc069ff 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantSensor : public sensor::Sensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 27d3705fc2..c4abf2295d 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "homeassistant.switch"; using namespace esphome::switch_; void HomeassistantSwitch::setup() { - api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullopt, [this](const std::string &state) { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](const std::string &state) { auto val = parse_on_off(state.c_str()); switch (val) { case PARSE_NONE: @@ -20,7 +20,7 @@ void HomeassistantSwitch::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); this->publish_state(new_state); break; } @@ -29,7 +29,7 @@ void HomeassistantSwitch::setup() { void HomeassistantSwitch::dump_config() { LOG_SWITCH("", "Homeassistant Switch", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantSwitch::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index a4da257960..c180b7f98a 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -8,14 +8,14 @@ namespace homeassistant { class HomeassistantSwitch : public switch_::Switch, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: void write_state(bool state) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 9b933fbbbe..6154330a4e 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -10,20 +10,19 @@ static const char *const TAG = "homeassistant.text_sensor"; void HomeassistantTextSensor::setup() { api::global_api_server->subscribe_home_assistant_state( this->entity_id_, this->attribute_, [this](const std::string &state) { - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_.c_str(), - this->attribute_.value().c_str(), state.c_str()); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_, this->attribute_, state.c_str()); } else { - ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); } this->publish_state(state); }); } void HomeassistantTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Homeassistant Text Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index ce6b2c2c3f..4d66c65a17 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/tests/integration/fixtures/api_custom_services.yaml b/tests/integration/fixtures/api_custom_services.yaml index a597c74126..827bee93a6 100644 --- a/tests/integration/fixtures/api_custom_services.yaml +++ b/tests/integration/fixtures/api_custom_services.yaml @@ -5,6 +5,7 @@ host: # This is required for CustomAPIDevice to work api: custom_services: true + homeassistant_states: true # Also test that YAML services still work actions: - action: test_yaml_service diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index c8581b3d2f..01bc7dcd98 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -17,6 +17,10 @@ void CustomAPIDeviceComponent::setup() { // Test array types register_service(&CustomAPIDeviceComponent::on_service_with_arrays, "custom_service_with_arrays", {"bool_array", "int_array", "float_array", "string_array"}); + + // Test Home Assistant state subscription using std::string API (custom_api_device.h) + // This tests the backward compatibility of the std::string overloads + subscribe_homeassistant_state(&CustomAPIDeviceComponent::on_ha_state_changed, std::string("sensor.custom_test")); } void CustomAPIDeviceComponent::on_test_service() { ESP_LOGI(TAG, "Custom test service called!"); } @@ -48,6 +52,11 @@ void CustomAPIDeviceComponent::on_service_with_arrays(std::vector bool_arr } } +void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::string state) { + ESP_LOGI(TAG, "Home Assistant state changed for %s: %s", entity_id.c_str(), state.c_str()); + ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); +} + } // namespace custom_api_device_component } // namespace esphome #endif // USE_API diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 92960746d9..0720b9e7de 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -22,6 +22,9 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { void on_service_with_arrays(std::vector bool_array, std::vector int_array, std::vector float_array, std::vector string_array); + + // Test Home Assistant state subscription with std::string API + void on_ha_state_changed(std::string entity_id, std::string state); }; } // namespace custom_api_device_component diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index cd33b5a1fc..acf69bf092 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -38,6 +38,7 @@ async def test_api_custom_services( custom_service_future = loop.create_future() custom_args_future = loop.create_future() custom_arrays_future = loop.create_future() + ha_state_future = loop.create_future() # Patterns to match in logs yaml_service_pattern = re.compile(r"YAML service called") @@ -50,6 +51,9 @@ async def test_api_custom_services( custom_arrays_pattern = re.compile( r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings" ) + ha_state_pattern = re.compile( + r"This subscription uses std::string API for backward compatibility" + ) def check_output(line: str) -> None: """Check log output for expected messages.""" @@ -65,6 +69,8 @@ async def test_api_custom_services( custom_args_future.set_result(True) elif not custom_arrays_future.done() and custom_arrays_pattern.search(line): custom_arrays_future.set_result(True) + elif not ha_state_future.done() and ha_state_pattern.search(line): + ha_state_future.set_result(True) # Run with log monitoring async with ( @@ -198,3 +204,8 @@ async def test_api_custom_services( }, ) await asyncio.wait_for(custom_arrays_future, timeout=5.0) + + # Test Home Assistant state subscription (std::string API backward compatibility) + # This verifies that custom_api_device.h can still use std::string overloads + client.send_home_assistant_state("sensor.custom_test", "", "42.5") + await asyncio.wait_for(ha_state_future, timeout=5.0) From e96c37965c6118a679da8198e57955b9c0450854 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:26:27 +0100 Subject: [PATCH 28/40] [wifi] Fix LibreTiny spurious disconnect events aborting connections (#12357) --- .../wifi/wifi_component_libretiny.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index d6bc8e53da..4fd64bdfa3 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -312,6 +312,23 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ char buf[33]; memcpy(buf, it.ssid, it.ssid_len); buf[it.ssid_len] = '\0'; + + // LibreTiny can send spurious disconnect events with empty ssid/bssid during connection. + // These are typically "Association Leave" events that don't indicate actual failures: + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [V][wifi_lt]: Connected ssid='WIFI' bssid=... channel=3, authmode=WPA2 PSK + // Without this check, the spurious events set s_sta_connecting=false, causing + // wifi_sta_connect_status_() to return IDLE. The main loop then sees + // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) + // and calls retry_connect(), aborting a connection that may succeed moments later. + // Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout. + if (it.ssid_len == 0 && s_sta_connecting) { + ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", + get_disconnect_reason_str(it.reason)); + break; + } + if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); } else { From 2c0f4d8f806ca17069112be5098dedf56805be5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Dec 2025 16:35:14 +0100 Subject: [PATCH 29/40] [api] Reduce heap usage for Home Assistant service call string storage (#12151) --- .../components/api/homeassistant_service.h | 32 ++++++++++------ esphome/core/automation.h | 37 ++++++++++++++----- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 397520fa2e..2da6e15362 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -17,6 +17,12 @@ namespace esphome::api { template class TemplatableStringValue : public TemplatableValue { + // Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation) + // rather than being wrapped in a lambda. The base class constructor for const char* is more + // specialized than the templated constructor here, so it should be selected. + static_assert(std::is_constructible_v, const char *>, + "Base class must have const char* constructor for STATIC_STRING optimization"); + private: // Helper to convert value to string - handles the case where value is already a string template static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } @@ -47,10 +53,10 @@ template class TemplatableKeyValuePair { // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") // and never templatable values or lambdas. Only the value parameter can be a lambda/template. - // Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues. - template TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {} + // Using const char* avoids std::string heap allocation - keys remain in flash. + template TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {} - std::string key; + const char *key{nullptr}; TemplatableStringValue value; }; @@ -109,14 +115,15 @@ template class HomeAssistantServiceCallAction : public Action void add_data(K &&key, V &&value) { - this->add_kv_(this->data_, std::forward(key), std::forward(value)); + // Using const char* for keys avoids std::string heap allocation - keys remain in flash. + template void add_data(const char *key, V &&value) { + this->add_kv_(this->data_, key, std::forward(value)); } - template void add_data_template(K &&key, V &&value) { - this->add_kv_(this->data_template_, std::forward(key), std::forward(value)); + template void add_data_template(const char *key, V &&value) { + this->add_kv_(this->data_template_, key, std::forward(value)); } - template void add_variable(K &&key, V &&value) { - this->add_kv_(this->variables_, std::forward(key), std::forward(value)); + template void add_variable(const char *key, V &&value) { + this->add_kv_(this->variables_, key, std::forward(value)); } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -189,10 +196,11 @@ template class HomeAssistantServiceCallAction : public Action void add_kv_(FixedVector> &vec, K &&key, V &&value) { + // Helper to add key-value pairs to FixedVectors + // Keys are always string literals (const char*), values can be lambdas/templates + template void add_kv_(FixedVector> &vec, const char *key, V &&value) { auto &kv = vec.emplace_back(); - kv.key = std::forward(key); + kv.key = key; kv.value = std::forward(value); } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index dacadd35e8..61d2944acf 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -45,6 +45,12 @@ template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} + // For const char* when T is std::string: store pointer directly, no heap allocation + // String remains in flash and is only converted to std::string when value() is called + TemplatableValue(const char *str) requires std::same_as : type_(STATIC_STRING) { + this->static_str_ = str; + } + template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { new (&this->value_) T(std::move(value)); } @@ -64,24 +70,28 @@ template class TemplatableValue { // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(other.value_); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = new std::function(*other.f_); - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } } // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(std::move(other.value_)); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } other.type_ = NONE; } @@ -104,12 +114,12 @@ template class TemplatableValue { } ~TemplatableValue() { - if (type_ == VALUE) { + if (this->type_ == VALUE) { this->value_.~T(); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { delete this->f_; } - // STATELESS_LAMBDA/NONE: no cleanup needed (function pointer or empty, not heap-allocated) + // STATELESS_LAMBDA/STATIC_STRING/NONE: no cleanup needed (pointers, not heap-allocated) } bool has_value() { return this->type_ != NONE; } @@ -122,6 +132,13 @@ template class TemplatableValue { return (*this->f_)(x...); // std::function call case VALUE: return this->value_; + case STATIC_STRING: + // if constexpr required: code must compile for all T, but STATIC_STRING + // can only be set when T is std::string (enforced by constructor constraint) + if constexpr (std::same_as) { + return std::string(this->static_str_); + } + __builtin_unreachable(); case NONE: default: return T{}; @@ -148,12 +165,14 @@ template class TemplatableValue { VALUE, LAMBDA, STATELESS_LAMBDA, + STATIC_STRING, // For const char* when T is std::string - avoids heap allocation } type_; union { T value_; std::function *f_; T (*stateless_f_)(X...); + const char *static_str_; // For STATIC_STRING type }; }; From fb7800a22f489bbdc23c016b4ee71491e1d3f9a2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 09:59:29 +1100 Subject: [PATCH 30/40] [binary_sensor] Fix reporting of 'unknown' (#12296) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../binary_sensor/binary_sensor.cpp | 13 +- .../components/binary_sensor/binary_sensor.h | 2 + esphome/core/entity_base.h | 16 +- tests/integration/README.md | 24 ++- .../binary_sensor_invalidate_state.yaml | 39 +++++ tests/integration/state_utils.py | 63 ++++++++ .../test_binary_sensor_invalidate_state.py | 138 ++++++++++++++++++ 7 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/binary_sensor_invalidate_state.yaml create mode 100644 tests/integration/test_binary_sensor_invalidate_state.py diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 220ed685db..97dfbb6a24 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -36,13 +36,20 @@ void BinarySensor::publish_initial_state(bool new_state) { void BinarySensor::send_state_internal(bool new_state) { // copy the new state to the visible property for backwards compatibility, before any callbacks this->state = new_state; - // Note that set_state_ de-dups and will only trigger callbacks if the state has actually changed - if (this->set_state_(new_state)) { - ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(new_state)); + // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed + this->set_new_state(new_state); +} + +bool BinarySensor::set_new_state(const optional &new_state) { + if (StatefulEntityBase::set_new_state(new_state)) { + // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif + ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + return true; } + return false; } void BinarySensor::add_filter(Filter *filter) { diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index c1661d710f..3f77def9a0 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -63,6 +63,8 @@ class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceCl protected: Filter *filter_list_{nullptr}; + + bool set_new_state(const optional &new_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2b52d66f76..8c87806f33 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -202,7 +202,7 @@ template class StatefulEntityBase : public EntityBase { virtual bool has_state() const { return this->state_.has_value(); } virtual const T &get_state() const { return this->state_.value(); } virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } - void invalidate_state() { this->set_state_({}); } + void invalidate_state() { this->set_new_state({}); } void add_full_state_callback(std::function previous, optional current)> &&callback) { if (this->full_state_callbacks_ == nullptr) @@ -224,20 +224,20 @@ template class StatefulEntityBase : public EntityBase { /** * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. * - * @param state The new state. + * @param new_state The new state. * @return True if the state was changed, false if it was the same as before. */ - bool set_state_(const optional &state) { - if (this->state_ != state) { + virtual bool set_new_state(const optional &new_state) { + if (this->state_ != new_state) { // call the full state callbacks with the previous and new state if (this->full_state_callbacks_ != nullptr) - this->full_state_callbacks_->call(this->state_, state); + this->full_state_callbacks_->call(this->state_, new_state); // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or // the previous state was valid auto had_state = this->has_state(); - this->state_ = state; - if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_->call(state.value()); + this->state_ = new_state; + if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) + this->state_callbacks_->call(new_state.value()); return true; } return false; diff --git a/tests/integration/README.md b/tests/integration/README.md index 2a6b6fe564..f99139db00 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te - `conftest.py` - Common fixtures and utilities - `const.py` - Constants used throughout the integration tests - `types.py` - Type definitions for fixtures and functions -- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`) +- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`) - `fixtures/` - YAML configuration files for tests - `test_*.py` - Individual test files @@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe **Future work:** Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts. +#### Entity Lookup Helpers (`state_utils.py`) + +Two helper functions simplify finding entities in test code: + +**`find_entity(entities, object_id_substring, entity_type=None)`** +- Finds an entity by searching for a substring in its `object_id` (case-insensitive) +- Optionally filters by entity type (e.g., `BinarySensorInfo`) +- Returns `None` if not found + +**`require_entity(entities, object_id_substring, entity_type=None, description=None)`** +- Same as `find_entity` but raises `AssertionError` if not found +- Use `description` parameter for clearer error messages + +```python +from aioesphomeapi import BinarySensorInfo +from .state_utils import require_entity + +# Find entities with clear error messages +binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) +button = require_entity(entities, "set_true", description="Set True button") +``` + ### Writing Tests The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures: diff --git a/tests/integration/fixtures/binary_sensor_invalidate_state.yaml b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml new file mode 100644 index 0000000000..4016cfe281 --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml @@ -0,0 +1,39 @@ +esphome: + name: test-binary-sensor-invalidate + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Template binary sensor that we can control +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + +# Buttons to control the binary sensor state +button: + - platform: template + name: "Set True" + id: set_true_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: true + + - platform: template + name: "Set False" + id: set_false_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: false + + - platform: template + name: "Invalidate State" + id: invalidate_button + on_press: + - binary_sensor.invalidate_state: + id: test_binary_sensor diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 6434a41ddf..b649056f2b 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -4,11 +4,74 @@ from __future__ import annotations import asyncio import logging +from typing import TypeVar from aioesphomeapi import ButtonInfo, EntityInfo, EntityState _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", bound=EntityInfo) + + +def find_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, +) -> T | EntityInfo | None: + """Find an entity by object_id substring and optionally by type. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + + Returns: + The first matching entity, or None if not found + + Example: + binary_sensor = find_entity(entities, "test_binary_sensor", BinarySensorInfo) + button = find_entity(entities, "set_true") # Any entity type + """ + substring_lower = object_id_substring.lower() + for entity in entities: + if substring_lower in entity.object_id.lower() and ( + entity_type is None or isinstance(entity, entity_type) + ): + return entity + return None + + +def require_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, + description: str | None = None, +) -> T | EntityInfo: + """Find an entity or raise AssertionError if not found. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + description: Human-readable description for error message + + Returns: + The first matching entity + + Raises: + AssertionError: If no matching entity is found + + Example: + binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) + button = require_entity(entities, "set_true", description="Set True button") + """ + entity = find_entity(entities, object_id_substring, entity_type) + if entity is None: + desc = description or f"entity with '{object_id_substring}' in object_id" + type_info = f" of type {entity_type.__name__}" if entity_type else "" + raise AssertionError(f"{desc}{type_info} not found in entities") + return entity + def build_key_to_entity_mapping( entities: list[EntityInfo], entity_names: list[str] diff --git a/tests/integration/test_binary_sensor_invalidate_state.py b/tests/integration/test_binary_sensor_invalidate_state.py new file mode 100644 index 0000000000..ee9e57319c --- /dev/null +++ b/tests/integration/test_binary_sensor_invalidate_state.py @@ -0,0 +1,138 @@ +"""Integration test for binary_sensor.invalidate_state() functionality. + +This tests the fix in PR #12296 where invalidate_state() was not properly +reporting the 'unknown' state to the API. The binary sensor should report +missing_state=True when invalidated. + +Regression test for: https://github.com/esphome/esphome/issues/12252 +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_binary_sensor_invalidate_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that binary_sensor.invalidate_state() reports unknown to the API. + + This verifies that: + 1. Binary sensor starts with missing_state=True (no initial state) + 2. Publishing true sets missing_state=False and state=True + 3. Publishing false sets missing_state=False and state=False + 4. Invalidating state sets missing_state=True (unknown state) + """ + loop = asyncio.get_running_loop() + + # Track state changes + states_received: list[BinarySensorState] = [] + state_future: asyncio.Future[BinarySensorState] = loop.create_future() + + def on_state(state: EntityState) -> None: + """Track binary sensor state changes.""" + if isinstance(state, BinarySensorState): + states_received.append(state) + if not state_future.done(): + state_future.set_result(state) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-binary-sensor-invalidate" + + # Get entities + entities, _ = await client.list_entities_services() + + # Find our binary sensor and buttons using helper + binary_sensor = require_entity(entities, "test_binary_sensor", BinarySensorInfo) + set_true_button = require_entity( + entities, "set_true", description="Set True button" + ) + set_false_button = require_entity( + entities, "set_false", description="Set False button" + ) + invalidate_button = require_entity( + entities, "invalidate", description="Invalidate button" + ) + + # Set up initial state helper to handle the initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Check initial state - should be missing (unknown) + initial_state = initial_state_helper.initial_states.get(binary_sensor.key) + assert initial_state is not None, "No initial state received for binary sensor" + assert isinstance(initial_state, BinarySensorState) + assert initial_state.missing_state is True, ( + f"Initial state should have missing_state=True, got {initial_state}" + ) + + # Test 1: Set state to true + states_received.clear() + state_future = loop.create_future() + client.button_command(set_true_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=true") + + assert state.missing_state is False, ( + f"After setting true, missing_state should be False, got {state}" + ) + assert state.state is True, f"Expected state=True, got {state}" + + # Test 2: Set state to false + states_received.clear() + state_future = loop.create_future() + client.button_command(set_false_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=false") + + assert state.missing_state is False, ( + f"After setting false, missing_state should be False, got {state}" + ) + assert state.state is False, f"Expected state=False, got {state}" + + # Test 3: Invalidate state (set to unknown) + # This is the critical test for the bug fix + states_received.clear() + state_future = loop.create_future() + client.button_command(invalidate_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for invalidated state - " + "binary_sensor.invalidate_state() may not be reporting to the API. " + "See issue #12252." + ) + + assert state.missing_state is True, ( + f"After invalidate_state(), missing_state should be True (unknown), " + f"got {state}. This is the regression from issue #12252." + ) From b6336f9e638d44c764ef44d9cc94746e5c2b4b63 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 7 Dec 2025 01:49:15 +1100 Subject: [PATCH 31/40] [lvgl] Number saves value on interactive change (#12315) --- esphome/components/lvgl/number/lvgl_number.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 7bc44c9e20..d9885bc7fb 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -29,15 +29,18 @@ class LVGLNumber : public number::Number, public Component { this->publish_state(value); } - void on_value() { this->publish_state(this->value_lambda_()); } + void on_value() { this->publish_(this->value_lambda_()); } protected: - void control(float value) override { - this->control_lambda_(value); + void publish_(float value) { this->publish_state(value); if (this->restore_) this->pref_.save(&value); } + void control(float value) override { + this->control_lambda_(value); + this->publish_(value); + } std::function control_lambda_; std::function value_lambda_; lv_event_code_t event_; From b213555dd24b13d57108cd706155c6f2274fff4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 13:30:22 -0600 Subject: [PATCH 32/40] [scheduler] Fix missing lock when recycling items in defer queue processing (#12343) --- esphome/core/scheduler.cpp | 4 ++++ esphome/core/scheduler.h | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5e313f770f..f84495950c 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -744,6 +744,10 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } +// Recycle a SchedulerItem back to the pool for reuse. +// IMPORTANT: Caller must hold the scheduler lock before calling this function. +// This protects scheduler_item_pool_ from concurrent access by other threads +// that may be acquiring items from the pool in set_timer_common_(). void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index dcf418c14f..5bf3d19adb 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -275,6 +275,7 @@ class Scheduler { // Helper to recycle a SchedulerItem back to the pool. // IMPORTANT: Only call from main loop context! Recycling clears the callback, // so calling from another thread while the callback is executing causes use-after-free. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled @@ -331,7 +332,10 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_main_loop_(std::move(item)); + { + LockGuard lock(this->lock_); + this->recycle_item_main_loop_(std::move(item)); + } } // If we've consumed all items up to the snapshot point, clean up the dead space From 436d2c44e84888b59b2ba294c47b68544708bda7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 7 Dec 2025 22:00:04 -0600 Subject: [PATCH 33/40] [wifi] Fix scan timeout loop when scan returns zero networks (#12354) --- esphome/components/wifi/wifi_component.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index abf62cb063..2882eab934 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1209,8 +1209,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } case WiFiRetryPhase::SCAN_CONNECTING: - // If scan found no matching networks, skip to hidden network mode - if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) { + // If scan found no networks or no matching networks, skip to hidden network mode + if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) { return WiFiRetryPhase::RETRY_HIDDEN; } From 16fe8f9e9ed660e18c8fd6b8bbf515fccc5a27d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Dec 2025 15:09:04 +0100 Subject: [PATCH 34/40] [libretiny] Fix WiFi scan timeout loop when scan fails (#12356) --- esphome/components/wifi/wifi_component_libretiny.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 8c6c28ac75..c99924785a 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -412,6 +412,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } void WiFiComponent::wifi_scan_done_callback_() { this->scan_result_.clear(); + this->scan_done_ = true; int16_t num = WiFi.scanComplete(); if (num < 0) @@ -430,7 +431,6 @@ void WiFiComponent::wifi_scan_done_callback_() { ssid.length() == 0); } WiFi.scanDelete(); - this->scan_done_ = true; } #ifdef USE_WIFI_AP From 464607011c342f9540e25ad05da17a30faf4c645 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:43:47 -0500 Subject: [PATCH 35/40] [mqtt] Fix logger method case sensitivity error (#12379) Co-authored-by: Claude --- esphome/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 0d50edbc2c..042df12d67 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -192,7 +192,7 @@ def get_esphome_device_ip( data = json.loads(payload) if "name" not in data or data["name"] != dev_name: - _LOGGER.Warn("Wrong device answer") + _LOGGER.warning("Wrong device answer") return dev_ip = [] From 4743e5592a2cb52b0e5965e675379bd507d8d845 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:02:53 -0500 Subject: [PATCH 36/40] Bump version to 2025.11.5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index dbb744767b..17c1f3d929 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.11.4 +PROJECT_NUMBER = 2025.11.5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 472e0a7bee..f50a3e3bb1 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.4" +__version__ = "2025.11.5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 329b38fa296b88c1dca4cd25ced351048a0ea85e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 9 Dec 2025 20:30:55 +0100 Subject: [PATCH 37/40] [micronova] Require memory location and address for custom entities (#12371) --- esphome/components/micronova/__init__.py | 24 +++++++++++-------- .../components/micronova/button/__init__.py | 6 ++--- .../components/micronova/sensor/__init__.py | 2 -- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 52fbae2da2..d6ef93cf30 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -40,21 +40,25 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( def MICRONOVA_ADDRESS_SCHEMA( *, - default_memory_location: int, - default_memory_address: int, + default_memory_location: int | None = None, + default_memory_address: int | None = None, is_polling_component: bool, ): + location_key = ( + cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) + if default_memory_location is not None + else cv.Required(CONF_MEMORY_LOCATION) + ) + address_key = ( + cv.Optional(CONF_MEMORY_ADDRESS, default=default_memory_address) + if default_memory_address is not None + else cv.Required(CONF_MEMORY_ADDRESS) + ) schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), - # On write requests the write bit (0x80) is added automatically to the location - # Therefore no locations >= 0x80 are allowed - cv.Optional( - CONF_MEMORY_LOCATION, default=default_memory_location - ): cv.hex_int_range(min=0x00, max=0x79), - cv.Optional( - CONF_MEMORY_ADDRESS, default=default_memory_address - ): cv.hex_int_range(min=0x00, max=0xFF), + location_key: cv.hex_int_range(min=0x00, max=0x79), + address_key: cv.hex_int_range(min=0x00, max=0xFF), } ) if is_polling_component: diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 2eda887443..6adf8d96fe 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -24,8 +24,6 @@ CONFIG_SCHEMA = cv.Schema( ) .extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x20, - default_memory_address=0x7D, is_polling_component=False, ) ) @@ -39,6 +37,6 @@ async def to_code(config): if custom_button_config := config.get(CONF_CUSTOM_BUTTON): bt = await button.new_button(custom_button_config, mv) - cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION))) - cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS))) + cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) + cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 55318a7fff..e53c49aca5 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -118,8 +118,6 @@ CONFIG_SCHEMA = cv.Schema( MicroNovaSensor, ).extend( MICRONOVA_ADDRESS_SCHEMA( - default_memory_location=0x00, - default_memory_address=0x00, is_polling_component=True, ) ), From 87142efbb43e1fd7619359f15df27cd7b05f0863 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:42:11 +1100 Subject: [PATCH 38/40] [epaper_spi] Set reasonable default update interval (#12331) --- esphome/components/epaper_spi/display.py | 15 +++++++++------ esphome/components/epaper_spi/epaper_spi.cpp | 10 +++++----- esphome/components/epaper_spi/epaper_spi.h | 2 +- .../components/epaper_spi/models/spectra_e6.py | 6 +++--- esphome/components/lvgl/lvgl_esphome.cpp | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ff5693c206..b7e71a3cae 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -41,6 +41,7 @@ AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] CONF_INIT_SEQUENCE_ID = "init_sequence_id" +CONF_MINIMUM_UPDATE_INTERVAL = "minimum_update_interval" epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi") EPaperBase = epaper_spi_ns.class_( @@ -71,6 +72,9 @@ TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) + minimum_update_interval = update_interval( + model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s") + ) cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required return ( display.FULL_DISPLAY_SCHEMA.extend( @@ -90,9 +94,9 @@ def model_schema(config): { cv.Optional(CONF_ROTATION, default=0): validate_rotation, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), - cv.Optional( - CONF_UPDATE_INTERVAL, default=cv.UNDEFINED - ): update_interval, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): cv.All( + update_interval, cv.Range(min=minimum_update_interval) + ), cv.Optional(CONF_TRANSFORM): cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, @@ -153,9 +157,8 @@ def _final_validate(config): else: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - config[CONF_UPDATE_INTERVAL] = core.TimePeriod( - seconds=60 - ).total_milliseconds + elif CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("1min") return config diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index f6313d33ef..b2e58694c8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -286,7 +286,7 @@ void EPaperBase::initialise_() { * @param y * @return false if the coordinates are out of bounds */ -bool EPaperBase::rotate_coordinates_(int &x, int &y) const { +bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; if (this->transform_ & SWAP_XY) @@ -297,6 +297,10 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) const { y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); return true; } @@ -319,10 +323,6 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } else { this->buffer_[byte_position] = original | pixel_bit; } - this->x_low_ = clamp_at_most(this->x_low_, x); - this->x_high_ = clamp_at_least(this->x_high_, x + 1); - this->y_low_ = clamp_at_most(this->y_low_, y); - this->y_high_ = clamp_at_least(this->y_high_, y + 1); } void EPaperBase::dump_config() { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 544ea3e9ba..6852416cac 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,7 +106,7 @@ class EPaperBase : public Display, void initialise_(); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); - bool rotate_coordinates_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 42a5a7da72..58015f486e 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -4,8 +4,8 @@ from . import EpaperModel class SpectraE6(EpaperModel): - def __init__(self, name, class_name="EPaperSpectraE6", **kwargs): - super().__init__(name, class_name, **kwargs) + def __init__(self, name, class_name="EPaperSpectraE6", **defaults): + super().__init__(name, class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): @@ -30,7 +30,7 @@ class SpectraE6(EpaperModel): return self.defaults.get(key, fallback) -spectra_e6 = SpectraE6("spectra-e6") +spectra_e6 = SpectraE6("spectra-e6", minimum_update_interval="30s") spectra_e6_7p3 = spectra_e6.extend( "7.3in-Spectra-E6", diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 18226a9f57..50dba94a2b 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -498,12 +498,12 @@ void LvglComponent::setup() { buf_bytes /= MIN_BUFFER_FRAC; buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } + this->buffer_frac_ = frac; if (buffer == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } - this->buffer_frac_ = frac; lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels); this->disp_drv_.hor_res = display->get_width(); this->disp_drv_.ver_res = display->get_height(); From ad0218fd4097b7212a16a1a8132e85413950a127 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:17:59 +1100 Subject: [PATCH 39/40] [mipi_rgb] Add Waveshare 3.16 (#12309) --- esphome/components/mipi_rgb/display.py | 15 ++-- esphome/components/mipi_rgb/mipi_rgb.cpp | 15 +--- esphome/components/mipi_rgb/models/lilygo.py | 2 - esphome/components/mipi_rgb/models/st7701s.py | 1 - .../components/mipi_rgb/models/waveshare.py | 76 ++++++++++++++++++- 5 files changed, 81 insertions(+), 28 deletions(-) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 2d2e022045..61dbeb8ed4 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -24,7 +24,7 @@ from esphome.components.mipi import ( CONF_VSYNC_BACK_PORCH, CONF_VSYNC_FRONT_PORCH, CONF_VSYNC_PULSE_WIDTH, - MODE_BGR, + MODE_RGB, PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, DriverChip, @@ -157,7 +157,7 @@ def model_schema(config): model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list( pins.gpio_output_pin_schema ), - model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True), + model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True), model.option(CONF_DRAW_ROUNDING, 2): power_of_two, model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( *pixel_modes, lower=True @@ -280,14 +280,9 @@ async def to_code(config): red_pins = config[CONF_DATA_PINS][CONF_RED] green_pins = config[CONF_DATA_PINS][CONF_GREEN] blue_pins = config[CONF_DATA_PINS][CONF_BLUE] - if config[CONF_COLOR_ORDER] == "BGR": - dpins.extend(red_pins) - dpins.extend(green_pins) - dpins.extend(blue_pins) - else: - dpins.extend(blue_pins) - dpins.extend(green_pins) - dpins.extend(red_pins) + dpins.extend(blue_pins) + dpins.extend(green_pins) + dpins.extend(red_pins) # swap bytes to match big-endian format dpins = dpins[8:16] + dpins[0:8] else: diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 74eedae4f4..d5d1caf6d2 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -371,17 +371,10 @@ void MipiRgb::dump_config() { get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); - if (this->madctl_ & MADCTL_BGR) { - this->dump_pins_(8, 13, "Blue", 0); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Red", 0); - } else { - this->dump_pins_(8, 13, "Red", 0); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Blue", 0); - } + this->dump_pins_(8, 13, "Blue", 0); + this->dump_pins_(13, 16, "Green", 0); + this->dump_pins_(0, 3, "Green", 3); + this->dump_pins_(3, 8, "Red", 0); } } // namespace mipi_rgb diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index 109dc42af6..c0e91cd8ae 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -7,7 +7,6 @@ ST7701S( "T-PANEL-S3", width=480, height=480, - color_order="BGR", invert_colors=False, swap_xy=UNDEFINED, spi_mode="MODE3", @@ -56,7 +55,6 @@ t_rgb = ST7701S( "T-RGB-2.1", width=480, height=480, - color_order="BGR", pixel_mode="18bit", invert_colors=False, swap_xy=UNDEFINED, diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 0b0a9548ca..3c66380d04 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -82,7 +82,6 @@ st7701s.extend( "MAKERFABS-4", width=480, height=480, - color_order="RGB", invert_colors=True, pixel_mode="18bit", cs_pin=1, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 0fc765fd52..cd1fc341ef 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,13 +1,13 @@ -from esphome.components.mipi import DriverChip +from esphome.components.mipi import DriverChip, delay from esphome.config_validation import UNDEFINED from .st7701s import st7701s +# fmt: off wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), - color_order="RGB", width=800, height=480, pclk_frequency="16MHz", @@ -55,10 +55,9 @@ wave_4_3.extend( ) st7701s.extend( - "WAVESHARE-4-480x480", + "WAVESHARE-4-480X480", data_rate="2MHz", spi_mode="MODE3", - color_order="BGR", pixel_mode="18bit", width=480, height=480, @@ -76,3 +75,72 @@ st7701s.extend( "blue": [5, 45, 48, 47, 21], }, ) + +st7701s.extend( + "WAVESHARE-3.16-320X820", + width=320, + height=820, + de_pin=40, + hsync_pin=38, + vsync_pin=39, + pclk_pin=41, + cs_pin={ + "number": 0, + "ignore_strapping_warning": True, + }, + pclk_frequency="18MHz", + reset_pin=16, + hsync_back_porch=30, + hsync_front_porch=30, + hsync_pulse_width=6, + vsync_back_porch=20, + vsync_front_porch=20, + vsync_pulse_width=40, + data_pins={ + "red": [17, 46, 3, 8, 18], + "green": [14, 13, 12, 11, 10, 9], + "blue": [21, 5, 45, 48, 47], + }, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0xE5, 0x02), + (0xC1, 0x15, 0x0A), + (0xC2, 0x07, 0x02), + (0xCC, 0x10), + (0xB0, 0x00, 0x08, 0x51, 0x0D, 0xCE, 0x06, 0x00, 0x08, 0x08, 0x24, 0x05, 0xD0, 0x0F, 0x6F, 0x36, 0x1F), + (0xB1, 0x00, 0x10, 0x4F, 0x0C, 0x11, 0x05, 0x00, 0x07, 0x07, 0x18, 0x02, 0xD3, 0x11, 0x6E, 0x34, 0x1F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x4D), + (0xB1, 0x37), + (0xB2, 0x87), + (0xB3, 0x80), + (0xB5, 0x4A), + (0xB7, 0x85), + (0xB8, 0x21), + (0xB9, 0x00, 0x13), + (0xC0, 0x09), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x80, 0x00, 0x02), + (0xE1, 0x0F, 0xA0, 0x00, 0x00, 0x10, 0xA0, 0x00, 0x00, 0x00, 0x60, 0x60), + (0xE2, 0x30, 0x30, 0x60, 0x60, 0x45, 0xA0, 0x00, 0x00, 0x46, 0xA0, 0x00, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x33, 0x33), + (0xE4, 0x44, 0x44), + (0xE5, 0x0F, 0x4A, 0xA0, 0xA0, 0x11, 0x4A, 0xA0, 0xA0, 0x13, 0x4A, 0xA0, 0xA0, 0x15, 0x4A, 0xA0, 0xA0), + (0xE6, 0x00, 0x00, 0x33, 0x33), + (0xE7, 0x44, 0x44), + (0xE8, 0x10, 0x4A, 0xA0, 0xA0, 0x12, 0x4A, 0xA0, 0xA0, 0x14, 0x4A, 0xA0, 0xA0, 0x16, 0x4A, 0xA0, 0xA0), + (0xEB, 0x02, 0x00, 0x4E, 0x4E, 0xEE, 0x44, 0x00), + (0xED, 0xFF, 0xFF, 0x04, 0x56, 0x72, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x65, 0x40, 0xFF, 0xFF), + (0xEF, 0x08, 0x08, 0x08, 0x40, 0x3F, 0x64), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xE8, 0x00, 0x0E), + (0xE8, 0x00, 0x0C), + delay(10), + (0xE8, 0x00, 0x00), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + ) +) From 1e23b10eedc852e6380c1ae9a8736a3d990e6961 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:02:42 +0000 Subject: [PATCH 40/40] Bump aioesphomeapi from 43.1.0 to 43.2.1 (#12385) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5596f050af..71aaf47ddb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ 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.1.0 +aioesphomeapi==43.2.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import