From 5c23a827b65351be1a1692bc9d5c478a245793af Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 00:35:55 +0200 Subject: [PATCH 01/63] [tmc2209] Add current and diagnostic sensors (#20638) --- esphome/components/tmc2209/sensor/__init__.py | 87 +++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.cpp | 95 +++++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.h | 35 +++++++ esphome/components/tmc2209/tmc2209.h | 3 + esphome/components/tmc22xx/tmc22xx.h | 9 ++ tests/components/tmc2209/common.yaml | 23 +++++ 6 files changed, 252 insertions(+) create mode 100644 esphome/components/tmc2209/sensor/__init__.py create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.cpp create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.h diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_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_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..19e4813b70d 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -59,7 +61,12 @@ static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +136,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..c39d3fd8ba2 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -84,3 +84,26 @@ esphome: target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result From 7d735f06774c2bd8a7cca2f5cd3a89e2ab64fe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:09 +0200 Subject: [PATCH 02/63] [emontx] Lock the emonTx on reboot when its firmware supports it (#20621) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 18 +- esphome/components/emontx/emontx.cpp | 156 ++++++++++++--- esphome/components/emontx/emontx.h | 29 ++- .../components/emontx/text_sensor/__init__.py | 1 + esphome/core/defines.h | 1 + .../emontx/test_lock_on_reboot.py | 38 ++++ tests/components/emontx/common-version.yaml | 9 + tests/components/emontx/common.h | 21 ++ tests/components/emontx/common.yaml | 7 - tests/components/emontx/emontx_test.cpp | 185 +++++++++++++++++- .../emontx/test-actions.esp8266-ard.yaml | 1 + .../components/emontx/text_sensor/__init__.py | 1 + .../text_sensor/emontx_version_test.cpp | 31 +-- 13 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 tests/component_tests/emontx/test_lock_on_reboot.py create mode 100644 tests/components/emontx/common-version.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), - cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - if config[CONF_LOCK_ON_REBOOT]: - cg.add(var.set_lock_on_reboot(True)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken - /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. - void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; - bool lock_on_reboot_{false}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..8d243043bde 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -69,6 +69,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.set_paused(true); this->sut_.on_shutdown(); EXPECT_TRUE(this->uart_.tx.empty()); } -TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { - this->sut_.set_lock_on_reboot(true); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true); From f265c46c2152e640b33eb899838ed98154e1ff12 Mon Sep 17 00:00:00 2001 From: Andrej Friesen Date: Wed, 14 Oct 2026 00:50:43 +0200 Subject: [PATCH 03/63] [PCA9505] Add PCA9505/PCA9506 40-channel GPIO expander (#20667) --- CODEOWNERS | 1 + esphome/components/pca9505/__init__.py | 79 +++++ esphome/components/pca9505/pca9505.cpp | 165 +++++++++ esphome/components/pca9505/pca9505.h | 72 ++++ tests/components/pca9505/common.yaml | 46 +++ tests/components/pca9505/test.esp32-idf.yaml | 6 + .../components/pca9505/test.esp8266-ard.yaml | 6 + tests/components/pca9505/test.rp2040-ard.yaml | 6 + tests/components/pca9505/test_pca9505.cpp | 312 ++++++++++++++++++ 9 files changed, 693 insertions(+) create mode 100644 esphome/components/pca9505/__init__.py create mode 100644 esphome/components/pca9505/pca9505.cpp create mode 100644 esphome/components/pca9505/pca9505.h create mode 100644 tests/components/pca9505/common.yaml create mode 100644 tests/components/pca9505/test.esp32-idf.yaml create mode 100644 tests/components/pca9505/test.esp8266-ard.yaml create mode 100644 tests/components/pca9505/test.rp2040-ard.yaml create mode 100644 tests/components/pca9505/test_pca9505.cpp diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..95d8c49c23e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -439,6 +439,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..7b0d5ac1732 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::pca9505 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing From d275926f6513edb2dfa1347f1583dbd053746584 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 17:55:22 -0500 Subject: [PATCH 04/63] [mdns] Advertise the connected interface in the network TXT record (#20673) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/mdns/mdns_component.cpp | 53 ++++++++++++++++++- esphome/components/mdns/mdns_component.h | 22 ++++++++ esphome/components/mdns/mdns_esp32.cpp | 19 ++++++- esphome/components/mdns/mdns_rp2.cpp | 19 +++++++ .../mdns/common-dual-interface.yaml | 9 ++++ .../mdns/test-dual-interface.esp32-idf.yaml | 17 ++++++ .../mdns/test-dual-interface.rp2040-ard.yaml | 17 ++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/components/mdns/common-dual-interface.yaml create mode 100644 tests/components/mdns/test-dual-interface.esp32-idf.yaml create mode 100644 tests/components/mdns/test-dual-interface.rp2040-ard.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4397c101fc5..0e363852688 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_() { + if (this->network_txt_ == nullptr) + return; + // With no interface connected, keep the last value instead of flapping to wifi + const char *network = this->connected_network_(); + if (network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..f2ea7c19149 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,12 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. +// OpenThread hands its services to the SRP client once, so it keeps the fixed value. +#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -150,6 +156,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Check the connected interface and publish a changed "network" TXT value. + void update_network_txt_(); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #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. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 1df5b9217e5..afa30eb5f30 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..603f3f0417c 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet From 63616bf642e3505c655076f6d8847d0e403cd31e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:06:27 -1000 Subject: [PATCH 05/63] [rtttl] Play a configured song from static storage without copying it (#19569) --- esphome/components/rtttl/__init__.py | 13 ++- esphome/components/rtttl/rtttl.cpp | 106 ++++++++++++++---- esphome/components/rtttl/rtttl.h | 69 +++++++++--- tests/components/rtttl/test_rtttl_parser.cpp | 109 +++++++++++++++++++ 4 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 tests/components/rtttl/test_rtttl_parser.cpp diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 47b8f26e3ff..8bff8bb58dc 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] DOMAIN = "rtttl" CONF_RTTTL = "rtttl" +# The player keeps the song length in 16 bits +MAX_SONG_LENGTH = 65535 CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") @@ -97,16 +99,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +def _static_song(config: ConfigType, value: str) -> str: + # A constant song plays straight from static storage, flash on ESP8266, with no copy per play + return f"esphome::rtttl::StaticSong{{{cg.FlashStringLiteral(value)}}}" + + automation.register_apply_action( "rtttl.play", cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), - cv.Required(CONF_RTTTL): cv.templatable(cv.string), + cv.Required(CONF_RTTTL): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_SONG_LENGTH)) + ), }, key=CONF_RTTTL, ), - automation.ApplyField(CONF_RTTTL, "play", cg.std_string), + automation.ApplyField(CONF_RTTTL, "play", cg.std_string, const_fn=_static_song), ) automation.register_apply_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index fb2bf63a646..a1d49c65c72 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,5 +1,8 @@ #include "rtttl.h" +#include #include +#include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -147,14 +150,14 @@ void Rtttl::loop() { #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->position_ < this->rtttl_.length()) { - char c = this->rtttl_[this->position_]; + while (this->position_ < this->song_len_) { + char c = this->song_at_(this->position_); if (c != ',' && c != ' ') break; this->position_++; } - if (this->position_ >= this->rtttl_.length()) { + if (this->position_ >= this->song_len_) { this->finish_(); return; } @@ -169,12 +172,12 @@ void Rtttl::loop() { this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } - uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); + uint8_t note_index_in_octave = note_index_from_char(this->song_at_(this->position_)); this->position_++; // Now, get optional '#' sharp - if (this->rtttl_[this->position_] == '#') { + if (this->song_at_(this->position_) == '#') { note_index_in_octave++; this->position_++; } @@ -192,7 +195,7 @@ void Rtttl::loop() { } // Now, get optional '.' dotted note - if (this->rtttl_[this->position_] == '.') { + if (this->song_at_(this->position_) == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } @@ -264,15 +267,68 @@ void Rtttl::loop() { } void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + if (this->is_busy_()) { return; } + this->owned_song_ = std::move(rtttl); + this->set_song_(this->owned_song_.data(), this->owned_song_.size()); + this->start_(); +} - this->rtttl_ = std::move(rtttl); +void Rtttl::play(StaticSong song) { + if (this->is_busy_()) { + return; + } + // Release a previously owned song; the static one needs no storage. + std::string().swap(this->owned_song_); + const char *data = reinterpret_cast(song.rtttl); + this->set_song_(data, ESPHOME_strlen_P(data)); + this->start_(); +} +void Rtttl::set_song_(const char *song, size_t len) { + this->song_ = song; + // Anything past what the 16 bit length holds is dropped; the validator keeps configured songs shorter. + this->song_len_ = static_cast(std::min(len, std::numeric_limits::max())); +} + +bool Rtttl::is_busy_() const { + if (this->state_ == State::STOPPED || this->state_ == State::STOPPING) { + return false; + } + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->song_find_(':', 0, this->song_len_)); + ESP_LOGW(TAG, "Already playing: %s", name); + return true; +} + +size_t Rtttl::song_find_(char c, size_t from, size_t end) const { + for (size_t pos = from; pos < end; pos++) { + if (this->song_at_(pos) == c) + return pos; + } + return end; +} + +size_t Rtttl::song_find_control_(char key, size_t from, size_t end) const { + size_t pos = from; + while ((pos = this->song_find_(key, pos, end)) < end) { + if (this->song_at_(pos + 1) == '=') + return pos; + pos++; + } + return end; +} + +void Rtttl::song_name_(char *buf, size_t size, size_t name_len) const { + size_t len = std::min(name_len, size - 1); + for (size_t pos = 0; pos < len; pos++) { + buf[pos] = this->song_at_(pos); + } + buf[len] = '\0'; +} + +void Rtttl::start_() { this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; @@ -281,9 +337,9 @@ void Rtttl::play(std::string rtttl) { uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name - this->position_ = this->rtttl_.find(':'); + this->position_ = this->song_find_(':', 0, this->song_len_); - if (this->position_ == std::string::npos) { + if (this->position_ == this->song_len_) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } @@ -292,18 +348,20 @@ void Rtttl::play(std::string rtttl) { static_cast(SONG_NAME_LENGTH_LIMIT)); return; } - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->position_); + ESP_LOGD(TAG, "Playing song %s", name); size_t name_end_position = this->position_; - size_t control_end = this->rtttl_.find(':', name_end_position + 1); - if (control_end == std::string::npos) { + size_t control_end = this->song_find_(':', name_end_position + 1, this->song_len_); + if (control_end == this->song_len_) { ESP_LOGE(TAG, "Missing second ':'"); return; } // Get default duration - size_t pos = this->rtttl_.find("d=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + size_t pos = this->song_find_control_('d', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); } else { this->position_ = pos + 2; @@ -317,8 +375,8 @@ void Rtttl::play(std::string rtttl) { } // Get default octave - pos = this->rtttl_.find("o=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('o', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); } else { this->position_ = pos + 2; @@ -332,8 +390,8 @@ void Rtttl::play(std::string rtttl) { } // Get BPM - pos = this->rtttl_.find("b=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('b', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); } else { this->position_ = pos + 2; @@ -387,7 +445,7 @@ void Rtttl::stop() { } #endif // USE_SPEAKER - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } @@ -411,7 +469,7 @@ void Rtttl::finish_() { #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 055204571e1..ade2dcd3205 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -14,6 +15,10 @@ namespace esphome::rtttl { +namespace testing { +class RtttlParserAccess; +} // namespace testing + inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note) inline constexpr uint8_t DEFAULT_OCTAVE = 6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`) @@ -26,6 +31,11 @@ enum class State : uint8_t { STOPPING, }; +/// A song in storage that outlives playback, such as a literal from generated code; PROGMEM on ESP8266. +struct StaticSong { + ProgmemStr rtttl; +}; + class Rtttl final : public Component { public: #ifdef USE_OUTPUT @@ -38,7 +48,10 @@ class Rtttl final : public Component { void dump_config() override; void loop() override; + /// Play a song; the string is copied so it may come from anywhere. void play(std::string rtttl); + /// Play a song from static storage without copying it. + void play(StaticSong song); void stop(); float get_gain() { return this->gain_; } @@ -55,11 +68,29 @@ class Rtttl final : public Component { protected: inline uint16_t get_integer_() { uint16_t ret = 0; - while (isdigit(this->rtttl_[this->position_])) { - ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); + while (isdigit(this->song_at_(this->position_))) { + ret = (ret * 10) + (this->song_at_(this->position_++) - '0'); } return ret; } + /// The byte of the song at pos, or NUL past its end; the read is flash safe for a PROGMEM song on ESP8266. + char song_at_(size_t pos) const { + if (pos >= this->song_len_) + return '\0'; + return static_cast(progmem_read_byte(reinterpret_cast(this->song_) + pos)); + } + /// Index of the first `c` in [from, end), or `end` when absent. + size_t song_find_(char c, size_t from, size_t end) const; + /// Index of `key` followed by '=' in [from, end), or `end` when absent. + size_t song_find_control_(char key, size_t from, size_t end) const; + /// Copies the first name_len bytes of the song, its name, into buf for logging. + void song_name_(char *buf, size_t size, size_t name_len) const; + /// Points the view at a song, clamped to what song_len_ can hold. + void set_song_(const char *song, size_t len); + /// Parses the header and starts playback of the song the view points at. + void start_(); + /// Logs and returns true when a song is still playing, so a new one must wait. + bool is_busy_() const; /** * @brief Finalizes the playback of the RTTTL string. * @@ -70,26 +101,30 @@ class Rtttl final : public Component { void finish_(); void set_state_(State state); - /// The RTTTL string to play. - std::string rtttl_; - /// The current position in the RTTTL string. - size_t position_{0}; - /// The default duration of a note (e.g. 4 for a quarter note). - uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; - /// The default octave for a note. - uint8_t default_octave_{DEFAULT_OCTAVE}; - /// The duration of the current note in milliseconds. - uint16_t note_duration_{0}; - /// The duration of a whole note in milliseconds. - uint16_t wholenote_duration_; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; + /// The song being played; points at owned_song_ or at static storage. + const char *song_{nullptr}; + /// Backing store for play(std::string). + std::string owned_song_; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; + /// Length of the song in bytes; songs are a few hundred bytes, so 16 bits is ample. + uint16_t song_len_{0}; + /// The current position in the song. + uint16_t position_{0}; + /// The duration of the current note in milliseconds. + uint16_t note_duration_{0}; + /// The duration of a whole note in milliseconds. + uint16_t wholenote_duration_; + /// The default duration of a note (e.g. 4 for a quarter note). + uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; + /// The default octave for a note. + uint8_t default_octave_{DEFAULT_OCTAVE}; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -113,6 +148,8 @@ class Rtttl final : public Component { /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; #endif + + friend class testing::RtttlParserAccess; }; } // namespace esphome::rtttl diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp new file mode 100644 index 00000000000..3c706f29877 --- /dev/null +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include "esphome/components/rtttl/rtttl.h" + +namespace esphome::rtttl::testing { + +// Reads the parser state the tests check; Rtttl is final, so this is a friend rather than a subclass. +class RtttlParserAccess { + public: + static uint8_t denominator(const Rtttl &p) { return p.default_note_denominator_; } + static uint8_t octave(const Rtttl &p) { return p.default_octave_; } + static uint16_t wholenote(const Rtttl &p) { return p.wholenote_duration_; } + static uint16_t note_duration(const Rtttl &p) { return p.note_duration_; } + static uint32_t freq(const Rtttl &p) { return p.output_freq_; } + static size_t position(const Rtttl &p) { return p.position_; } + static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } + static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } +}; + +using Access = RtttlParserAccess; + +static const char SONG[] = "test:d=8,o=5,b=100:c"; + +TEST(RtttlParser, StaticSongParsesTheHeader) { + Rtttl player; + player.play(StaticSong{SONG}); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 100); + // Positioned on the first note after the second ':' + EXPECT_EQ(Access::position(player), 19u); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); +} + +TEST(RtttlParser, OwnedSongParsesTheSameAndOutlivesTheCaller) { + Rtttl player; + { + std::string song(SONG); + player.play(song); + } + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::at(player, 0), 't'); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); + EXPECT_EQ(Access::at(player, 100), '\0'); +} + +TEST(RtttlParser, ControlOrderDoesNotMatter) { + for (const char *song : {"a:b=100,o=5,d=8:c", "a:o=5,b=100,d=8:c", "a:d=8,b=100,o=5:c"}) { + Rtttl player; + player.play(StaticSong{song}); + EXPECT_EQ(Access::denominator(player), 8) << song; + EXPECT_EQ(Access::octave(player), 5) << song; + EXPECT_EQ(Access::wholenote(player), 2400) << song; + } +} + +TEST(RtttlParser, MissingControlsUseDefaults) { + Rtttl player; + player.play(StaticSong{"a::c"}); + EXPECT_EQ(Access::denominator(player), DEFAULT_NOTE_DENOMINATOR); + EXPECT_EQ(Access::octave(player), DEFAULT_OCTAVE); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 63); +} + +TEST(RtttlParser, NotesReadFromStaticStorage) { + Rtttl player; + player.play(StaticSong{"t:d=4,o=5,b=120:8c,p"}); + Access::set_running(player); + player.loop(); + // C5 at 523 Hz for an eighth of a 2000 ms whole note + EXPECT_EQ(Access::freq(player), 523u); + EXPECT_EQ(Access::note_duration(player), 250); + player.loop(); + // A pause for a default quarter note + EXPECT_EQ(Access::freq(player), 0u); + EXPECT_EQ(Access::note_duration(player), 500); + player.loop(); + // End of the song + EXPECT_EQ(Access::at(player, Access::position(player)), '\0'); +} + +TEST(RtttlParser, RefusesANewSongWhilePlaying) { + Rtttl player; + player.play(StaticSong{"first:d=8:c"}); + Access::set_running(player); + player.play(std::string("second:d=16:c")); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::at(player, 0), 'f'); +} + +TEST(RtttlParser, ClampsASongLongerThanTheLengthField) { + Rtttl player; + player.play(std::string(70000, 'a')); + EXPECT_EQ(Access::at(player, 65534), 'a'); + EXPECT_EQ(Access::at(player, 65535), '\0'); + EXPECT_EQ(Access::note_duration(player), 0); +} + +TEST(RtttlParser, RejectsASongWithoutAName) { + Rtttl player; + player.play(StaticSong{"no colon here"}); + EXPECT_EQ(Access::note_duration(player), 0); + EXPECT_EQ(Access::position(player), 13u); +} + +} // namespace esphome::rtttl::testing From 36e1d831d3b73b83bc2dec4bece3d6f7f9c339a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:18:35 -1000 Subject: [PATCH 06/63] [core] Add a decimal fast path to `parse_number` (#20639) --- esphome/core/helpers.cpp | 52 +++++++++++++++ esphome/core/helpers.h | 8 +-- tests/components/core/test_parse_float.cpp | 77 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/components/core/test_parse_float.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 906f9fb12ab..872bb199084 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef USE_ESP32 @@ -315,6 +316,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size // Parsing & formatting +// A plain decimal converts exactly with one division: the digits accumulate +// into an integer that stays below 2^24 and the fraction's scale is a power of +// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division +// rounds the quotient correctly, the same result strtof produces. +static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24 +static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9; + +optional parse_float(const char *str) { + const char *p = str; + const bool negative = *p == '-'; + if (negative || *p == '+') + p++; + const char *digits = p; + const char *dot = nullptr; + uint32_t mantissa = 0; + for (;; p++) { + const uint32_t d = static_cast(*p) - '0'; + if (d > 9) { + if (*p == '.' && dot == nullptr) { + dot = p; + continue; + } + break; + } + if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT) + break; + mantissa = mantissa * 10 + d; + } + const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0; + const bool any_digit = p - digits > (dot != nullptr ? 1 : 0); + if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) { + // The mantissa is exact in a float, so the sign goes on before the one rounding step + float value = static_cast(mantissa); + if (negative) + value = -value; + if (fraction_digits != 0) { + uint32_t scale = 10; + for (uint32_t i = 1; i < fraction_digits; i++) + scale *= 10; + value /= static_cast(scale); + } + return value; + } + // Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path. + char *end = nullptr; + const float value = ::strtof(str, &end); + if (end == str || *end != '\0' || value == HUGE_VALF) + return {}; + return value; +} + size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bcb03b010f6..15f247cadbc 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1299,13 +1299,11 @@ template::value && std::is_signed parse_number(const std::string &str) { return parse_number(str.c_str()); } +/// Parse a decimal floating-point number; plain decimals skip the libc parser. +optional parse_float(const char *str); /// Parse a decimal floating-point number from a null-terminated string. template::value), int> = 0> optional parse_number(const char *str) { - char *end = nullptr; - float value = ::strtof(str, &end); - if (end == str || *end != '\0' || value == HUGE_VALF) - return {}; - return value; + return parse_float(str); } /// Parse a decimal floating-point number. template::value), int> = 0> diff --git a/tests/components/core/test_parse_float.cpp b/tests/components/core/test_parse_float.cpp new file mode 100644 index 00000000000..1d52f8afc8f --- /dev/null +++ b/tests/components/core/test_parse_float.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static uint32_t bits(float value) { + uint32_t out; + memcpy(&out, &value, sizeof(out)); + return out; +} + +// strtof is the reference: every accepted input must give the same bits. +static void expect_matches_strtof(const char *str) { + char *end = nullptr; + const float expected = ::strtof(str, &end); + const auto parsed = parse_number(str); + if (!parsed.has_value()) { + ADD_FAILURE() << str << " was rejected"; + return; + } + const float actual = parsed.value(); + EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected; +} + +TEST(ParseFloat, PlainDecimalsMatchStrtof) { + for (const char *str : + {"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3", + "2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5", + "-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) { + expect_matches_strtof(str); + } +} + +TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) { + char buf[16]; + for (int i = -20000; i <= 20000; i++) { + snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100); + expect_matches_strtof(buf); + } +} + +// A fraction longer than the fast path accepts must fall back, however long it is. +TEST(ParseFloat, LongFractionsMatchStrtof) { + std::string str = "0."; + str.append(300, '0'); + str += "25"; + expect_matches_strtof(str.c_str()); + expect_matches_strtof(("-" + str).c_str()); + str = "1."; + str.append(300, '0'); + expect_matches_strtof(str.c_str()); +} + +TEST(ParseFloat, LibcPathInputsMatchStrtof) { + for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001", + "0.00000000001", "1.23456789", "3.4028235e38"}) { + expect_matches_strtof(str); + } +} + +// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser +// rejects both; the negative forms pass through, as they did before the fast path. +TEST(ParseFloat, RejectsInvalidInput) { + for (const char *str : + {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) { + EXPECT_FALSE(parse_number(str).has_value()) << str; + } +} + +} // namespace esphome::core::testing From f61955baa2d7f1b44ce5e8fa58f027f837546c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:23 -1000 Subject: [PATCH 07/63] [core] Declare the remaining log tags with ESPHOME_LOG_TAG (#20666) --- esphome/components/bedjet/bedjet_const.h | 3 ++- esphome/components/bme280_i2c/bme280_i2c.h | 3 ++- esphome/components/bmp280_i2c/bmp280_i2c.h | 3 ++- esphome/components/bmp581_i2c/bmp581_i2c.h | 3 ++- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/cst9220/touchscreen/cst9220_touchscreen.h | 2 +- esphome/components/ezo/ezo.h | 3 ++- esphome/components/lvgl/lvgl_esphome.h | 2 +- esphome/components/mapping/mapping.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 3 ++- esphome/components/mipi_spi/mipi_spi.h | 3 ++- esphome/components/runtime_stats/runtime_stats.h | 2 +- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/spi_led_strip/spi_led_strip.h | 2 +- esphome/components/st7701s/st7701s.h | 3 ++- esphome/components/usb_host/usb_host.h | 3 ++- esphome/components/usb_uart/usb_uart.h | 3 ++- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 3 ++- esphome/core/log.h | 5 +++-- esphome/core/wake/wake_host.cpp | 2 +- 22 files changed, 35 insertions(+), 23 deletions(-) diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index eb777a6a834..aa84cdddde9 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -1,10 +1,11 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::bedjet { -static const char *const TAG = "bedjet"; +ESPHOME_LOG_TAG(TAG, "bedjet"); /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 501556d3c40..45c3e5b260f 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bme280_i2c { -static const char *const TAG = "bme280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor"); class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index a19203ff0a0..f2ec93176ef 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp280_i2c { -static const char *const TAG = "bmp280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor"); /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index 126ffd6a606..24929c7d8b0 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp581_base/bmp581_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp581_i2c { -static const char *const TAG = "bmp581_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor"); /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 84e539e5f25..9278546ee94 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::chsc6x { -static const char *const TAG = "chsc6x.touchscreen"; +ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen"); static const uint8_t CHSC6X_REG_STATUS = 0x00; static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 84b561c7345..72dda4e9854 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst816 { -static const char *const TAG = "cst816.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst816.touchscreen"); static const uint8_t REG_STATUS = 0x00; static const uint8_t REG_TOUCH_NUM = 0x02; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h index 17050e2429a..5a8e043d654 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst9220 { -static const char *const TAG = "cst9220.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen"); // The CST92xx family uses 16-bit (big-endian) register addresses. static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index a20419eceb1..7a3e84f56ca 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,11 +3,12 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" #include namespace esphome::ezo { -static const char *const TAG = "ezo.sensor"; +ESPHOME_LOG_TAG(TAG, "ezo.sensor"); enum EzoCommandType : uint8_t { EZO_READ = 0, diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4da8a6a1a30..75f926d865e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,7 +214,7 @@ enum class Orientation : uint8_t { }; class LvglComponent final : public PollingComponent { - constexpr static const char *const TAG = "lvgl"; + constexpr static const char *const TAG = "lvgl"; // NOLINT: lvgl does not build for ESP8266 public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index d6790caa35d..e4985c91fa1 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -19,7 +19,7 @@ using alloc_string_t = std::basic_string, RAMAlloca * @tparam V The type of the value in the mapping. Should be a basic type or pointer. */ -static const char *const TAG = "mapping"; +ESPHOME_LOG_TAG(TAG, "mapping"); template class Mapping { public: diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4243d3116cf..4df22354681 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -18,7 +18,7 @@ namespace esphome::mipi_dsi { -constexpr static const char *const TAG = "display.mipi_dsi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 8b29e273abd..01b1ebee91f 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" +#include "esphome/core/log.h" #include "esphome/components/display/display.h" #include #ifdef USE_SPI @@ -10,7 +11,7 @@ namespace esphome::mipi_rgb { -constexpr static const char *const TAG = "display.mipi_rgb"; +ESPHOME_LOG_TAG(TAG, "display.mipi_rgb"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48a2cafbdd3..96f26ef19b7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,10 +7,11 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome::mipi_spi { -constexpr static const char *const TAG = "display.mipi_spi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_spi"); // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 2c783a0df30..61b152d7d36 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -15,7 +15,7 @@ class Component; // Forward declaration namespace runtime_stats { -static const char *const TAG = "runtime_stats"; +ESPHOME_LOG_TAG(TAG, "runtime_stats"); class RuntimeStatsCollector final { public: diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 54f0d2573fd..c58de643cc8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { -constexpr static const char *const TAG = "sdl"; +ESPHOME_LOG_TAG(TAG, "sdl"); class Sdl final : public display::Display, public snapshot::Snapshot { public: diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 20b9c25c2e9..a5b10a0e9a6 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -7,7 +7,7 @@ namespace esphome::spi_led_strip { -static const char *const TAG = "spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); class SpiLedStrip final : public light::AddressableLight, public spi::SPIDevice { diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index d44f8c68590..fe29d794141 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -8,13 +8,14 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_rgb.h" namespace esphome::st7701s { -constexpr static const char *const TAG = "display.st7701s"; +ESPHOME_LOG_TAG(TAG, "display.st7701s"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index aaab8e22a18..bbb6f15f292 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,6 +12,7 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -39,7 +40,7 @@ namespace esphome::usb_host { // - Main loop controls backpressure by deciding when to restart after consuming data // The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking. -static const char *const TAG = "usb_host"; +ESPHOME_LOG_TAG(TAG, "usb_host"); // Forward declarations struct TransferRequest; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..b54641884e6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -9,6 +9,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -19,7 +20,7 @@ class USBUartComponent; class USBUartChannelBase; class USBUartTypePL2303; -static const char *const TAG = "usb_uart"; +ESPHOME_LOG_TAG(TAG, "usb_uart"); static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 1dbce873073..12ea8e8ade0 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" -static const char *const TAG = "Zio Ultrasonic"; +ESPHOME_LOG_TAG(TAG, "Zio Ultrasonic"); namespace esphome::zio_ultrasonic { diff --git a/esphome/core/log.h b/esphome/core/log.h index 145ba3a6f8a..840da292aee 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,8 +190,9 @@ struct LogString; #endif -// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with -// the _P functions, never strcmp/strlen. +// Declares a static log tag at namespace scope, in a source file or a header; on ESP8266 it lives in flash, so +// read it with the _P functions, never strcmp/strlen. Not usable for a class member: the ESP8266 form is a +// PROGMEM array, which cannot be initialized in a class. #ifdef USE_ESP8266 #define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag #else diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9fc1c717032..e2353db28f4 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -22,7 +22,7 @@ namespace esphome { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -static const char *const TAG = "wake"; +ESPHOME_LOG_TAG(TAG, "wake"); namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and From 92f6ab836744c0ed25ddb7e37c9245be37eb4724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 08/63] [ci] Lint log tags in headers and core (#20668) --- esphome/components/ili9xxx/ili9xxx_display.h | 3 ++- esphome/components/qspi_dbi/qspi_dbi.h | 3 ++- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- script/ci-custom.py | 10 ++++--- tests/script/test_ci_custom.py | 28 +++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 2529e54021b..77cee44ae68 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -2,12 +2,13 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "ili9xxx_defines.h" #include "ili9xxx_init.h" namespace esphome::ili9xxx { -static const char *const TAG = "ili9xxx"; +ESPHOME_LOG_TAG(TAG, "ili9xxx"); const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 enum ILI9XXXColorMode { diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 8a1bf0d4c24..eca062f4711 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -8,12 +8,13 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_rgb.h" namespace esphome::qspi_dbi { -constexpr static const char *const TAG = "display.qspi_dbi"; +ESPHOME_LOG_TAG(TAG, "display.qspi_dbi"); static const uint8_t SW_RESET_CMD = 0x01; static const uint8_t SLEEP_OUT = 0x11; static const uint8_t NORON = 0x13; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index df0e2b0b167..ad2f6e80c26 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -16,7 +16,7 @@ namespace esphome::rpi_dpi_rgb { -constexpr static const char *const TAG = "rpi_dpi_rgb"; +ESPHOME_LOG_TAG(TAG, "rpi_dpi_rgb"); class RpiDpiRgb final : public display::Display { public: diff --git a/script/ci-custom.py b/script/ci-custom.py index 8361387af01..4431f62cedd 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1269,11 +1269,15 @@ def lint_no_std_nothrow(fname, match): ) +# Every C++ source and header; plain C files cannot use the macro +LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] + + @lint_re_check( r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" - r"\s*(?:=\s*\{?|[{(])\s*\"", + r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_macro(fname, match): return ( @@ -1292,7 +1296,7 @@ def lint_log_tag_macro(fname, match): r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", mask=True, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_as_string(fname, match): return ( diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index f303d19bb0d..621ffd93a42 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -400,6 +400,7 @@ def test_ternary_error_message_names_the_literal() -> None: 'static constexpr char TAG[] = {"x"};', 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', + 'constexpr static const char *const TAG = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -408,12 +409,37 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: @pytest.mark.parametrize( "line", - ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], + [ + 'ESPHOME_LOG_TAG(TAG, "x");', + 'static const char *const NAME = "x";', + 'static const char *const TAG = "x"; // NOLINT', + ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("fname", "covered"), + [ + ("esphome/components/x/x.cpp", True), + ("esphome/components/x/x.h", True), + ("esphome/components/x/sub/x.h", True), + ("esphome/core/wake/wake_host.cpp", True), + ("esphome/components/x/x.c", False), + ], +) +def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None: + for name, line in ( + ("lint_log_tag_macro", 'static const char *const TAG = "x";\n'), + ("lint_log_tag_as_string", "strcmp(TAG, name);\n"), + ): + lint = next( + c for c in ci_custom.LINT_CONTENT_CHECKS if c["func"].__name__ == name + ) + assert bool(ci_custom.run_check(lint, fname, fname, line)) is covered + + @pytest.mark.parametrize( "line", [ From c6e90de262e87ba0d05db0a7d705fd3f7a0f3a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 09/63] [ci] Catch out-of-class member log tags and keep midea's in flash (#20674) --- esphome/components/ble_client/automation.cpp | 2 +- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- script/ci-custom.py | 8 +++++--- tests/script/test_ci_custom.py | 5 +++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f6173..b0fac5b1218 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -4,7 +4,7 @@ namespace esphome::ble_client { -const char *const Automation::TAG = "ble_client.automation"; +const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266 } // namespace esphome::ble_client diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..e362c241e8c 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,7 +5,7 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; +const char Constants::TAG[] PROGMEM = "midea"; const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; const char *const Constants::SILENT = "Silent"; const char *const Constants::TURBO = "Turbo"; diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..62e93d2465a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,7 +17,7 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; + static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG static const char *const FREEZE_PROTECTION; static const char *const SILENT; static const char *const TURBO; diff --git a/script/ci-custom.py b/script/ci-custom.py index 4431f62cedd..5b268d62969 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1274,7 +1274,7 @@ LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] @lint_re_check( - r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?(?:\w+::)*TAG|\s(?:const\s+)?(?:\w+::)*TAG\s*\[\w*\])" r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", include=LOG_TAG_INCLUDE, @@ -1285,6 +1285,8 @@ def lint_log_tag_macro(fname, match): f"ESP8266.\n" f" Before: {highlight('static const char *const TAG = "name";')}\n" f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"A class member tag cannot use the macro; declare {highlight('static const char TAG[];')} in the class " + f"and define {highlight('const char Foo::TAG[] PROGMEM = "name";')} in the .cpp.\n" f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @@ -1292,8 +1294,8 @@ def lint_log_tag_macro(fname, match): @lint_re_check( r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" - r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" - r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*(?:\w+::)*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*(?:\w+::)*TAG\b", mask=True, prefilter="TAG", include=LOG_TAG_INCLUDE, diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 621ffd93a42..e65bf9755e3 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -401,6 +401,8 @@ def test_ternary_error_message_names_the_literal() -> None: 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', 'constexpr static const char *const TAG = "x";', + 'const char *const Constants::TAG = "x";', + 'const char Constants::TAG[] = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -413,6 +415,7 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: 'ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";', 'static const char *const TAG = "x"; // NOLINT', + 'const char Constants::TAG[] PROGMEM = "x";', ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: @@ -452,6 +455,8 @@ def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None "std::string(TAG);", "std::string name = TAG;", "std::string name{TAG};", + "strlen(Constants::TAG);", + "this->set_timeout(Constants::TAG, 100, cb);", ], ) def test_log_tag_as_string_flags_string_uses(line: str) -> None: From 3cdcf0417a62809452aba6abb054654f14ec050d Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:31:08 +0100 Subject: [PATCH 10/63] [lvgl] Make the list_outside_block tests work offline (#20676) --- tests/component_tests/lvgl/config/image.png | Bin 0 -> 78 bytes .../lvgl/config/list_outside_block_test.yaml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/image.png diff --git a/tests/component_tests/lvgl/config/image.png b/tests/component_tests/lvgl/config/image.png new file mode 100644 index 0000000000000000000000000000000000000000..dec8dd15317b06fcf0915663767b66aef8ddd225 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0y~yVBi2@Mg|53hBqb6Y77hvqMj~}Ar-fhf1JN?Vgoym ewB$eIKkN+pkJ(J(vVYG5DfD#pb6Mw<&;$Ua^c4;O literal 0 HcmV?d00001 diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml index 44407348999..58898c89b12 100644 --- a/tests/component_tests/lvgl/config/list_outside_block_test.yaml +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -30,7 +30,7 @@ display: image: - platform: file - file: mdi:battery + file: image.png id: my_image resize: 8x8 type: binary From 95711d83d1cb164f197165703bb04e994a65297b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:29 -1000 Subject: [PATCH 11/63] [ili9xxx] Set removal of the deprecated component for 2027.5.0 (#20687) --- esphome/components/ili9xxx/__init__.py | 1 + esphome/components/ili9xxx/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 2803b439156..3efebabd885 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -3,4 +3,5 @@ DOMAIN = "ili9xxx" DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 64f87c167c1..5928eb5771f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -220,8 +220,10 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'ili9xxx' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) From 96902cc1222b64cd288fcb805563fbadfcc653ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:52 -1000 Subject: [PATCH 12/63] [qspi_dbi] Set removal of the deprecated component for 2027.5.0 (#20690) --- esphome/components/qspi_dbi/__init__.py | 1 + esphome/components/qspi_dbi/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index abf64f675af..a6dee79abb0 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -6,4 +6,5 @@ CONF_DRAW_FROM_ORIGIN = "draw_from_origin" DEPRECATED_COMPONENT = """ The 'qspi_dbi' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index dce1a95687d..cf2a6441a6c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -165,8 +165,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'qspi_dbi' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 9238fcc72727f4f1ca575afc706e4a3250c1c0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:21 -1000 Subject: [PATCH 13/63] [st7735] Set removal of the deprecated component for 2027.5.0 (#20688) --- esphome/components/st7735/__init__.py | 1 + esphome/components/st7735/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 176eb233e17..d6e5035a9a4 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -7,4 +7,5 @@ st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ The 'st7735' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 5cbcee12ab3..d6774f4639e 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -92,8 +92,10 @@ async def setup_st7735(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7735' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable( config[CONF_ID], From 3b261d2cf0dca7517f0050f50f1444679288eb18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:52 -1000 Subject: [PATCH 14/63] [st7789v] Set removal of the deprecated component for 2027.5.0 (#20689) --- esphome/components/st7789v/__init__.py | 1 + esphome/components/st7789v/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index e67f4809128..dfe139d0183 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -7,4 +7,5 @@ st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ The 'st7789v' component is deprecated and no new functionality will be added to it. PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index fa72c7c328a..82d3f0b26d6 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -183,8 +183,10 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7789v' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 52a5e6a0072faeb94264e58d8c102f3e0e264ed5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:32:18 -1000 Subject: [PATCH 15/63] [rpi_dpi_rgb] Set removal of the deprecated component for 2027.5.0 (#20691) --- esphome/components/rpi_dpi_rgb/__init__.py | 1 + esphome/components/rpi_dpi_rgb/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 20629fd7305..6b274b9b734 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_rgb' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 1ca29a32597..be6ca6c2a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -132,8 +132,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + "The 'rpi_dpi_rgb' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_rgb' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From f505212e64c4a8975e5d74a5aa2ae7a018399e9e Mon Sep 17 00:00:00 2001 From: Felipe Guedes Date: Wed, 14 Oct 2026 02:03:05 +0100 Subject: [PATCH 16/63] [epaper_spi] Add Waveshare 2.66" e-Paper Module (B) support (#20546) --- .../epaper_spi/models/waveshare_b.py | 8 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 688e7164566..67a0cf5f233 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -24,3 +24,11 @@ WaveshareB( data_rate="10MHz", minimum_update_interval="1s", ) + +WaveshareB( + "waveshare-2.66in-b", + width=152, + height=296, + data_rate="10MHz", + minimum_update_interval="15s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 3839b4ff87a..a2c343be836 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,27 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.66in-b + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) - platform: epaper_spi spi_id: spi_bus From c9cf0ad92eb085ba2fccaedfda4e91abe6c029b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:37:36 -0400 Subject: [PATCH 17/63] Bump platformdirs from 4.12.3 to 4.12.4 (#20697) Signed-off-by: dependabot[bot] 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 a9dbaa3b8d7..fcbcb171246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 576d3325febf66cdcd32e2637a330ef08b2f2c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:37:47 -1000 Subject: [PATCH 18/63] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 in /.github/actions/restore-python (#20700) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From c95c17c4e7bd0a948040a18bc1b8cc69195e844c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:17 -1000 Subject: [PATCH 19/63] Bump pyupgrade from 3.21.2 to 3.22.0 (#20698) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..e06494573c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..91ddfa7102d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 426a4a2dce80c4bc823e77d298ba25d4d9ff5da2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:26 -1000 Subject: [PATCH 20/63] Bump filelock from 4.0.12 to 4.1.0 (#20696) Signed-off-by: dependabot[bot] 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 fcbcb171246..4ead538d8e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 81e5b50087d7ab0fca84824a5092e65aabcf1266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:35 -1000 Subject: [PATCH 21/63] Bump backports-zstd from 1.7.0 to 1.8.0 (#20695) Signed-off-by: dependabot[bot] 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 4ead538d8e1..ca8c489116d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 From d452dbce30fd4195c8003430b8abfdd249566f56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 22/63] Bump actions/download-artifact from 8.0.1 to 8.0.2 (#20703) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..f3457aad4ea 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..fee9fea64e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1740,13 +1740,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..add0f80b243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests From 7cb5906d2abaebf4e98bd44b0fa795623fa955ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:42 +0000 Subject: [PATCH 23/63] Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#20702) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..a516e868bef 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3457aad4ea..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee9fea64e1..741a0d7deb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,7 +477,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -1647,7 +1647,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1710,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add0f80b243..c0991a6308f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From c5522abc7137eef0742020c2678c77d4ab26a7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:39:11 -0400 Subject: [PATCH 24/63] Bump github/codeql-action/analyze from 4.38.2 to 4.38.3 (#20699) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..4d441c73c14 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" From bc39d56bb48a9eb10b37d8e86194acf5c9eb3295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:29 -1000 Subject: [PATCH 25/63] Bump CodSpeedHQ/action from 5.4.0 to 5.4.2 (#20705) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741a0d7deb7..3e27a3aeb52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate From 16134ee993ddb5f8e416f8c9fd8a63b72b5e325f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:38 -1000 Subject: [PATCH 26/63] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#20704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a516e868bef..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e27a3aeb52..d68bb93ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1448,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..ba0404011be 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 8fce95454ae731ec8fa55926c7b9e79e3f4fb25b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:49 -1000 Subject: [PATCH 27/63] Bump github/codeql-action/init from 4.38.2 to 4.38.3 (#20701) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4d441c73c14..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 59f55dbe9ee457948402e68b36c21e13e95a4b81 Mon Sep 17 00:00:00 2001 From: fredkotze <40389058+fredkotze@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:40:28 +1100 Subject: [PATCH 28/63] [epaper_spi] Add Waveshare 2.13in-G (JD79660) display models (#18141) Co-authored-by: J. Nick Koston --- .../components/epaper_spi/models/jd79660.py | 44 ++++++++++++++++++- .../epaper_spi/test.esp32-s3-idf.yaml | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index a2c343be836..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi From 9ee4314bc2642b9c4c53317a54ecf46e2facdca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:30 -1000 Subject: [PATCH 29/63] Bump esptool from 5.4.0 to 5.5.0 (#20693) Signed-off-by: dependabot[bot] 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 ca8c489116d..5397959f63f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 613973b4327fb933b7101c14740b350a1431abb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:44 -0400 Subject: [PATCH 30/63] Bump ruff from 0.16.10 to 0.17.0 (#20694) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 4 ++-- requirements_test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06494573c6..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/requirements_test.txt b/requirements_test.txt index 91ddfa7102d..65e8a63bfe9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 4b8af08155eebac3ae39da1412cb7b92f04bdf42 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 20:57:59 -0500 Subject: [PATCH 31/63] [network] Notify listeners when the active interface changes (#20684) Co-authored-by: Claude Opus 5.5 --- esphome/components/mdns/__init__.py | 19 +++++++ esphome/components/mdns/mdns_component.cpp | 22 ++++++-- esphome/components/mdns/mdns_component.h | 21 +++++--- esphome/components/mdns/mdns_esp32.cpp | 5 +- esphome/components/mdns/mdns_rp2.cpp | 3 +- esphome/components/network/__init__.py | 33 +++++++++++- .../components/network/network_component.cpp | 22 +++++--- .../components/network/network_component.h | 32 ++++++++++++ esphome/core/defines.h | 6 +++ .../config/priority_ethernet_first_api.yaml | 28 ++++++++++ .../network/config/priority_rp2040_api.yaml | 26 ++++++++++ .../network/test_active_interface.py | 52 +++++++++++++++++++ 12 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/network/config/priority_ethernet_first_api.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040_api.yaml create mode 100644 tests/component_tests/network/test_active_interface.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..bc431599248 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. OpenThread hands its services to the SRP client once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and CONF_OPENTHREAD not in CORE.config + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 0e363852688..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -62,12 +62,9 @@ const char *MDNSComponent::connected_network_() const { return nullptr; } -void MDNSComponent::update_network_txt_() { - if (this->network_txt_ == nullptr) - return; +void MDNSComponent::update_network_txt_(const char *network) { // With no interface connected, keep the last value instead of flapping to wifi - const char *network = this->connected_network_(); - if (network == nullptr || network == this->network_txt_) + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) return; if (!this->publish_network_txt_(network)) { // The _esphomelib service is missing, so a retry would fail too @@ -80,6 +77,21 @@ void MDNSComponent::update_network_txt_() { this->network_record_->value = MDNS_STR(network); #endif } + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif #endif void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f2ea7c19149..06d10d2d818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,10 +32,11 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif -// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. -// OpenThread hands its services to the SRP client once, so it keeps the fixed value. -#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) -#define USE_MDNS_DYNAMIC_NETWORK_TXT +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" #endif namespace esphome::mdns { @@ -79,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -140,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -160,8 +169,8 @@ class MDNSComponent final : public Component static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. const char *connected_network_() const; - /// Check the connected interface and publish a changed "network" TXT value. - void update_network_txt_(); + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. bool publish_network_txt_(const char *network); /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index afa30eb5f30..d84a2551202 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -103,8 +103,9 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); -#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif } diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 603f3f0417c..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -64,7 +64,8 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT // No-op until the first IP-up event registers the services - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..89835128fe2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -100,6 +100,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d243043bde..1fa417d7afd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -217,6 +217,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -567,6 +568,10 @@ #endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -647,6 +652,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt From f45e6f0bf47a818b8bcb4afff47e2a8a505306b4 Mon Sep 17 00:00:00 2001 From: Andres Signes Grimalt Date: Wed, 14 Oct 2026 04:03:18 +0200 Subject: [PATCH 32/63] [midea] Add display_control and send the display toggle over UART without a transmitter (#18671) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/midea/air_conditioner.cpp | 34 +++++++++++++++---- esphome/components/midea/air_conditioner.h | 9 +++++ esphome/components/midea/climate.py | 24 +++++++++++++ tests/components/midea/common-uart_only.yaml | 6 ++++ tests/components/midea/common.yaml | 1 + .../midea/test-uart_only.esp32-idf.yaml | 8 +++++ 6 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/components/midea/common-uart_only.yaml create mode 100644 tests/components/midea/test-uart_only.esp32-idf.yaml diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 From 7b591b26d692b5b06e0f8927123831dacd2bed2e Mon Sep 17 00:00:00 2001 From: willumpie82 Date: Wed, 14 Oct 2026 04:19:58 +0200 Subject: [PATCH 33/63] [sy6970] Service the I2C watchdog so charge/LED settings persist (#19424) Co-authored-by: Willem Oldemans <> Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sy6970/__init__.py | 14 ++++++ esphome/components/sy6970/sy6970.cpp | 70 ++++++++++++++++++++++++++- esphome/components/sy6970/sy6970.h | 20 +++++++- tests/components/sy6970/common.yaml | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: From bb1119d83f5ba0bb4b932aed88a3cf824a812d95 Mon Sep 17 00:00:00 2001 From: Christopher Reimer Date: Wed, 14 Oct 2026 04:34:03 +0200 Subject: [PATCH 34/63] [esp32_can] Support 20 kbit/s on ESP32 revision 2 or later (#20426) --- esphome/components/esp32_can/canbus.py | 22 ++++++++ esphome/components/esp32_can/esp32_can.cpp | 4 ++ .../esp32_can/test-20kbps.esp32-idf.yaml | 11 ++++ tests/unit_tests/components/test_esp32_can.py | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/components/esp32_can/test-20kbps.esp32-idf.yaml create mode 100644 tests/unit_tests/components/test_esp32_can.py diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..ae299370cd7 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -49,6 +54,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..d84b58c5b0c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) From 07029d00775a76427d0ff70011025750eef93fe6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:47:31 -0500 Subject: [PATCH 35/63] [sen6x] Add measurement control and maintenance actions (#18783) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 111 ++++++++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 13 ++++ esphome/components/sen6x/sensor.py | 20 ++++++ tests/components/sen6x/common.yaml | 14 ++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor From 40e12cd0d893f496c78ccbacdb5df529e64cd783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:01:47 -1000 Subject: [PATCH 36/63] [image] Add CodSpeed benchmarks for image, runtime image and display block draws (#20649) --- .../benchmarks/components/display/__init__.py | 7 + .../display/bench_draw_pixels_at.cpp | 82 ++++++ tests/benchmarks/components/image/__init__.py | 7 + .../components/image/bench_image_draw.cpp | 245 ++++++++++++++++++ .../bench_runtime_image_draw.cpp | 125 +++++++++ tests/testing_helpers.py | 22 ++ 6 files changed, 488 insertions(+) create mode 100644 tests/benchmarks/components/display/__init__.py create mode 100644 tests/benchmarks/components/display/bench_draw_pixels_at.cpp create mode 100644 tests/benchmarks/components/image/__init__.py create mode 100644 tests/benchmarks/components/image/bench_image_draw.cpp create mode 100644 tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code From 5718e4b779991c7200a1d18d513bbae38dbea5bc Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:06:56 -0400 Subject: [PATCH 37/63] [tmp102] Add text sensor for diagnostics (#20629) Co-authored-by: J. Nick Koston --- esphome/components/tmp102/text_sensor.py | 31 ++++++ esphome/components/tmp102/tmp102.cpp | 105 ++++++++++++++++-- esphome/components/tmp102/tmp102.h | 32 +++++- esphome/core/defines.h | 1 + .../tmp102/config/duplicate_status.yaml | 23 ++++ .../component_tests/tmp102/config/status.yaml | 20 ++++ tests/component_tests/tmp102/test_config.py | 24 ++++ tests/components/tmp102/__init__.py | 4 +- tests/components/tmp102/common.yaml | 5 + .../test-text-sensor-only.esp32-idf.yaml | 12 ++ tests/components/tmp102/test_tmp102.cpp | 94 ++++++++++++++++ 11 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 esphome/components/tmp102/text_sensor.py create mode 100644 tests/component_tests/tmp102/config/duplicate_status.yaml create mode 100644 tests/component_tests/tmp102/config/status.yaml create mode 100644 tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml diff --git a/esphome/components/tmp102/text_sensor.py b/esphome/components/tmp102/text_sensor.py new file mode 100644 index 00000000000..5d901bfab89 --- /dev/null +++ b/esphome/components/tmp102/text_sensor.py @@ -0,0 +1,31 @@ +"""TMP102 threshold status entity.""" + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC +).extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "text_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_TEXT_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(parent.set_threshold_status_text_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index bb82975dbc3..06d94f1ba86 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -1,8 +1,14 @@ #include "tmp102.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/core/progmem.h" +#endif #include +#ifdef USE_TMP102_TEXT_SENSOR +#include +#endif namespace esphome::tmp102 { @@ -12,6 +18,30 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; + +enum TMP102ThresholdStatus : uint8_t { + TMP102_THRESHOLD_STATUS_NOT_CONFIGURED, + TMP102_THRESHOLD_STATUS_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_OK, + TMP102_THRESHOLD_STATUS_RESTORE_RESET, + TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERY_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERED, + TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED, + TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED, + TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_COUNT, +}; + +#ifdef USE_TMP102_TEXT_SENSOR +PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset", + "Restore reset; save failed", "Preference save failed", "Configuration recovery failed", + "Configuration restored", "Write failed: high threshold", "Write failed: low threshold", + "Rejected: sensor setup failed"); +static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT); +#endif + #if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; @@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) { return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); } #endif +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void TMP102Component::setup() { - if (!this->configure_) +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->setup_configured_(); return; + } +#endif + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED); +} +#endif + +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::setup_configured_() { + bool corrected = false; #ifdef USE_TMP102_NUMBER float restored[2]{}; - this->prepare_number_limits_(restored); + bool restore_corrected[2]{}; + corrected = this->prepare_number_limits_(restored, restore_corrected); #endif if (!this->write_configuration_()) { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); this->mark_failed(); return; } @@ -61,14 +106,17 @@ void TMP102Component::setup() { if (control == nullptr) continue; const float value = this->get_limit_temperature_(static_cast(limit)); - if (value != restored[limit]) + if (restore_corrected[limit] || value != restored[limit]) saved &= control->save_value_(value); control->publish_state(value); } if (!saved) { ESP_LOGW(TAG, "Failed to save threshold preferences"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED); + return; } #endif + this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK); } #endif @@ -83,6 +131,9 @@ void TMP102Component::dump_config() { #ifdef USE_TMP102_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); #endif +#ifdef USE_TMP102_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() { this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED); + } else { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED); } return false; } @@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) { this->limit_controls_[number->limit_type_] = number; } -void TMP102Component::prepare_number_limits_(float *restored) { +bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) { const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + bool corrected = false; for (uint8_t limit = 0; limit < 2; ++limit) { TMP102LimitNumber *control = this->limit_controls_[limit]; if (control == nullptr) continue; - restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]); + corrected |= restore_corrected[limit]; raw[limit] = encode_temperature(restored[limit], extended); } if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); - return; + return true; } this->low_limit_ = raw[TMP102_LIMIT_LOW]; this->high_limit_ = raw[TMP102_LIMIT_HIGH]; + return corrected; } float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { @@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended)); return false; } if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { this->status_set_warning(); + this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED + : TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED); return false; } (high ? this->high_limit_ : this->low_limit_) = raw; @@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper #endif #endif +#ifdef USE_TMP102_TEXT_SENSOR +void TMP102Component::publish_threshold_status_(uint8_t status) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[40]; + ESPHOME_strncpy_P( + text, + reinterpret_cast(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->threshold_status_text_sensor_->publish_state(text); +} + +void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[96]; + if (high) { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other); + } else { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other); + } + this->threshold_status_text_sensor_->publish_state(text); +} +#endif + #ifdef USE_TMP102_BINARY_SENSOR void TMP102Component::invalidate_alert_() { if (this->alert_binary_sensor_ != nullptr) @@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() { #endif #ifdef USE_TMP102_NUMBER -float TMP102LimitNumber::load_value_(float fallback) { +float TMP102LimitNumber::load_value_(float fallback, bool &corrected) { float value = fallback; if (this->restore_value_) { this->pref_ = this->make_entity_preference(); @@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) { if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); value = fallback; + corrected = true; } return value; } @@ -313,6 +400,7 @@ void TMP102LimitNumber::control(float value) { // A chip that failed setup holds nothing the entity could show if (this->parent_->is_failed()) { ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED); return; } const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); @@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) { this->publish_state(value); if (accepted && !this->save_value_(value)) { ESP_LOGW(TAG, "Failed to save threshold preference"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED); + } else if (accepted) { + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK); } } #endif diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index d88dde78344..34cdfc973c1 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -12,11 +12,20 @@ #include "esphome/core/preferences.h" #endif #include "esphome/components/sensor/sensor.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include namespace esphome::tmp102 { +#ifdef USE_HOST +namespace testing { +class TMP102Test; +} +#endif + #ifdef USE_TMP102_NUMBER enum TMP102LimitType : uint8_t { TMP102_LIMIT_LOW = 0, @@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #ifdef USE_TMP102_BINARY_SENSOR SUB_BINARY_SENSOR(alert) #endif -#ifdef USE_TMP102_CONFIGURE +#ifdef USE_TMP102_TEXT_SENSOR + SUB_TEXT_SENSOR(threshold_status) +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void setup() override; +#endif +#ifdef USE_TMP102_CONFIGURE void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { this->config_ = config; this->high_limit_ = high_limit; @@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: +#ifdef USE_TMP102_TEXT_SENSOR + void publish_threshold_status_(uint8_t status); + void publish_threshold_rejection_(bool high, float value, float other); +#else + void publish_threshold_status_(uint8_t) {} + void publish_threshold_rejection_(bool, float, float) {} +#endif void set_warning_and_invalidate_alert_() { this->status_set_warning(); #ifdef USE_TMP102_BINARY_SENSOR @@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void invalidate_alert_(); #endif #ifdef USE_TMP102_CONFIGURE + void setup_configured_(); bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); bool check_configuration_(); @@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu friend class TMP102LimitNumber; bool set_limit_temperature_(TMP102LimitType limit, float temperature); float get_limit_temperature_(TMP102LimitType limit) const; - void prepare_number_limits_(float *restored); + bool prepare_number_limits_(float *restored, bool *restore_corrected); TMP102LimitNumber *limit_controls_[2]{}; #endif - uint16_t config_{0}; uint16_t high_limit_{0}; uint16_t low_limit_{0}; @@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented None: + source = generate_main(component_config_path("status.yaml")) + assert "set_threshold_status_text_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_TEXT_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_status_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_status.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 text_sensor may reference each temperature sensor" + in capsys.readouterr().out + ) + + def test_threshold_numbers_enable_configuration_once( generate_main: Callable[[Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 346efac83ec..60de2d34958 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,11 +6,12 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor", "number"): + for platform in ("sensor", "binary_sensor", "number", "text_sensor"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") cg.add_define("USE_TMP102_NUMBER") + cg.add_define("USE_TMP102_TEXT_SENSOR") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + [ @@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "sensor", "binary_sensor", "number", + "text_sensor", ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index dfc2e2e2245..3d1f66da5bb 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -30,3 +30,8 @@ binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature name: TMP102 Alert + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml new file mode 100644 index 00000000000..21880a6338f --- /dev/null +++ b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: tmp102 + id: tmp102_text_sensor_parent + name: TMP102 Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_text_sensor_parent + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 6c294f4cdc6..a5b65b060d4 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" @@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus { unsigned transactions{0}; unsigned writes{0}; uint8_t pointer{0}; + std::function on_write; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { @@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_NOT_ACKNOWLEDGED; ++this->writes; this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + if (this->on_write) { + auto callback = std::move(this->on_write); + callback(); + } } } if (out_size != 0) { @@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + text_sensor::TextSensor status_; TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; @@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test { static void save(TMP102LimitNumber &number, float value) { ASSERT_TRUE(number.make_entity_preference().save(&value)); } + + static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { EXPECT_EQ(this->bus_.registers, original); } +TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + EXPECT_EQ(this->bus_.registers, original); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "Not configured"); +} + +TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "OK"); +} + TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { for (bool configured_extended : {false, true}) { this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, @@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 20); save(this->low_, 35); @@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { EXPECT_FLOAT_EQ(this->low_.state, 25); EXPECT_FLOAT_EQ(saved(this->high_), 30); EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); +} + +TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->bus_.on_write = [this]() { + fail_saves(this->high_); + fail_saves(this->low_); + }; + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->status_.state, "Restore reset; save failed"); } TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, NAN); save(this->low_, INFINITY); this->chip_.setup(); EXPECT_FLOAT_EQ(this->high_.state, 30); EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); } TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); this->bus_.fail_write = 3; this->chip_.setup(); EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_EQ(this->status_.state, "Setup failed"); EXPECT_FALSE(this->high_.has_state()); EXPECT_FLOAT_EQ(saved(this->high_), 90); EXPECT_FLOAT_EQ(saved(this->low_), 85); } TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); this->bus_.fail_write = 3; @@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { EXPECT_FALSE(this->high_.has_state()); EXPECT_EQ(this->bus_.writes, writes); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed"); } TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { @@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { } TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); @@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { EXPECT_FLOAT_EQ(this->high_.state, 90); EXPECT_EQ(publications, 1); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Write failed: high threshold"); + + this->bus_.fail_write = 2; + this->low_.make_call().set_value(80).perform(); + EXPECT_FLOAT_EQ(this->low_.state, 85); + EXPECT_FLOAT_EQ(saved(this->low_), 85); + EXPECT_EQ(this->status_.state, "Write failed: low threshold"); +} + +TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + fail_saves(this->high_); + this->high_.make_call().set_value(35).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_EQ(this->status_.state, "Preference save failed"); +} + +TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + this->high_.make_call().set_value(20).perform(); + EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C"); + this->high_.make_call().set_value(35).perform(); + EXPECT_EQ(this->status_.state, "OK"); +} + +TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + this->bus_.registers[1] = 0; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration restored"); + + this->bus_.registers[1] = 0; + this->bus_.fail_write = 1; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration recovery failed"); } TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { From 5171113c870a58c0237f4546ac38d8f42e25bd76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:17:48 -1000 Subject: [PATCH 38/63] [core] Let PROGMEM_STRING_TABLE hold more than 255 bytes (#20646) --- esphome/core/progmem.h | 35 ++++++++-- .../core/test_progmem_string_table.cpp | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_progmem_string_table.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 54fc42dde16..677ef0e2afb 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -68,6 +69,7 @@ template struct FixedString { }; /// Compile-time string table that packs strings into a single blob with offset lookup. +/// Blobs over 255 bytes have no offsets and are scanned, so keep those for rarely read strings. /// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266. /// /// Example: @@ -78,6 +80,11 @@ template struct FixedString { template struct ProgmemStringTable { static constexpr size_t COUNT = sizeof...(Strs); static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1)); + /// Length of the longest string, for sizing a RAM copy on ESP8266 + static constexpr size_t MAX_LENGTH = std::max({Strs.size()...}); + /// One-byte offsets address up to 255 bytes; a larger blob has no offset table and is scanned instead, + /// which costs less flash than wider offsets for the rarely read tables that get this big. + static constexpr bool INDEXED = BLOB_SIZE <= UINT8_MAX; /// Generate packed string blob at compile time static constexpr auto make_blob() { @@ -91,18 +98,28 @@ template struct ProgmemStringTable { return result; } - /// Generate offset table at compile time (uint8_t limits blob to 255 bytes) + /// Generate offset table at compile time static constexpr auto make_offsets() { static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string"); static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices"); - static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings"); - std::array result{}; - size_t pos = 0, idx = 0; - ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); - return result; + if constexpr (INDEXED) { + std::array result{}; + size_t pos = 0, idx = 0; + ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); + return result; + } else { + return std::array{}; + } } }; +/// Skip idx strings in a packed NUL-separated blob that may live in flash. +inline const char *progmem_skip_strings(const char *blob, uint8_t idx) { + while (idx-- > 0) + blob += ESPHOME_strlen_P(blob) + 1; + return blob; +} + // Forward declaration for LogString (defined in log.h) struct LogString; @@ -115,6 +132,7 @@ struct LogString; static constexpr size_t COUNT = Table::COUNT; \ static constexpr uint8_t LAST_INDEX = COUNT - 1; \ static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \ + static constexpr size_t MAX_LENGTH = Table::MAX_LENGTH; \ static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \ static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \ static const char *get_(uint8_t idx, uint8_t fallback) { \ @@ -122,7 +140,10 @@ struct LogString; idx = fallback; \ /* std::launder is used here to prevent the inter-procedural analysis that */ \ /* causes the false positive that the string is not null terminated */ \ - return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + if constexpr (Table::INDEXED) \ + return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + else \ + return std::launder(::esphome::progmem_skip_strings(BLOB.data(), idx)); \ } \ static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \ return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \ diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp new file mode 100644 index 00000000000..4069bd4d2ab --- /dev/null +++ b/tests/components/core/test_progmem_string_table.cpp @@ -0,0 +1,65 @@ +#include + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::core::testing { + +PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN"); + +// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned. +PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789", + "1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789", + "3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789", + "5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789", + "7123456789012345678901234567890123456789"); + +// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning. +PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789"); +PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789x"); + +TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) { + static_assert(Exactly255::BLOB_SIZE == 255); + static_assert(Exactly255::Table::INDEXED); + static_assert(Exactly256::BLOB_SIZE == 256); + static_assert(!Exactly256::Table::INDEXED); + static_assert(Exactly256::OFFSETS.empty()); + EXPECT_EQ(Exactly255::OFFSETS[4], 204); + EXPECT_STREQ(reinterpret_cast(Exactly256::get_progmem_str(4, 0)), + "01234567890123456789012345678901234567890123456789x"); +} + +TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) { + static_assert(SmallStrings::BLOB_SIZE <= 255); + static_assert(SmallStrings::Table::INDEXED); + static_assert(SmallStrings::MAX_LENGTH == 7); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN"); +} + +TEST(ProgmemStringTable, LargeTableIsScanned) { + static_assert(LargeStrings::BLOB_SIZE > 255); + static_assert(!LargeStrings::Table::INDEXED); + static_assert(LargeStrings::MAX_LENGTH == 40); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)), + "0123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)), + "3123456789012345678901234567890123456789"); +} + +} // namespace esphome::core::testing From e41d16ba7e156315e7eb1217196fbb34332e7b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:23:28 -1000 Subject: [PATCH 39/63] [haier] Keep the alarm message table in flash (#20641) --- esphome/components/haier/hon_climate.cpp | 36 +++++++++++++-- esphome/components/haier/hon_packet.h | 56 ------------------------ 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 5e672c3e65d..72b1e26818b 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -3,6 +3,7 @@ #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "hon_climate.h" #include "hon_packet.h" @@ -20,6 +21,27 @@ constexpr size_t ALARM_STATUS_REQUEST_INTERVAL_MS = 600000; const uint8_t ONE_BUF[] = {0x00, 0x01}; const uint8_t ZERO_BUF[] = {0x00, 0x00}; +// Alarm texts indexed by alarm code; the last entry is the fallback for unknown codes +PROGMEM_STRING_TABLE( + HonAlarmMessages, "Outdoor module failure", "Outdoor defrost sensor failure", + "Outdoor compressor exhaust sensor failure", "Outdoor EEPROM abnormality", "Indoor coil sensor failure", + "Indoor-outdoor communication failure", "Power supply overvoltage protection", + "Communication failure between panel and indoor unit", "Outdoor compressor overheat protection", + "Outdoor environmental sensor abnormality", "Full water protection", "Indoor EEPROM failure", + "Outdoor out air sensor failure", "CBD and module communication failure", "Indoor DC fan failure", + "Outdoor DC fan failure", "Door switch failure", "Dust filter needs cleaning reminder", "Water shortage protection", + "Humidity sensor failure", "Indoor temperature sensor failure", "Manipulator limit failure", + "Indoor PM2.5 sensor failure", "Outdoor PM2.5 sensor failure", "Indoor heating overload/high load alarm", + "Outdoor AC current protection", "Outdoor compressor operation abnormality", "Outdoor DC current protection", + "Outdoor no-load failure", "CT current abnormality", "Indoor cooling freeze protection", + "High and low pressure protection", "Compressor out air temperature is too high", + "Outdoor evaporator sensor failure", "Outdoor cooling overload", "Water pump drainage failure", + "Three-phase power supply failure", "Four-way valve failure", "External alarm/scraper flow switch failure", + "Temperature cutoff protection alarm", "Different mode operation failure", "Electronic expansion valve failure", + "Dual heat source sensor Tw failure", "Communication failure with the wired controller", + "Indoor unit address duplication failure", "50Hz zero crossing failure", "Outdoor unit failure", + "Formaldehyde sensor failure", "VOC sensor failure", "CO2 sensor failure", "Firewall failure", "Unknown"); + HonClimate::HonClimate() : cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), active_alarms_{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -687,9 +709,17 @@ void HonClimate::process_alarm_message_(const uint8_t *packet, uint8_t size, boo if ((packet[2 + i] & alarm_bit) != (this->active_alarms_[i] & alarm_bit)) { bool alarm_status = (packet[2 + i] & alarm_bit) != 0; int log_level = alarm_status ? ESPHOME_LOG_LEVEL_WARN : ESPHOME_LOG_LEVEL_INFO; - const char *alarm_message = alarm_code < esphome::haier::hon_protocol::HON_ALARM_COUNT - ? esphome::haier::hon_protocol::HON_ALARM_MESSAGES[alarm_code].c_str() - : "Unknown"; +#ifdef USE_ESP8266 + // Static so a delayed on_alarm automation can still read it, but only until the next + // alarm change, including a later one in this packet; `code` stays correct + static char alarm_message[HonAlarmMessages::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(alarm_message, + reinterpret_cast( + HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX)), + sizeof(alarm_message)); +#else + const char *alarm_message = HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX); +#endif esp_log_printf_(log_level, TAG, __LINE__, "Alarm %s (%d): %s", alarm_status ? "activated" : "deactivated", alarm_code, alarm_message); if (alarm_status) { diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index 63799f04ba1..1d17acb868a 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -197,60 +197,4 @@ enum class SubcommandsControl : uint16_t { // content: all values like in status packet) }; -const std::string HON_ALARM_MESSAGES[] = { - "Outdoor module failure", - "Outdoor defrost sensor failure", - "Outdoor compressor exhaust sensor failure", - "Outdoor EEPROM abnormality", - "Indoor coil sensor failure", - "Indoor-outdoor communication failure", - "Power supply overvoltage protection", - "Communication failure between panel and indoor unit", - "Outdoor compressor overheat protection", - "Outdoor environmental sensor abnormality", - "Full water protection", - "Indoor EEPROM failure", - "Outdoor out air sensor failure", - "CBD and module communication failure", - "Indoor DC fan failure", - "Outdoor DC fan failure", - "Door switch failure", - "Dust filter needs cleaning reminder", - "Water shortage protection", - "Humidity sensor failure", - "Indoor temperature sensor failure", - "Manipulator limit failure", - "Indoor PM2.5 sensor failure", - "Outdoor PM2.5 sensor failure", - "Indoor heating overload/high load alarm", - "Outdoor AC current protection", - "Outdoor compressor operation abnormality", - "Outdoor DC current protection", - "Outdoor no-load failure", - "CT current abnormality", - "Indoor cooling freeze protection", - "High and low pressure protection", - "Compressor out air temperature is too high", - "Outdoor evaporator sensor failure", - "Outdoor cooling overload", - "Water pump drainage failure", - "Three-phase power supply failure", - "Four-way valve failure", - "External alarm/scraper flow switch failure", - "Temperature cutoff protection alarm", - "Different mode operation failure", - "Electronic expansion valve failure", - "Dual heat source sensor Tw failure", - "Communication failure with the wired controller", - "Indoor unit address duplication failure", - "50Hz zero crossing failure", - "Outdoor unit failure", - "Formaldehyde sensor failure", - "VOC sensor failure", - "CO2 sensor failure", - "Firewall failure", -}; - -constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]); - } // namespace esphome::haier::hon_protocol From c1fa73470e7c4cc6409da6bb97ce6b20a8c34ea5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:31:13 -1000 Subject: [PATCH 40/63] [display] Walk the blit fallback in frame buffer order and resolve the bitness once (#20651) --- esphome/components/display/display.cpp | 80 ++++++++----- esphome/components/display/display.h | 11 ++ .../components/display/display_color_utils.h | 22 ++++ esphome/components/epaper_spi/epaper_spi.h | 1 + esphome/components/it8951/it8951.h | 1 + esphome/components/mipi_spi/mipi_spi.h | 18 +-- tests/components/display/__init__.py | 20 ++++ .../display/test_draw_pixels_at.cpp | 109 ++++++++++++++++++ 8 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 tests/components/display/__init__.py create mode 100644 tests/components/display/test_draw_pixels_at.cpp diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 0bd8346953c..f006c64be76 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -5,6 +5,7 @@ #include "display_color_utils.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::display { @@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ this->line(x1, y1, x2, y2, color); } -void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels - uint32_t color_value; +// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at +// -Os stops inlining it once there are two call sites, which costs a call and +// spills per pixel. Without PSRAM there is one call site and it changes +// nothing, and draw_pixel_at() stays a virtual call either way. +template +static void __attribute__((flatten)) +draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad) { + const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels + // BITNESS is a constant here, so to_color()'s three per pixel divisions + // become a multiply and shift and its bitness switch folds. + const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { + display->draw_pixel_at( + x + x_start, y + y_start, + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + }; + // The source and frame buffers are row-major, so walking rows keeps the read + // and the write sequential. When the display swaps the axes before writing, + // walking columns keeps the write sequential instead; with the frame buffer + // in PSRAM a strided write costs more than a strided read. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + size_t column_idx = y_offset * line_stride + x_offset; + for (int x = 0; x != w; x++, column_idx++) { + size_t source_idx = column_idx; + for (int y = 0; y != h; y++, source_idx += line_stride) { + draw_source_pixel(x, y, source_idx); + } + } + return; + } +#endif + // Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped. for (int y = 0; y != h; y++) { size_t source_idx = (y_offset + y) * line_stride + x_offset; - size_t source_idx_mod; for (int x = 0; x != w; x++, source_idx++) { - switch (bitness) { - default: - color_value = ptr[source_idx]; - break; - case COLOR_BITNESS_565: - source_idx_mod = source_idx * 2; - if (big_endian) { - color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1]; - } else { - color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8); - } - break; - case COLOR_BITNESS_888: - source_idx_mod = source_idx * 3; - if (big_endian) { - color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2]; - } else { - color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16); - } - break; - } - this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness)); + draw_source_pixel(x, y, source_idx); } } } +void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + switch (bitness) { + case COLOR_BITNESS_565: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + case COLOR_BITNESS_888: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + default: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + } +} + void HOT Display::horizontal_line(int x, int y, int width, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = x; i < x + width; i++) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 7d1053f4fa7..1a45c4d6750 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -139,6 +139,11 @@ enum DisplayRotation { DISPLAY_ROTATION_270_DEGREES = 270, }; +/// Whether a rotation swaps the x and y axes. +constexpr bool rotation_swaps_axes(DisplayRotation rotation) { + return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES; +} + const int EDGES_TRIGON = 3; const int EDGES_TRIANGLE = 3; const int EDGES_TETRAGON = 4; @@ -717,6 +722,12 @@ class Display : public PollingComponent { virtual DisplayRotation get_rotation() const { return this->rotation_; } + /// True when draw_pixel_at() swaps x and y before writing, so pixels that are + /// neighbours along x land a row apart in the frame buffer. Not the same as + /// get_width()/get_height() swapping: a display that rotates in hardware + /// reports swapped dimensions but writes unswapped pixels. + virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); } + /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. */ diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index be39e2a3e2f..3cf4a54999e 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -2,6 +2,7 @@ #include #include #include "esphome/core/color.h" +#include "esphome/core/helpers.h" namespace esphome::display { enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 }; @@ -10,6 +11,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25 class ColorUtil { public: + /// Reads one packed pixel of the given bitness from a source buffer into the + /// value to_color() takes. BITNESS is a template argument so the byte layout + /// folds; pass it on to to_color() as a constant too, and its channel scaling + /// divides by a constant, which the compiler turns into a multiply and shift. + template + static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) { + if constexpr (BITNESS == COLOR_BITNESS_565) { + const size_t idx = index * 2; + if (big_endian) + return (ptr[idx] << 8) + ptr[idx + 1]; + return ptr[idx] + (ptr[idx + 1] << 8); + } else if constexpr (BITNESS == COLOR_BITNESS_888) { + const size_t idx = index * 3; + if (big_endian) + return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2]; + return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); + } else { + return ptr[index]; + } + } + static Color to_color(uint32_t colorcode, ColorOrder color_order, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { uint8_t first_color, second_color, third_color; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 7f1167ae6a4..384b9cb0181 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,6 +106,7 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e1273587c45..e99e905ebf5 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -204,6 +204,7 @@ class IT8951Display : public Display, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: int get_height_internal() override { return this->height_; } diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 96f26ef19b7..3a772862f31 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -130,19 +130,8 @@ class MipiSpi : public display::Display, } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; - } + int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; } + int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; } void set_init_sequence(const uint8_t *sequence, size_t len) { this->init_sequence_ = sequence; @@ -621,6 +610,9 @@ class MipiSpiBuffer #endif } + // With hardware rotation the buffer is written in drawn coordinates. + bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } + // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { if (this->is_point_clipped(x, y)) diff --git a/tests/components/display/__init__.py b/tests/components/display/__init__.py new file mode 100644 index 00000000000..db75e3e6360 --- /dev/null +++ b/tests/components/display/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + +TESTS_DIR = Path(__file__).parent + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The column walk in Display::draw_pixels_at() is only built with PSRAM; define it + # when display's own tests are built so both walk orders run, and leave other + # components' test binaries on the plain build. + async def to_code_testing(config: ConfigType) -> None: + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + cg.add_define("USE_PSRAM") + + manifest.to_code = to_code_testing diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp new file mode 100644 index 00000000000..c8c32d15145 --- /dev/null +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::display::testing { + +// Records every pixel write; pixel_axes_swapped() picks the walk order. +class RecordingDisplay : public Display { + public: + void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; } + bool pixel_axes_swapped() const override { return this->axes_swapped_; } + void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); } + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + + std::vector> sorted_pixels() const { + auto sorted = this->pixels_; + std::sort(sorted.begin(), sorted.end()); + return sorted; + } + size_t count() const { return this->pixels_.size(); } + const std::vector> &pixels() const { return this->pixels_; } + + protected: + bool axes_swapped_{false}; + std::vector> pixels_; +}; + +// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of +// right padding, so the source index arithmetic of both walks is exercised. +static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4; +static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD; +static constexpr int LINES = Y_OFFSET + H; + +static std::vector make_source(int bytes_per_pixel) { + std::vector source(LINE_STRIDE * LINES * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : source) + byte = value++; + return source; +} + +static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) { + const auto source = make_source(bytes_per_pixel); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + EXPECT_EQ(rows.count(), static_cast(W * H)); + EXPECT_EQ(columns.count(), static_cast(W * H)); + EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); +} + +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); } + +// One known pixel per bitness and byte order, so a decode that swapped bytes or channels +// could not pass the walk comparison above by being wrong the same way twice. +static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); +} + +TEST(DrawPixelsAt, Golden565BigEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7 +} +TEST(DrawPixelsAt, Golden565LittleEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green +} +TEST(DrawPixelsAt, Golden888BigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30)); +} +TEST(DrawPixelsAt, Golden888LittleEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); +} +TEST(DrawPixelsAt, Golden332) { + expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3 +} + +// The column walk really is a different order, otherwise the tests above prove nothing. +TEST(DrawPixelsAt, ColumnWalkChangesOrder) { + const auto source = make_source(1); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, + X_PAD); + ASSERT_GT(rows.count(), 1u); + ASSERT_GT(columns.count(), 1u); + EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first + EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first +} + +} // namespace esphome::display::testing From 887d4c3a8337d4c62c391b90176fe97b9b1ee798 Mon Sep 17 00:00:00 2001 From: Ryan Ludwig <20423360+steamEngineer@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:36:15 -0700 Subject: [PATCH 41/63] [esp32_can] Add NO_ACK mode (#20706) Co-authored-by: Cursor --- esphome/components/esp32_can/canbus.py | 1 + esphome/components/esp32_can/esp32_can.cpp | 7 +++++-- esphome/components/esp32_can/esp32_can.h | 1 + tests/components/esp32_can/common.yaml | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index ae299370cd7..f8f76b2d578 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -47,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode") CAN_MODES = { "NORMAL": CanMode.CAN_MODE_NORMAL, "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, + "NO_ACK": CanMode.CAN_MODE_NO_ACK, } # Currently the driver only supports a subset of the bit rates defined in canbus diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index d84b58c5b0c..ee677a08005 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -79,10 +79,13 @@ bool ESP32Can::setup_internal() { } // Select TWAI mode based on configuration - twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; - + twai_mode_t twai_mode = TWAI_MODE_NORMAL; if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + twai_mode = TWAI_MODE_LISTEN_ONLY; ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } else if (this->mode_ == CAN_MODE_NO_ACK) { + twai_mode = TWAI_MODE_NO_ACK; + ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)"); } twai_general_config_t g_config = diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f9549bea346..176a3b57f5e 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -12,6 +12,7 @@ namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, CAN_MODE_LISTEN_ONLY = 1, + CAN_MODE_NO_ACK = 2, }; class ESP32Can final : public canbus::Canbus { diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index a26c5d35a34..01b208995df 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,7 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps - mode: NORMAL + mode: NO_ACK on_frame: - can_id: 500 then: From 60a1d70805b4cc810928ea4174af3c7b3bcc8fde Mon Sep 17 00:00:00 2001 From: Dmitrii Kuminov Date: Tue, 13 Oct 2026 20:46:54 -0700 Subject: [PATCH 42/63] [core] Move deferred include processing to parse_yaml (#16344) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 14 ++++++++++---- tests/unit_tests/test_yaml_util.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b5e2ec17e8a..261d8b50e36 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1120,8 +1120,12 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err - # Top-level !include returns a deferred IncludeFile; resolve it so - # callers always receive the final content. + return res + + +def _resolve_include(res: Any) -> Any: + # A document that is just `!include` parses to a deferred IncludeFile; resolve it so + # every parse_yaml caller, including the VS Code and ACE loaders, gets the final content. if isinstance(res, IncludeFile): res = res.load() if res.should_load() else {} return res @@ -1147,7 +1151,7 @@ def parse_yaml( # tracked mode; reject the combination instead of half-applying it. raise ValueError("track_document_range=False requires the default yaml_loader") try: - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, @@ -1159,13 +1163,15 @@ def parse_yaml( # readable exceptions # Rewind the stream so we can try again file_handle.seek(0, 0) - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomePurePythonLoader, file_name, file_handle, yaml_loader, track_document_range=track_document_range, ) + # Resolved after the fallback so a failed first parse never loads the include twice + return _resolve_include(res) def _load_yaml_internal_with_type( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c3276823ecc..655fac6bf26 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -954,6 +954,23 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" +def test_parse_yaml_custom_loader_resolves_root_include() -> None: + """A document that is only `!include` is resolved for a custom loader too.""" + parent = Path("/fake/main.yaml") + loader_calls: list[Path] = [] + + def custom_loader(fname: Path) -> dict[str, int]: + loader_calls.append(fname) + return {"x": 1} + + config = yaml_util.parse_yaml( + parent, io.StringIO("!include inner.yaml\n"), custom_loader + ) + + assert config == {"x": 1} + assert loader_calls == [Path("/fake/inner.yaml")] + + def test_yaml_merge_chain_include_depth_exceeded() -> None: """Chain includes in merge keys exceeding depth limit raise a clear error.""" parent = Path("/fake/main.yaml") From 1338902ac5e5f098801e01d79f42d9d0e1422b26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 18:06:22 -1000 Subject: [PATCH 43/63] [heatpumpir] Link only the configured protocol classes (#20707) --- esphome/components/heatpumpir/climate.py | 4 +- esphome/components/heatpumpir/heatpumpir.cpp | 132 ++++++++++--------- esphome/components/heatpumpir/heatpumpir.h | 32 +++-- 3 files changed, 90 insertions(+), 78 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 2583839ca81..42ef79d31d2 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await climate_ir.new_climate_ir(config) - cg.add(var.set_protocol(config[CONF_PROTOCOL])) + tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL]) + var = await climate_ir.new_climate_ir(config, tag()) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index b4597fe4abf..a23d69e3b22 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -5,7 +5,6 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) #include -#include #include #include #include "esphome/components/remote_base/remote_base.h" @@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender { ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); -const std::map> PROTOCOL_CONSTRUCTOR_MAP = { - {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT - {PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT - {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT - {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT -}; +template struct ProtocolClass; + +template HeatpumpIR *create_heatpump_ir() { + return new typename ProtocolClass

::type(); // NOLINT +} + +// Codegen calls only the configured protocols, so the linker drops the other classes +#define HEATPUMPIR_PROTOCOL(protocol, cls) \ + template<> struct ProtocolClass { using type = cls; }; \ + template HeatpumpIR *create_heatpump_ir(); + +HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR) + +#undef HEATPUMPIR_PROTOCOL void HeatpumpIRClimate::setup() { - auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_); - if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) { - ESP_LOGE(TAG, "Invalid protocol"); - return; - } - this->heatpump_ir_ = protocol_constructor->second(); climate_ir::ClimateIR::setup(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index d00fa91d05a..dfa5c40366a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -89,25 +89,21 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 5, }; +/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp. +template HeatpumpIR *create_heatpump_ir(); + +/// Selects the protocol at compile time so the enum and the library object always match. +template struct ProtocolTag {}; + // Temperature const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: - HeatpumpIRClimate() - : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, - climate::CLIMATE_FAN_AUTO}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + template + explicit HeatpumpIRClimate(ProtocolTag

/*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir

()) {} void setup() override; - void set_protocol(Protocol protocol) { - this->protocol_ = protocol; - if (this->has_fan_byte_presets_()) { - this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; - } - } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void set_min_temperature(float temperature) { this->min_temperature_ = temperature; } protected: + HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir) + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}), + heatpump_ir_(heatpump_ir), + protocol_(protocol) { + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } HeatpumpIR *heatpump_ir_; void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. From 1146b77836f59944a18001d95e675bfb67f1f5d9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 08:19:17 +0300 Subject: [PATCH 44/63] [socket] Add TcpClientLink::connecting() (#20722) --- esphome/components/socket/tcp_client_link.h | 2 ++ .../socket/test_tcp_client_link_host.cpp | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index f77ee050555..517a83375df 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -61,6 +61,8 @@ class TcpClientLink { void close(); bool connected() const { return this->connected_; } + /// A connect attempt is in flight. + bool connecting() const { return this->sock_ != nullptr && !this->connected_; } bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } /// Shared retry clock, also usable for a listen socket. void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 98be8190b00..a5be9225e52 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { EXPECT_TRUE(this->link_.in_backoff()); } +TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) { + this->link_.begin("link_test"); + EXPECT_FALSE(this->link_.connecting()); + this->set_pending_socket(); + this->link_.note_attempt(); + this->link_.poll(); + EXPECT_TRUE(this->link_.connecting()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.connecting()); + EXPECT_FALSE(this->link_.connected()); +} + TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { this->link_.set_reconnect_interval(20000); this->link_.begin("link_test"); @@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); + EXPECT_FALSE(p.link_.connecting()); EXPECT_EQ(p.link_.queue(reinterpret_cast("ping"), 4), 4u); EXPECT_TRUE(p.link_.flush_tx()); char buf[8]; From a97604d5746ed7c3045ca539ea319ce52edfd0b1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 01:10:51 -0500 Subject: [PATCH 45/63] [esp32_ble] Rely on preferences for NVS init (#20708) Co-authored-by: Claude Opus 5.5 --- esphome/components/esp32/preferences.cpp | 1 + esphome/components/esp32_ble/ble.cpp | 16 ++++------------ esphome/components/esp32_ble/ble.h | 1 - .../esp32_ble_beacon/esp32_ble_beacon.cpp | 1 - .../components/esp32_ble_server/ble_server.cpp | 1 - .../esp32_ble_tracker/esp32_ble_tracker.cpp | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 1 - 7 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 02978ae0f7a..87cb3345b08 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { void ESP32Preferences::open() { // Runs from app_main() before the logger is initialized; any logging here // must be deferred. See s_open_err and make_preference() below. + // Components that need NVS check nvs_handle instead of initializing it again. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 11290a95f41..1379584120b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #ifndef CONFIG_BT_CONTROLLER_DISABLED #include @@ -28,7 +29,6 @@ extern "C" { #include #include #include -#include #ifdef USE_ARDUINO // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). @@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; void ESP32BLE::setup() { global_ble = this; - if (!ble_pre_setup_()) { - ESP_LOGE(TAG, "BLE could not be prepared for configuration"); + // Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); this->mark_failed(); return; } @@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { } #endif -bool ESP32BLE::ble_pre_setup_() { - esp_err_t err = nvs_flash_init(); - if (err != ESP_OK) { - ESP_LOGE(TAG, "nvs_flash_init failed: %d", err); - return false; - } - return true; -} - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 950ae6e0bc1..ef326e1532d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -185,7 +185,6 @@ class ESP32BLE final : public Component { } this->ble_events_.get_and_reset_dropped_count(); } - bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 7481d2c612c..430393f2d81 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "esphome/core/hal.h" diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 650a765b6a5..0dec0a74c5d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #ifndef CONFIG_BT_CONTROLLER_DISABLED diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8d3148c2fc7..cd1c16e8f5d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #ifdef USE_OTA diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 68c0d005b57..29bbdc2b0b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -5,7 +5,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" -#include "nvs_flash.h" #include "zigbee_attribute_esp32.h" #include "zigbee_esp32.h" #include "esphome/core/application.h" From 7f2c3ba3f5540e241d5aa110967d4d36db410a5e Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 09:11:47 +0300 Subject: [PATCH 46/63] [display] Let other components draw into a display (#20622) --- esphome/components/display/__init__.py | 36 +++++++ esphome/components/epaper_spi/display.py | 7 +- esphome/components/it8951/display.py | 6 +- .../config/epaper_spi_owner_auto_id.yaml | 28 ++++++ .../display/config/it8951_owner_auto_id.yaml | 21 ++++ .../display/config/no_owner.yaml | 25 +++++ .../display/config/owner_by_id.yaml | 33 +++++++ .../fake_display_writer/__init__.py | 20 ++++ .../display/test_external_writer.py | 98 +++++++++++++++++++ 9 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/it8951_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/no_owner.yaml create mode 100644 tests/component_tests/display/config/owner_by_id.yaml create mode 100644 tests/component_tests/display/external_components/fake_display_writer/__init__.py create mode 100644 tests/component_tests/display/test_external_writer.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 0b871af30cd..22e3d9320b0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -250,6 +250,42 @@ def add_metadata( ) +KEY_EXTERNAL_WRITERS = "external_writers" + + +def _get_external_writer_list() -> list[tuple[ID, str]]: + """Get the raw external writer list. Each entry is (display id, owner).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, []) + + +def register_external_writer(display_id: ID, owner: str) -> None: + """Declare that ``owner`` draws into the display from outside its lambda or pages. + + Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass + resolves it in place before final validation. + """ + _get_external_writer_list().append((display_id, owner)) + + +def get_external_writers(display_id: ID) -> list[str]: + """Get the owners registered for a display. Only valid once IDs are resolved.""" + return [ + owner + for id_, owner in _get_external_writer_list() + if id_.id is not None and id_.id == display_id.id + ] + + +def has_external_writer(display_id: ID) -> bool: + """Tell a driver's final validation that something else draws into the display. + + LVGL anywhere in the config also counts, as the drivers assumed before. + """ + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id)) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..06aa53acb69 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -40,7 +40,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from . import models @@ -198,15 +197,11 @@ def _final_validate(config) -> None: "epaper_spi", require_miso=False, require_mosi=True )(config) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: - # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 76fbadd1968..d9c80ebf43d 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -37,7 +37,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] @@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None: config ) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: diff --git a/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml new file mode 100644 index 00000000000..73d993fb290 --- /dev/null +++ b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: epaper_spi + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: diff --git a/tests/component_tests/display/config/it8951_owner_auto_id.yaml b/tests/component_tests/display/config/it8951_owner_auto_id.yaml new file mode 100644 index 00000000000..ed6b0fd8994 --- /dev/null +++ b/tests/component_tests/display/config/it8951_owner_auto_id.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + model: m5stack-m5paper + +fake_display_writer: diff --git a/tests/component_tests/display/config/no_owner.yaml b/tests/component_tests/display/config/no_owner.yaml new file mode 100644 index 00000000000..4a4714779a2 --- /dev/null +++ b/tests/component_tests/display/config/no_owner.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 diff --git a/tests/component_tests/display/config/owner_by_id.yaml b/tests/component_tests/display/config/owner_by_id.yaml new file mode 100644 index 00000000000..e3be24deffe --- /dev/null +++ b/tests/component_tests/display/config/owner_by_id.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: + display_id: epaper_display diff --git a/tests/component_tests/display/external_components/fake_display_writer/__init__.py b/tests/component_tests/display/external_components/fake_display_writer/__init__.py new file mode 100644 index 00000000000..5e1388578fa --- /dev/null +++ b/tests/component_tests/display/external_components/fake_display_writer/__init__.py @@ -0,0 +1,20 @@ +"""External component that draws into a display from outside its lambda or pages.""" + +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["display"] +DOMAIN = "fake_display_writer" + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}), + _register_writer, +) diff --git a/tests/component_tests/display/test_external_writer.py b/tests/component_tests/display/test_external_writer.py new file mode 100644 index 00000000000..c51bf15b579 --- /dev/null +++ b/tests/component_tests/display/test_external_writer.py @@ -0,0 +1,98 @@ +"""Tests for the display external writer registry.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.components.display import ( + Display, + get_external_writers, + has_external_writer, + register_external_writer, +) +from esphome.core import ID +from esphome.final_validate import full_config + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + + +@pytest.fixture +def unload_fake_writer() -> Generator[None]: + """Drop the fake external component so later tests start clean.""" + yield + loader._COMPONENT_CACHE.pop("fake_display_writer", None) + sys.modules.pop("esphome.components.fake_display_writer", None) + loader.clear_component_meta_finders() + + +def test_unresolved_id_matches_after_resolution() -> None: + """An auto-picked ID registered during schema validation matches once resolved.""" + pending = ID(None, is_declaration=False, type=Display) + register_external_writer(pending, "owner") + declared = ID("epd", is_declaration=True, type=Display) + + assert get_external_writers(declared) == [] + assert has_external_writer(declared) is False + + pending.id = "epd" + assert get_external_writers(declared) == ["owner"] + assert has_external_writer(declared) is True + assert get_external_writers(ID("other", is_declaration=True, type=Display)) == [] + + +def test_all_owners_returned() -> None: + register_external_writer(ID("epd"), "first") + register_external_writer(ID("epd"), "second") + register_external_writer(ID("other"), "third") + + assert get_external_writers(ID("epd")) == ["first", "second"] + + +def test_lvgl_counts_as_external_writer() -> None: + assert has_external_writer(ID("epd")) is False + full_config.get()["lvgl"] = [] + assert has_external_writer(ID("epd")) is True + + +def test_drivers_show_test_card_without_owner( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("no_owner.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" in main_cpp + assert UPDATE_NEVER not in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +@pytest.mark.parametrize( + "config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"] +) +def test_owned_display_has_no_test_card( + config_file: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The owner picks the display ID automatically, so it resolves after the schema runs.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +def test_only_owned_display_loses_test_card( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("owner_by_id.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" not in main_cpp + assert f"epaper_display->{UPDATE_NEVER}" in main_cpp + assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp From 35b416c6c0bc85135811bca6a98150c3160d5558 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Wed, 14 Oct 2026 08:19:06 +0000 Subject: [PATCH 47/63] [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++++++++-- .../web_server_idf/web_server_idf.h | 12 +++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d9a7d65e8ce..456c9be2c9a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -959,10 +959,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -976,7 +1005,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1085,6 +1114,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 4d4490d9076..c25dd8d609f 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -339,10 +339,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -357,11 +362,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -383,6 +390,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From 56cc03c4d4e6696fc1d4f0ca745748a57205ac0c Mon Sep 17 00:00:00 2001 From: Pavlo Dudnytskyi Date: Wed, 14 Oct 2026 11:27:18 +0200 Subject: [PATCH 48/63] [haier] Haier Select component added (#16148) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pavlo Dudnytskyi Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/haier/hon_climate.cpp | 176 ++++++++++++++++-- esphome/components/haier/hon_climate.h | 38 ++++ esphome/components/haier/select/__init__.py | 67 +++++++ .../haier/select/horizontal_airflow.h | 15 ++ .../haier/select/vertical_airflow.h | 15 ++ tests/components/haier/common.yaml | 8 + 7 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 esphome/components/haier/select/__init__.py create mode 100644 esphome/components/haier/select/horizontal_airflow.h create mode 100644 esphome/components/haier/select/vertical_airflow.h diff --git a/CODEOWNERS b/CODEOWNERS index 95d8c49c23e..1282e9d66b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -240,6 +240,7 @@ esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn esphome/components/haier/button/* @paveldn +esphome/components/haier/select/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 72b1e26818b..59484aab16d 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -2,6 +2,8 @@ #include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/progmem.h" #include "hon_climate.h" @@ -12,6 +14,26 @@ using namespace esphome::uart; namespace esphome::haier { +static bool vertical_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_VERTICAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool horizontal_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool is_auto(hon_protocol::VerticalSwingMode mode) { + return (mode == hon_protocol::VerticalSwingMode::AUTO) || (mode == hon_protocol::VerticalSwingMode::AUTO_SPECIAL); +} + +static bool is_auto(hon_protocol::HorizontalSwingMode mode) { return mode == hon_protocol::HorizontalSwingMode::AUTO; } + +// A position the user just set wins over the appliance's echo of the old one for this long +constexpr uint32_t AIRFLOW_SELECT_DEBOUNCE_MS = 10000; +static bool in_debounce(uint32_t set_time, uint32_t now) { + return set_time != 0 && now - set_time < AIRFLOW_SELECT_DEBOUNCE_MS; +} + ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; @@ -89,21 +111,60 @@ bool HonClimate::get_quiet_mode_state() const { } esphome::optional HonClimate::get_vertical_airflow() const { + if (!this->current_vertical_swing_.has_value()) { + return this->current_vertical_swing_; + } + // While the appliance swings, or right after the user chose a position, report the chosen position + if (is_auto(this->current_vertical_swing_.value()) || + in_debounce(this->vertical_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_vertiacal_swing; + } return this->current_vertical_swing_; -}; +} void HonClimate::set_vertical_airflow(hon_protocol::VerticalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Vertical AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_vertiacal_swing != direction) { + this->settings_.last_vertiacal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_vertical_direction_ = direction; this->force_send_control_ = true; + this->vertical_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } esphome::optional HonClimate::get_horizontal_airflow() const { + if (!this->current_horizontal_swing_.has_value()) { + return this->current_horizontal_swing_; + } + if (is_auto(this->current_horizontal_swing_.value()) || + in_debounce(this->horizontal_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_horizontal_swing; + } return this->current_horizontal_swing_; } void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Horizontal AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_horizontal_swing != direction) { + this->settings_.last_horizontal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_horizontal_direction_ = direction; this->force_send_control_ = true; + this->horizontal_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } const char *HonClimate::get_cleaning_status_text() const { @@ -533,6 +594,18 @@ void HonClimate::initialization() { } else { this->settings_ = {hon_protocol::VerticalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::CENTER, true, false}; } + bool settings_fixed = false; + if (is_auto(this->settings_.last_vertiacal_swing)) { + this->settings_.last_vertiacal_swing = hon_protocol::VerticalSwingMode::CENTER; + settings_fixed = true; + } + if (is_auto(this->settings_.last_horizontal_swing)) { + this->settings_.last_horizontal_swing = hon_protocol::HorizontalSwingMode::CENTER; + settings_fixed = true; + } + if (settings_fixed) { + this->hon_rtc_.save(&this->settings_); + } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; @@ -668,12 +741,21 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { } } } + const ClimateSwingMode target_swing_mode = this->current_hvac_settings_.swing_mode.value_or(this->swing_mode); if (this->pending_vertical_direction_.has_value()) { - out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + if (!vertical_axis_is_auto(target_swing_mode)) { + out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + } else { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } this->pending_vertical_direction_.reset(); } if (this->pending_horizontal_direction_.has_value()) { - out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + if (!horizontal_axis_is_auto(target_swing_mode)) { + out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + } else { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } this->pending_horizontal_direction_.reset(); } { @@ -840,6 +922,41 @@ void HonClimate::set_quiet_mode_switch(switch_::Switch *sw) { } #endif // USE_SWITCH +#ifdef USE_SELECT +template +static void publish_airflow_index(select::Select *sel, const std::array &order, Mode mode) { + if (sel == nullptr) { + return; + } + // Saved settings never hold AUTO after initialization(); any other value outside the table shows as Center + size_t index = N; + size_t center = 0; + for (size_t i = 0; i < N; i++) { + const Mode entry = airflow_order_at(order, i); + if (entry == mode) { + index = i; + } else if (entry == Mode::CENTER) { + center = i; + } + } + if (index == N) { + index = center; + } + const auto active = sel->active_index(); + if (active.has_value() && *active == index) { + return; + } + sel->publish_state(index); +} + +void HonClimate::publish_airflow_selects_() { + publish_airflow_index(this->vertical_airflow_select_, VERTICAL_SWING_MODES_ORDER, + this->settings_.last_vertiacal_swing); + publish_airflow_index(this->horizontal_airflow_select_, HORIZONTAL_SWING_MODES_ORDER, + this->settings_.last_horizontal_swing); +} +#endif // USE_SELECT + haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { size_t expected_size = 2 + this->status_message_header_size_ + this->real_control_packet_size_ + this->real_sensors_packet_size_; @@ -1074,16 +1191,25 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // Saving last known non auto mode for vertical and horizontal swing this->current_vertical_swing_ = (hon_protocol::VerticalSwingMode) packet.control.vertical_swing_mode; this->current_horizontal_swing_ = (hon_protocol::HorizontalSwingMode) packet.control.horizontal_swing_mode; - bool save_settings = ((this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO) && - (this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO_SPECIAL) && - (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing)) || - ((this->current_horizontal_swing_.value() != hon_protocol::HorizontalSwingMode::AUTO) && - (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing)); - if (save_settings) { - this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); - this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + const uint32_t now_ms = App.get_loop_component_start_time(); + bool save_vertical = !in_debounce(this->vertical_direction_set_time_, now_ms) && + !is_auto(this->current_vertical_swing_.value()) && + (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing); + bool save_horizontal = !in_debounce(this->horizontal_direction_set_time_, now_ms) && + !is_auto(this->current_horizontal_swing_.value()) && + (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing); + if (save_vertical || save_horizontal) { + if (save_vertical) { + this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); + } + if (save_horizontal) { + this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + } this->hon_rtc_.save(&this->settings_); } +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); @@ -1259,8 +1385,10 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::SET_POINT, buffer, 2); } + ClimateSwingMode target_swing_mode = this->swing_mode; // Vertical swing mode if (climate_control.swing_mode.has_value()) { + target_swing_mode = climate_control.swing_mode.value(); uint8_t vertical_swing_buf[] = {0x00, (uint8_t) hon_protocol::VerticalSwingMode::AUTO}; uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) hon_protocol::HorizontalSwingMode::AUTO}; switch (climate_control.swing_mode.value()) { @@ -1286,6 +1414,32 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, vertical_swing_buf, 2); } + // A swing change above already sent both axes from settings_.last_*, which set_*_airflow() updated + const bool swing_sent = climate_control.swing_mode.has_value(); + if (this->pending_vertical_direction_.has_value()) { + if (vertical_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t vertical_swing_buf[] = {0x00, (uint8_t) this->pending_vertical_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, + vertical_swing_buf, 2); + } + this->pending_vertical_direction_.reset(); + } + if (this->pending_horizontal_direction_.has_value()) { + if (horizontal_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) this->pending_horizontal_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::HORIZONTAL_SWING_MODE, + horizontal_swing_buf, 2); + } + this->pending_horizontal_direction_.reset(); + } // Fan mode if (climate_control.fan_mode.has_value()) { switch (climate_control.fan_mode.value()) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a34b4422c69..db4ef4590bf 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #ifdef USE_SENSOR @@ -14,12 +15,36 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "haier_base.h" #include "hon_packet.h" namespace esphome::haier { +#ifdef USE_SELECT +// Positions in the order the select platform lists them; in flash on ESP8266, so read with airflow_order_at() +inline constexpr std::array VERTICAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::VerticalSwingMode::HEALTH_UP, hon_protocol::VerticalSwingMode::MAX_UP, + hon_protocol::VerticalSwingMode::UP, hon_protocol::VerticalSwingMode::CENTER, + hon_protocol::VerticalSwingMode::DOWN, hon_protocol::VerticalSwingMode::MAX_DOWN, + hon_protocol::VerticalSwingMode::HEALTH_DOWN, +}; +inline constexpr std::array HORIZONTAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::HorizontalSwingMode::MAX_LEFT, hon_protocol::HorizontalSwingMode::LEFT, + hon_protocol::HorizontalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::RIGHT, + hon_protocol::HorizontalSwingMode::MAX_RIGHT, +}; + +template Mode airflow_order_at(const std::array &order, size_t index) { + static_assert(sizeof(Mode) == 1); + return static_cast(progmem_read_byte(reinterpret_cast(order.data() + index))); +} +#endif + enum class CleaningState : uint8_t { NO_CLEANING = 0, SELF_CLEAN = 1, @@ -100,6 +125,16 @@ class HonClimate final : public HaierClimateBase { protected: switch_::Switch *beeper_switch_{nullptr}; switch_::Switch *quiet_mode_switch_{nullptr}; +#endif +#ifdef USE_SELECT + public: + void set_vertical_airflow_select(select::Select *sel) { this->vertical_airflow_select_ = sel; } + void set_horizontal_airflow_select(select::Select *sel) { this->horizontal_airflow_select_ = sel; } + + protected: + void publish_airflow_selects_(); + select::Select *vertical_airflow_select_{nullptr}; + select::Select *horizontal_airflow_select_{nullptr}; #endif public: HonClimate(); @@ -195,6 +230,9 @@ class HonClimate final : public HaierClimateBase { uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; + // millis() of the last user-set position per axis, 0 when none + uint32_t vertical_direction_set_time_{0}; + uint32_t horizontal_direction_set_time_{0}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; diff --git a/esphome/components/haier/select/__init__.py b/esphome/components/haier/select/__init__.py new file mode 100644 index 00000000000..4198ba82081 --- /dev/null +++ b/esphome/components/haier/select/__init__.py @@ -0,0 +1,67 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import ( + CONF_HAIER_ID, + CONF_HORIZONTAL_AIRFLOW, + CONF_VERTICAL_AIRFLOW, + HonClimate, + haier_ns, +) + +CODEOWNERS = ["@paveldn"] +VerticalAirflowSelect = haier_ns.class_("VerticalAirflowSelect", select.Select) +HorizontalAirflowSelect = haier_ns.class_("HorizontalAirflowSelect", select.Select) + +# Same order as VERTICAL_SWING_MODES_ORDER and HORIZONTAL_SWING_MODES_ORDER in hon_climate.h +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = [ + "Health Up", + "Max Up", + "Up", + "Center", + "Down", + "Max Down", + "Health Down", +] + +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = [ + "Max Left", + "Left", + "Center", + "Right", + "Max Right", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HAIER_ID): cv.use_id(HonClimate), + cv.Optional(CONF_VERTICAL_AIRFLOW): select.select_schema( + VerticalAirflowSelect, + icon="mdi:arrow-expand-vertical", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_HORIZONTAL_AIRFLOW): select.select_schema( + HorizontalAirflowSelect, + icon="mdi:arrow-expand-horizontal", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_AIRFLOW, + hub.set_vertical_airflow_select, + options=AIRFLOW_VERTICAL_DIRECTION_OPTIONS, + ) + await selects( + CONF_HORIZONTAL_AIRFLOW, + hub.set_horizontal_airflow_select, + options=AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS, + ) diff --git a/esphome/components/haier/select/horizontal_airflow.h b/esphome/components/haier/select/horizontal_airflow.h new file mode 100644 index 00000000000..54f46bfcb19 --- /dev/null +++ b/esphome/components/haier/select/horizontal_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class HorizontalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_horizontal_airflow(airflow_order_at(HORIZONTAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/esphome/components/haier/select/vertical_airflow.h b/esphome/components/haier/select/vertical_airflow.h new file mode 100644 index 00000000000..81ac1b39d2c --- /dev/null +++ b/esphome/components/haier/select/vertical_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class VerticalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_vertical_airflow(airflow_order_at(VERTICAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index ab39dfa61d5..2c0283215f6 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -121,3 +121,11 @@ switch: name: Haier health mode quiet_mode: name: Haier quiet mode + +select: + - platform: haier + haier_id: haier_ac + horizontal_airflow: + name: Haier airflow horizontal + vertical_airflow: + name: Haier airflow vertical From 34e1cf96d583156bebf26ecf8ca305962a04a36f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:58:56 -1000 Subject: [PATCH 49/63] [bmi270] Build the component on ESP8266 in CI (#20731) --- tests/components/bmi270/test.esp8266-ard.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/components/bmi270/test.esp8266-ard.yaml diff --git a/tests/components/bmi270/test.esp8266-ard.yaml b/tests/components/bmi270/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7163710d74b --- /dev/null +++ b/tests/components/bmi270/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bmi270: !include common.yaml From d9298cb7b91c3a238af6a1346b53d7b87ff8bf0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:59:27 -1000 Subject: [PATCH 50/63] [seeed_mr60bha2] Compile without sensors or binary sensors (#20729) --- esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp | 12 ++++++++++++ .../seeed_mr60bha2/common-binary-sensor.yaml | 9 +++++++++ tests/components/seeed_mr60bha2/common-minimal.yaml | 3 +++ .../seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml | 3 +++ .../seeed_mr60bha2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60bha2/test.esp32-idf.yaml | 3 +++ .../components/seeed_mr60bha2/test.esp8266-ard.yaml | 3 +++ 7 files changed, 36 insertions(+) create mode 100644 tests/components/seeed_mr60bha2/common-binary-sensor.yaml create mode 100644 tests/components/seeed_mr60bha2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index c5d12924583..e86909d0491 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -162,6 +162,7 @@ bool MR60BHA2Component::validate_message_() { void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, const uint8_t *data, size_t length) { switch (frame_type) { case BREATH_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->breath_rate_sensor_ != nullptr && length >= 4) { uint32_t current_breath_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_breath_rate_int != 0) { @@ -170,11 +171,14 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->breath_rate_sensor_->publish_state(breath_rate_float); } } +#endif break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->has_target_binary_sensor_ != nullptr && length >= 2) { uint16_t has_target_int = encode_uint16(data[1], data[0]); this->has_target_binary_sensor_->publish_state(has_target_int); +#ifdef USE_SENSOR if (has_target_int == 0) { if (this->breath_rate_sensor_ != nullptr) this->breath_rate_sensor_->publish_state(0.0); @@ -185,9 +189,12 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c if (this->num_targets_sensor_ != nullptr) this->num_targets_sensor_->publish_state(0); } +#endif } +#endif break; case HEART_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->heart_rate_sensor_ != nullptr && length >= 4) { uint32_t current_heart_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_heart_rate_int != 0) { @@ -196,8 +203,10 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->heart_rate_sensor_->publish_state(heart_rate_float); } } +#endif break; case DISTANCE_TYPE_BUFFER: +#ifdef USE_SENSOR if (length >= 1 && data[0] != 0) { if (this->distance_sensor_ != nullptr && length >= 8) { uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]); @@ -206,12 +215,15 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->distance_sensor_->publish_state(distance_float); } } +#endif break; case PRINT_CLOUD_BUFFER: +#ifdef USE_SENSOR if (this->num_targets_sensor_ != nullptr && length >= 4) { uint32_t current_num_targets_int = encode_uint32(data[3], data[2], data[1], data[0]); this->num_targets_sensor_->publish_state(current_num_targets_int); } +#endif break; default: break; diff --git a/tests/components/seeed_mr60bha2/common-binary-sensor.yaml b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml new file mode 100644 index 00000000000..b329cdbef90 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml @@ -0,0 +1,9 @@ +# The hub with only the presence binary sensor, which nests a USE_SENSOR guard in process_frame_() +seeed_mr60bha2: + id: my_seeed_mr60bha2_binary_sensor + +binary_sensor: + - platform: seeed_mr60bha2 + mr60bha2_id: my_seeed_mr60bha2_binary_sensor + has_target: + name: "Person Information Only" diff --git a/tests/components/seeed_mr60bha2/common-minimal.yaml b/tests/components/seeed_mr60bha2/common-minimal.yaml new file mode 100644 index 00000000000..571e5a57386 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the sensors and binary sensor that process_frame_() guards +seeed_mr60bha2: + id: my_seeed_mr60bha2_minimal diff --git a/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml new file mode 100644 index 00000000000..0f5c050ba73 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-binary-sensor.yaml diff --git a/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..a97c76ca583 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml new file mode 100644 index 00000000000..18801075447 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4f849c315e2 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60bha2: !include common.yaml From 66ea99ce65a3c7d6221c98139a6f2d21b69a414a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:00:08 -1000 Subject: [PATCH 51/63] [seeed_mr60fda2] Compile without binary sensors or selects (#20726) --- esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp | 8 ++++++++ tests/components/seeed_mr60fda2/common-minimal.yaml | 3 +++ .../components/seeed_mr60fda2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp8266-ard.yaml | 3 +++ 5 files changed, 20 insertions(+) create mode 100644 tests/components/seeed_mr60fda2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 332c59061b1..6b5fb087171 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -96,6 +96,7 @@ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_ return calculate_checksum(data, len) == expected_checksum; } +#ifdef USE_SELECT static uint8_t find_nearest_index(float value, const float *arr, int size) { int nearest_index = 0; float min_diff = std::abs(value - arr[0]); @@ -108,6 +109,7 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) { } return nearest_index; } +#endif /** * @brief Convert a float value to a byte array. @@ -250,15 +252,19 @@ void MR60FDA2Component::split_frame_(uint8_t buffer) { void MR60FDA2Component::process_frame_() { switch (this->current_frame_type_) { case IS_FALL_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->fall_detected_binary_sensor_ != nullptr) { this->fall_detected_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); } +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->people_exist_binary_sensor_ != nullptr) this->people_exist_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; @@ -293,6 +299,7 @@ void MR60FDA2Component::process_frame_() { float install_height_float = 0; float height_threshold_float = 0; uint32_t current_sensitivity = 0; +#ifdef USE_SELECT if (this->install_height_select_ != nullptr) { uint32_t current_install_height_int = encode_uint32(current_data_buf_[3], current_data_buf_[2], current_data_buf_[1], current_data_buf_[0]); @@ -318,6 +325,7 @@ void MR60FDA2Component::process_frame_() { uint32_t select_index = find_nearest_index(current_sensitivity, SENSITIVITY, 3); this->sensitivity_select_->publish_state(select_index); } +#endif ESP_LOGD(TAG, "Mounting height: %.2f, Height threshold: %.2f, Sensitivity: %" PRIu32, install_height_float, height_threshold_float, current_sensitivity); diff --git a/tests/components/seeed_mr60fda2/common-minimal.yaml b/tests/components/seeed_mr60fda2/common-minimal.yaml new file mode 100644 index 00000000000..da329aefbe3 --- /dev/null +++ b/tests/components/seeed_mr60fda2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the binary sensors and selects that process_frame_() guards +seeed_mr60fda2: + id: my_seeed_mr60fda2_minimal diff --git a/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..4338267485c --- /dev/null +++ b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml new file mode 100644 index 00000000000..07faf09a8df --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f059ca9f66f --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60fda2: !include common.yaml From d6abf35138be086d4b7f81685de8d49e06ddaf6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:10 -1000 Subject: [PATCH 52/63] [sx126x][sx127x] Stop the watchdog reset loop when a packet is sent before setup (#20727) --- esphome/components/sx126x/sx126x.cpp | 7 +++++++ esphome/components/sx126x/sx126x.h | 2 +- esphome/components/sx127x/sx127x.cpp | 7 +++++++ esphome/components/sx127x/sx127x.h | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6c1281c89b1..0bac0eb0466 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -1,4 +1,5 @@ #include "sx126x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -313,6 +314,11 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX126xError::SX126X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; @@ -333,6 +339,7 @@ SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio1_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > TRANSMIT_TIMEOUT_MS) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX126xError::TIMEOUT; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 536bb93c48f..aa275c9e972 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -48,7 +48,7 @@ enum SX126xBw : uint8_t { SX126X_BW_500000, }; -enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY }; class SX126xListener { public: diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 16c3d1dd21f..fb816094c29 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -1,4 +1,5 @@ #include "sx127x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -256,6 +257,11 @@ size_t SX127x::get_max_packet_size() { } SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX127xError::SX127X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; @@ -292,6 +298,7 @@ SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio0_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > 4000) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX127xError::TIMEOUT; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 27031d98b2b..547c52e7bb3 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -36,7 +36,7 @@ enum SX127xBw : uint8_t { SX127X_BW_500_0, }; -enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX127X_ERROR_NOT_READY }; class SX127xListener { public: From 25d4b88605566506212cacbf7ecb2f2d06c8a010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:59 -1000 Subject: [PATCH 53/63] [nfc] Add unit tests for the URI record prefix codes (#20724) --- tests/components/nfc/test_ndef_record_uri.cpp | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/components/nfc/test_ndef_record_uri.cpp diff --git a/tests/components/nfc/test_ndef_record_uri.cpp b/tests/components/nfc/test_ndef_record_uri.cpp new file mode 100644 index 00000000000..5629c23c9f6 --- /dev/null +++ b/tests/components/nfc/test_ndef_record_uri.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_record_uri.h" + +namespace esphome::nfc::testing { + +static std::string decode(const std::vector &payload) { return NdefRecordUri(payload).get_payload(); } + +static uint8_t encoded_prefix(const std::string &uri) { return NdefRecordUri(uri).get_encoded_payload()[0]; } + +TEST(NdefRecordUri, DecodeExpandsPrefixCode) { + EXPECT_EQ(decode({0x01, 'a', '.', 'b'}), "http://www.a.b"); + EXPECT_EQ(decode({0x04, 'a', '.', 'b'}), "https://a.b"); + EXPECT_EQ(decode({0x07, 'a'}), "ftp://anonymous:anonymous@a"); +} + +// URI identifier codes from the NFC Forum URI Record Type Definition, kept separate from the +// component's table so a reordered entry fails here +static constexpr const char *SPEC_PREFIXES[] = {"", + "http://www.", + "https://www.", + "http://", + "https://", + "tel:", + "mailto:", + "ftp://anonymous:anonymous@", + "ftp://ftp.", + "ftps://", + "sftp://", + "smb://", + "nfs://", + "ftp://", + "dav://", + "news:", + "telnet://", + "imap:", + "rtsp://", + "urn:", + "pop:", + "sip:", + "sips:", + "tftp:", + "btspp://", + "btl2cap://", + "btgoep://", + "tcpobex://", + "irdaobex://", + "file://", + "urn:epc:id:", + "urn:epc:tag:", + "urn:epc:pat:", + "urn:epc:raw:", + "urn:epc:", + "urn:nfc:"}; + +TEST(NdefRecordUri, DecodeEveryPrefixCode) { + for (size_t code = 1; code < std::size(SPEC_PREFIXES); code++) + EXPECT_EQ(decode({static_cast(code), 'x'}), std::string(SPEC_PREFIXES[code]) + "x") << "code " << code; +} + +TEST(NdefRecordUri, DecodeAcceptsLastPrefixCode) { EXPECT_EQ(decode({0x23, 'x'}), "urn:nfc:x"); } + +TEST(NdefRecordUri, DecodeIgnoresUnknownPrefixCode) { + EXPECT_EQ(decode({0x00, 'x'}), "x"); + EXPECT_EQ(decode({0x24, 'x'}), "x"); +} + +// Encode takes the first prefix in table order that matches, so codes 0x1E to 0x23 are never written; +// these tests pin that current behaviour, not the spec's longest match +TEST(NdefRecordUri, EncodePicksFirstMatchingPrefix) { + EXPECT_EQ(encoded_prefix("https://www.a.b"), 0x02); + EXPECT_EQ(encoded_prefix("http://a.b"), 0x03); + EXPECT_EQ(encoded_prefix("urn:epc:id:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStopsBeforeLastPrefixCode) { + // urn:nfc: (0x23) is never chosen; the shorter urn: (0x13) matches first + EXPECT_EQ(encoded_prefix("urn:nfc:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStripsPrefix) { + const std::vector expected = {0x02, 'a', '.', 'b'}; + EXPECT_EQ(NdefRecordUri("https://www.a.b").get_encoded_payload(), expected); +} + +TEST(NdefRecordUri, EncodeWithoutPrefix) { + EXPECT_EQ(encoded_prefix("ht"), 0x00); + const std::vector expected = {0x00, 'x', 'y'}; + EXPECT_EQ(NdefRecordUri("xy").get_encoded_payload(), expected); +} + +} // namespace esphome::nfc::testing From 10f42e3e7c79ba77015a4e332cf2bfda1148347b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:03:34 -1000 Subject: [PATCH 54/63] [shelly_dimmer] Build the STM32 firmware update path in CI (#20677) --- tests/components/shelly_dimmer/common-update.yaml | 15 +++++++++++++++ .../shelly_dimmer/test-update.esp8266-ard.yaml | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 tests/components/shelly_dimmer/common-update.yaml create mode 100644 tests/components/shelly_dimmer/test-update.esp8266-ard.yaml diff --git a/tests/components/shelly_dimmer/common-update.yaml b/tests/components/shelly_dimmer/common-update.yaml new file mode 100644 index 00000000000..babf55b2300 --- /dev/null +++ b/tests/components/shelly_dimmer/common-update.yaml @@ -0,0 +1,15 @@ +light: + - platform: shelly_dimmer + name: Shelly Dimmer Light + power: + name: Shelly Dimmer Power + voltage: + name: Shelly Dimmer Voltage + current: + name: Shelly Dimmer Current + max_brightness: 500 + firmware: + version: "51.6" + update: true + nrst_pin: 13 + boot0_pin: 14 diff --git a/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml new file mode 100644 index 00000000000..185686969df --- /dev/null +++ b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + shelly_dimmer: !include common-update.yaml From bd6a0c02415db929ba9a154b57264a287c95a228 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 09:56:23 -1000 Subject: [PATCH 55/63] [shelly_dimmer] Keep the STM32 device table in flash --- esphome/components/shelly_dimmer/dev_table.h | 188 +++++++++--------- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../components/shelly_dimmer/stm32flash.cpp | 37 ++-- esphome/components/shelly_dimmer/stm32flash.h | 17 +- 4 files changed, 131 insertions(+), 113 deletions(-) diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 32b4810d7a2..9f15c8b434f 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -21,6 +21,7 @@ #include "esphome/core/defines.h" #ifdef USE_SHD_FIRMWARE_DATA +#include "esphome/core/hal.h" #include "stm32flash.h" namespace esphome::shelly_dimmer { @@ -41,115 +42,124 @@ constexpr uint32_t SZ_256K = 0x00040000; */ /* fixed size pages */ -constexpr uint32_t p_128[] = {SZ_128, 0}; // NOLINT -constexpr uint32_t p_256[] = {SZ_256, 0}; // NOLINT -constexpr uint32_t p_1k[] = {SZ_1K, 0}; // NOLINT -constexpr uint32_t p_2k[] = {SZ_2K, 0}; // NOLINT +constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT +constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT +constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT +constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT /* F2 and F4 page size */ -constexpr uint32_t f2f4[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT +constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT /* F4 dual bank page size */ -constexpr uint32_t f4db[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT - SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; +constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT + SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; /* F7 page size */ -constexpr uint32_t f7[] = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT +constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT /* * Device table, corresponds to the "Bootloader device-dependant parameters" * table in ST document AN2606. * Note that the option bytes upper range is inclusive! */ -constexpr stm32_dev_t DEVICES[] = { - /* ID "name" SRAM-address-range FLASH-address-range PPS PSize +constexpr stm32_dev_t DEVICES[] PROGMEM = { + /* ID SRAM-address-range FLASH-address-range PPS PSize Option-byte-addr-range System-mem-addr-range Flags */ /* F0 */ - {0x440, "STM32F030x8/F05xxx", 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x442, "STM32F030xC/F09xxx", 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, F_OBLL}, - {0x444, "STM32F03xx4/6", 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x445, "STM32F04xxx/F070x6", 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC400, 0x1FFFF800, 0}, - {0x448, "STM32F070xB/F071xx/F72xx", 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, 0}, + // STM32F030x8/F05xxx + {0x440, 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F030xC/F09xxx + {0x442, 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, + F_OBLL}, + // STM32F03xx4/6 + {0x444, 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F04xxx/F070x6 + {0x445, 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC400, 0x1FFFF800, 0}, + // STM32F070xB/F071xx/F72xx + {0x448, 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, 0}, /* F1 */ - {0x412, "STM32F10xxx Low-density", 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x410, "STM32F10xxx Medium-density", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x414, "STM32F10xxx High-density", 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x420, "STM32F10xxx Medium-density VL", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x428, "STM32F10xxx High-density VL", 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x418, "STM32F105xx/F107xx", 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFB000, 0x1FFFF800, 0}, - {0x430, "STM32F10xxx XL-density", 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFE000, 0x1FFFF800, 0}, + // STM32F10xxx Low-density + {0x412, 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density + {0x410, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density + {0x414, 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density VL + {0x420, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density VL + {0x428, 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F105xx/F107xx + {0x418, 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFB000, 0x1FFFF800, 0}, + // STM32F10xxx XL-density + {0x430, 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFE000, 0x1FFFF800, 0}, /* F2 */ - {0x411, "STM32F2xxxx", 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F2xxxx + {0x411, 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F3 */ - {0x432, "STM32F373xx/F378xx", 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFD800, 0x1FFFF800, 0}, - {0x422, "STM32F302xB(C)/F303xB(C)/F358xx", 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x439, "STM32F301xx/F302x4(6/8)/F318xx", 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x438, "STM32F303x4(6/8)/F334xx/F328xx", 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x446, "STM32F302xD(E)/F303xD(E)/F398xx", 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F373xx/F378xx + {0x432, 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xB(C)/F303xB(C)/F358xx + {0x422, 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F301xx/F302x4(6/8)/F318xx + {0x439, 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F303x4(6/8)/F334xx/F328xx + {0x438, 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xD(E)/F303xD(E)/F398xx + {0x446, 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, /* F4 */ - {0x413, "STM32F40xxx/41xxx", 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x419, "STM32F42xxx/43xxx", 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x423, "STM32F401xB(C)", 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x433, "STM32F401xD(E)", 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x458, "STM32F410xx", 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x431, "STM32F411xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x421, "STM32F446xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x434, "STM32F469xx", 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F40xxx/41xxx + {0x413, 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F42xxx/43xxx + {0x419, 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xB(C) + {0x423, 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xD(E) + {0x433, 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F410xx + {0x458, 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F411xx + {0x431, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F446xx + {0x421, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F469xx + {0x434, 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F7 */ - {0x449, "STM32F74xxx/75xxx", 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, - 0x1FF00000, 0x1FF0EDC0, 0}, + // STM32F74xxx/75xxx + {0x449, 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, 0x1FF00000, 0x1FF0EDC0, 0}, /* L0 */ - {0x425, "STM32L031xx/041xx", 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x417, "STM32L05xxx/06xxx", 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x447, "STM32L07xxx/08xxx", 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, + // STM32L031xx/041xx + {0x425, 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L05xxx/06xxx + {0x417, 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L07xxx/08xxx + {0x447, 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, /* L1 */ - {0x416, "STM32L1xxx6(8/B)", 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, F_NO_ME}, - {0x429, "STM32L1xxx6(8/B)A", 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x427, "STM32L1xxxC", 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, - {0x436, "STM32L1xxxD", 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, 0}, - {0x437, "STM32L1xxxE", 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, F_NO_ME}, + // STM32L1xxx6(8/B) + {0x416, 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + F_NO_ME}, + // STM32L1xxx6(8/B)A + {0x429, 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L1xxxC + {0x427, 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxD + {0x436, 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxE + {0x437, 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + F_NO_ME}, /* L4 */ - {0x415, "STM32L476xx/486xx", 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, - 0x1FFF0000, 0x1FFF7000, 0}, + // STM32L476xx/486xx + {0x415, 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, 0x1FFF0000, 0x1FFF7000, 0}, /* These are not (yet) in AN2606: */ - {0x641, "Medium_Density PL", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x9a8, "STM32W-128K", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x9b0, "STM32W-256K", 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x0, "", 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, + // Medium_Density PL + {0x641, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32W-128K + {0x9a8, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + // STM32W-256K + {0x9b0, 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, }; } // namespace esphome::shelly_dimmer diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index eedf7ff7ce2..9ff913f46f4 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -176,7 +176,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint8_t buffer[BUFFER_SIZE]; const uint8_t *p = STM_FIRMWARE; uint32_t offset = 0; - uint32_t addr = stm32->dev->fl_start; + uint32_t addr = stm32->dev.fl_start; const uint32_t end = addr + STM_FIRMWARE_SIZE_IN_BYTES; while (addr < end && offset < STM_FIRMWARE_SIZE_IN_BYTES) { diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index c33876e4943..7e76aac60c7 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -116,18 +116,25 @@ namespace esphome::shelly_dimmer { namespace { +static uint32_t read_page_size(const uint32_t *psize) { + uint32_t size; + progmem_memcpy(&size, psize, sizeof(size)); + return size; +} + int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) + if (!(addr >= stm->dev.fl_start && addr <= stm->dev.fl_end)) return 0; int page = 0; - addr -= stm->dev->fl_start; - const auto *psize = stm->dev->fl_ps; + addr -= stm->dev.fl_start; + const auto *psize = stm->dev.fl_ps; - while (addr >= psize[0]) { - addr -= psize[0]; + uint32_t size; + while (addr >= (size = read_page_size(psize))) { + addr -= size; page++; - if (psize[1]) + if (read_page_size(psize + 1)) psize++; } @@ -637,14 +644,16 @@ stm32_unique_ptr stm32_init(uart::UARTDevice *stream, const uint8_t flags, const return make_stm32_with_deletor(nullptr); } - stm->dev = DEVICES; - while (stm->dev->id != 0x00 && stm->dev->id != stm->pid) - ++stm->dev; + const stm32_dev_t *dev = DEVICES; + uint16_t id; + while ((id = progmem_read_uint16(&dev->id)) != 0x00 && id != stm->pid) + ++dev; - if (!stm->dev->id) { + if (!id) { ESP_LOGD(TAG, "Unknown/unsupported device (Device ID: 0x%03x)", stm->pid); return make_stm32_with_deletor(nullptr); } + progmem_memcpy(&stm->dev, dev, sizeof(stm->dev)); return stm; } @@ -821,10 +830,10 @@ stm32_err_t stm32_erase_memory(const stm32_unique_ptr &stm, uint32_t spage, uint * a tentative of intrusion and will hang. * Erasing the flash page-by-page is the safer way to go. */ - if (!(stm->dev->flags & F_NO_ME)) + if (!(stm->dev.flags & F_NO_ME)) return stm32_mass_erase(stm); - pages = flash_addr_to_page_ceil(stm, stm->dev->fl_end); + pages = flash_addr_to_page_ceil(stm, stm->dev.fl_end); } /* @@ -913,9 +922,9 @@ stm32_err_t stm32_go(const stm32_unique_ptr &stm, const uint32_t address) { } stm32_err_t stm32_reset_device(const stm32_unique_ptr &stm) { - const auto target_address = stm->dev->ram_start; + const auto target_address = stm->dev.ram_start; - if (stm->dev->flags & F_OBLL) { + if (stm->dev.flags & F_OBLL) { /* set the OBL_LAUNCH bit to reset device (see RM0360, 2.5) */ return stm32_run_raw_code(stm, target_address, STM_OBL_LAUNCH_CODE, STM_OBL_LAUNCH_CODE_SIZE); } else { diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index 9a81f07373e..1c14d3790b2 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -73,15 +73,14 @@ using stm32_cmd_t = struct Stm32Cmd { }; using stm32_dev_t = struct Stm32Dev { // NOLINT - const uint16_t id; - const char *name; - const uint32_t ram_start, ram_end; - const uint32_t fl_start, fl_end; - const uint16_t fl_pps; // pages per sector + uint16_t id; + uint32_t ram_start, ram_end; + uint32_t fl_start, fl_end; + uint16_t fl_pps; // pages per sector const uint32_t *fl_ps; // page size - const uint32_t opt_start, opt_end; - const uint32_t mem_start, mem_end; - const uint32_t flags; + uint32_t opt_start, opt_end; + uint32_t mem_start, mem_end; + uint32_t flags; }; using stm32_t = struct Stm32 { @@ -93,7 +92,7 @@ using stm32_t = struct Stm32 { uint8_t option1, option2; uint16_t pid; stm32_cmd_t *cmd; - const stm32_dev_t *dev; + stm32_dev_t dev; // RAM copy of the PROGMEM DEVICES entry }; /* From 1e21b8a417f7febf8f373f60f428670f44d68241 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 10:16:22 -1000 Subject: [PATCH 56/63] Drop the redundant static inside the anonymous namespace --- esphome/components/shelly_dimmer/stm32flash.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index 7e76aac60c7..0dab7b75f9c 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -116,7 +116,7 @@ namespace esphome::shelly_dimmer { namespace { -static uint32_t read_page_size(const uint32_t *psize) { +uint32_t read_page_size(const uint32_t *psize) { uint32_t size; progmem_memcpy(&size, psize, sizeof(size)); return size; From c304871e73978ab2b97317420ca80cc288eb4fba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 10:27:10 -1000 Subject: [PATCH 57/63] Read the device table in place with word-only fields instead of copying an entry --- .../shelly_dimmer/shelly_dimmer.cpp | 2 +- .../components/shelly_dimmer/stm32flash.cpp | 37 +++++++------------ esphome/components/shelly_dimmer/stm32flash.h | 18 +++++---- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index 9ff913f46f4..eedf7ff7ce2 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -176,7 +176,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint8_t buffer[BUFFER_SIZE]; const uint8_t *p = STM_FIRMWARE; uint32_t offset = 0; - uint32_t addr = stm32->dev.fl_start; + uint32_t addr = stm32->dev->fl_start; const uint32_t end = addr + STM_FIRMWARE_SIZE_IN_BYTES; while (addr < end && offset < STM_FIRMWARE_SIZE_IN_BYTES) { diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index 0dab7b75f9c..c33876e4943 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -116,25 +116,18 @@ namespace esphome::shelly_dimmer { namespace { -uint32_t read_page_size(const uint32_t *psize) { - uint32_t size; - progmem_memcpy(&size, psize, sizeof(size)); - return size; -} - int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev.fl_start && addr <= stm->dev.fl_end)) + if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) return 0; int page = 0; - addr -= stm->dev.fl_start; - const auto *psize = stm->dev.fl_ps; + addr -= stm->dev->fl_start; + const auto *psize = stm->dev->fl_ps; - uint32_t size; - while (addr >= (size = read_page_size(psize))) { - addr -= size; + while (addr >= psize[0]) { + addr -= psize[0]; page++; - if (read_page_size(psize + 1)) + if (psize[1]) psize++; } @@ -644,16 +637,14 @@ stm32_unique_ptr stm32_init(uart::UARTDevice *stream, const uint8_t flags, const return make_stm32_with_deletor(nullptr); } - const stm32_dev_t *dev = DEVICES; - uint16_t id; - while ((id = progmem_read_uint16(&dev->id)) != 0x00 && id != stm->pid) - ++dev; + stm->dev = DEVICES; + while (stm->dev->id != 0x00 && stm->dev->id != stm->pid) + ++stm->dev; - if (!id) { + if (!stm->dev->id) { ESP_LOGD(TAG, "Unknown/unsupported device (Device ID: 0x%03x)", stm->pid); return make_stm32_with_deletor(nullptr); } - progmem_memcpy(&stm->dev, dev, sizeof(stm->dev)); return stm; } @@ -830,10 +821,10 @@ stm32_err_t stm32_erase_memory(const stm32_unique_ptr &stm, uint32_t spage, uint * a tentative of intrusion and will hang. * Erasing the flash page-by-page is the safer way to go. */ - if (!(stm->dev.flags & F_NO_ME)) + if (!(stm->dev->flags & F_NO_ME)) return stm32_mass_erase(stm); - pages = flash_addr_to_page_ceil(stm, stm->dev.fl_end); + pages = flash_addr_to_page_ceil(stm, stm->dev->fl_end); } /* @@ -922,9 +913,9 @@ stm32_err_t stm32_go(const stm32_unique_ptr &stm, const uint32_t address) { } stm32_err_t stm32_reset_device(const stm32_unique_ptr &stm) { - const auto target_address = stm->dev.ram_start; + const auto target_address = stm->dev->ram_start; - if (stm->dev.flags & F_OBLL) { + if (stm->dev->flags & F_OBLL) { /* set the OBL_LAUNCH bit to reset device (see RM0360, 2.5) */ return stm32_run_raw_code(stm, target_address, STM_OBL_LAUNCH_CODE, STM_OBL_LAUNCH_CODE_SIZE); } else { diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index 1c14d3790b2..9aa2e470808 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -72,16 +72,18 @@ using stm32_cmd_t = struct Stm32Cmd { uint8_t crc; }; +// Word-only fields: the table is in PROGMEM and aligned 32-bit loads from flash are safe on ESP8266 using stm32_dev_t = struct Stm32Dev { // NOLINT - uint16_t id; - uint32_t ram_start, ram_end; - uint32_t fl_start, fl_end; - uint16_t fl_pps; // pages per sector + const uint32_t id; + const uint32_t ram_start, ram_end; + const uint32_t fl_start, fl_end; + const uint32_t fl_pps; // pages per sector const uint32_t *fl_ps; // page size - uint32_t opt_start, opt_end; - uint32_t mem_start, mem_end; - uint32_t flags; + const uint32_t opt_start, opt_end; + const uint32_t mem_start, mem_end; + const uint32_t flags; }; +static_assert(sizeof(stm32_dev_t) == 12 * sizeof(uint32_t), "stm32_dev_t must stay word-only for flash reads"); using stm32_t = struct Stm32 { uart::UARTDevice *stream; @@ -92,7 +94,7 @@ using stm32_t = struct Stm32 { uint8_t option1, option2; uint16_t pid; stm32_cmd_t *cmd; - stm32_dev_t dev; // RAM copy of the PROGMEM DEVICES entry + const stm32_dev_t *dev; }; /* From 04216571e64092f0cf9997503fc5b7ec56b72cb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 18:41:20 -1000 Subject: [PATCH 58/63] Note why the page-size tables are read directly --- esphome/components/shelly_dimmer/dev_table.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 9f15c8b434f..96bb5ea1a68 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -39,6 +39,7 @@ constexpr uint32_t SZ_256K = 0x00040000; /* * Page-size for page-by-page flash erase. * Arrays are zero terminated; last non-zero value is automatically repeated + * In PROGMEM and read by direct indexing: each entry is an aligned 32-bit load, which is safe from flash on ESP8266 */ /* fixed size pages */ From d8cf0c6afbfd0b3ae6f5ada5c6d529712e6a808e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 00:29:45 -1000 Subject: [PATCH 59/63] Make the 4-byte alignment of the directly read flash tables explicit --- esphome/components/shelly_dimmer/dev_table.h | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 96bb5ea1a68..8dee068b607 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -43,24 +43,24 @@ constexpr uint32_t SZ_256K = 0x00040000; */ /* fixed size pages */ -constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT -constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT -constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT -constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT +alignas(4) constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT +alignas(4) constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT /* F2 and F4 page size */ -constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT +alignas(4) constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT /* F4 dual bank page size */ -constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT - SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; +alignas(4) constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT + SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; /* F7 page size */ -constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT +alignas(4) constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT /* * Device table, corresponds to the "Bootloader device-dependant parameters" * table in ST document AN2606. * Note that the option bytes upper range is inclusive! */ -constexpr stm32_dev_t DEVICES[] PROGMEM = { +alignas(4) constexpr stm32_dev_t DEVICES[] PROGMEM = { /* ID SRAM-address-range FLASH-address-range PPS PSize Option-byte-addr-range System-mem-addr-range Flags */ /* F0 */ From 9c568525b0229b319d5832ab17bdb18f7bd1fc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:08:41 -1000 Subject: [PATCH 60/63] [core] Add CodSpeed benchmarks for importing entity components (#20648) --- .../python/test_component_import_bench.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/benchmarks/python/test_component_import_bench.py diff --git a/tests/benchmarks/python/test_component_import_bench.py b/tests/benchmarks/python/test_component_import_bench.py new file mode 100644 index 00000000000..35981cb7ee4 --- /dev/null +++ b/tests/benchmarks/python/test_component_import_bench.py @@ -0,0 +1,105 @@ +"""CodSpeed benchmarks for importing entity components. + +Every ``esphome config`` or ``esphome compile`` imports the Python module of +each component the YAML uses. A component that imports its optional +integrations (mqtt, web_server, zigbee) at module scope drags their whole +import chains in for every user, so the cost of a fresh import is what these +benchmarks pin down. + +The core of esphome is imported once up front and stays loaded; each run +drops only the component modules from ``sys.modules`` and imports the +component again, so the import machinery and the module bodies of the +component and everything it pulls in run every time, as they do in a new +process, while a change to the core moves no number here. The modules and +registry entries the session had before a case are put back afterwards, so +later tests keep the objects they already hold. + +Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test +suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Generator, Iterable +import importlib +import sys + +import pytest + +pytest.importorskip("pytest_codspeed") + +# Load the core before the first case so no case pays for it. +from esphome import automation, pins # noqa: E402 +import esphome.codegen # noqa: E402, F401 +import esphome.config_validation # noqa: E402, F401 + +COMPONENTS = ("binary_sensor", "sensor", "logger") +COMPONENT_PACKAGE = "esphome.components" +COMPONENT_PREFIX = COMPONENT_PACKAGE + "." +# Core registries that component module bodies write to when they are imported +REGISTRIES = ( + automation.ACTION_REGISTRY, + automation.CONDITION_REGISTRY, + pins.PIN_SCHEMA_REGISTRY, +) + + +def _component_modules() -> dict[str, object]: + # The package itself goes too: ``from esphome.components import x`` is + # answered from its attributes, so a stale package would hide the import. + return { + n: m + for n, m in sys.modules.items() + if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX) + } + + +def _forget_component_modules() -> None: + for name in _component_modules(): + del sys.modules[name] + + +def _import_component(name: str) -> object: + _forget_component_modules() + return importlib.import_module(COMPONENT_PREFIX + name) + + +_MISSING = object() + + +def _parent_bindings(names: Iterable[str]) -> list[tuple[object, str, object]]: + # An import binds the module to its parent package; a package may hold something else + # under that name (substitutions keeps a Jinja() instance as ``jinja``), so the value + # the parent has now is what goes back, not the module. + bindings = [] + for name in names: + parent, _, child = name.rpartition(".") + bindings.append( + (sys.modules[parent], child, getattr(sys.modules[parent], child, _MISSING)) + ) + return bindings + + +@pytest.fixture +def restore_component_modules() -> Generator[None]: + saved = _component_modules() + saved_bindings = _parent_bindings(saved) + saved_registries = [dict(registry) for registry in REGISTRIES] + yield + _forget_component_modules() + sys.modules.update(saved) + for parent, child, value in saved_bindings: + if value is _MISSING: + delattr(parent, child) + else: + setattr(parent, child, value) + # The re-imported bodies registered their actions, conditions and pin schemas again + for registry, entries in zip(REGISTRIES, saved_registries, strict=True): + registry.clear() + registry.update(entries) + + +@pytest.mark.usefixtures("restore_component_modules") +@pytest.mark.parametrize("component", COMPONENTS) +def test_import_component(benchmark, component: str) -> None: + benchmark(_import_component, component) From 7d7a78308d0259f07747e33e5a08de7b39b520fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:02 -0400 Subject: [PATCH 61/63] [ci] Test tinyusb_keyboard in isolation (#20749) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index f7680f105ca..fee1438636b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -98,6 +98,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } From 0132f722a359e41297297a003c149725897f50ed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 13:24:36 -0400 Subject: [PATCH 62/63] [audio] Bump esp-audio-libs to v4.1.0 (#20754) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 332638e75bb..36a9161eb45 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -338,7 +338,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") + add_idf_component(name="esphome/esp-audio-libs", ref="4.1.0") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 2ef48602d1b..87ec5cdcfc4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 2.2.0 esphome/esp-audio-libs: - version: 4.0.1 + version: 4.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 9fe75e262f629d6692553d017efe94afcbb19c34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:09:53 -0400 Subject: [PATCH 63/63] [ci] Add a host clang-tidy environment and job (#20747) --- .github/workflows/ci.yml | 26 +++++++++--- esphome/components/host/core.cpp | 4 +- esphome/components/host/hal.cpp | 2 +- esphome/components/host/helpers.cpp | 4 +- esphome/components/host/preferences.cpp | 8 ++-- .../http_request/http_request_host.cpp | 4 +- esphome/components/ota/ota_backend_host.cpp | 34 +++++++-------- .../prometheus/prometheus_handler.cpp | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/sdl/sdl_esphome.cpp | 6 +-- esphome/components/sdl/sdl_esphome.h | 4 +- esphome/components/snapshot/gif.cpp | 6 +-- esphome/components/snapshot/snapshot.cpp | 2 +- .../components/uart/uart_component_host.cpp | 7 ++-- esphome/components/uart/uart_component_host.h | 6 ++- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/core/defines.h | 8 +++- platformio.ini | 17 ++++++++ script/clang-tidy | 42 +++++++++++++++---- 20 files changed, 125 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68bb93ec9f..04df45d7df7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,8 @@ jobs: - name: Install apt packages (cached) uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 determine-jobs: name: Determine which jobs to run @@ -415,8 +415,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -695,6 +695,10 @@ jobs: name: Run script/clang-tidy for RP2 options: --environment rp2-tidy --grep USE_RP2 pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for host + options: --environment host-tidy --grep USE_HOST + pio_cache_key: tidyhost - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy @@ -707,6 +711,16 @@ jobs: with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 + - name: Install apt packages (cached) + if: matrix.pio_cache_key == 'tidyhost' + # libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev + # by dsmr and dlms_meter. Packages and version must match seed-apt-cache + # exactly. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Restore Python id: restore-python @@ -1102,8 +1116,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index f753534efd9..2cc455e3f45 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,8 +59,8 @@ namespace esphome::host { char **get_argv() { return s_argv; } const std::string &get_exe_path() { - static const std::string empty; - return s_exe_path != nullptr ? *s_exe_path : empty; + static const std::string EMPTY; + return s_exe_path != nullptr ? *s_exe_path : EMPTY; } void arm_reexec(const std::string &path) { diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 9108c1ea9d3..caee316a53e 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" #include "core.h" -#include +#include #include #include #include diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 9e203efcb9c..f8b7a0c5f08 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,8 +39,8 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; - memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); + static constexpr uint8_t HOST_MAC_ADDRESS[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; + memcpy(mac, HOST_MAC_ADDRESS, sizeof(HOST_MAC_ADDRESS)); } } // namespace esphome diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 9a8208e96ea..0f6b03ff0b5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "preferences.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -45,11 +46,10 @@ void HostPreferences::setup_() { break; if (fread(&len, sizeof(len), 1, fp) != 1) break; - uint8_t data[len]; - if (fread(data, sizeof(uint8_t), len, fp) != len) + std::vector data(len); + if (fread(data.data(), sizeof(uint8_t), len, fp) != len) break; - std::vector vec(data, data + len); - this->data_[key] = vec; + this->data_[key] = std::move(data); } fclose(fp); } diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 0130445acdc..1856b97e6e9 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -53,7 +53,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c for (const auto &[name, value] : request_headers) { h_headers.emplace(name, value); } - httplib::Client client(scheme_host.c_str()); + httplib::Client client(scheme_host); if (!client.is_valid()) { ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; @@ -114,7 +114,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c } container->content_length = container->response_body_.size(); - for (auto header : response.headers) { + for (const auto &header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 923b0afe18a..765d6a04c08 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -32,7 +32,7 @@ ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; -ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { +ssize_t read_header(const char *path, uint8_t *buf, size_t len) { int fd = ::open(path, O_RDONLY); if (fd < 0) return -1; @@ -50,7 +50,7 @@ struct ElfIdent { uint16_t e_type; }; -ElfIdent parse_elf_(const uint8_t *buf, size_t len) { +ElfIdent parse_elf(const uint8_t *buf, size_t len) { ElfIdent out{}; if (len < EI_NIDENT + 4) return out; @@ -76,17 +76,17 @@ ElfIdent parse_elf_(const uint8_t *buf, size_t len) { return out; } -bool validate_elf_(const char *staging_path, const std::string &exe_path) { +bool validate_elf(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { ESP_LOGE(TAG, "ELF header read failed"); return false; } - ElfIdent new_id = parse_elf_(new_buf, new_n); - ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + ElfIdent new_id = parse_elf(new_buf, new_n); + ElfIdent cur_id = parse_elf(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); return false; @@ -122,7 +122,7 @@ struct MachOIdent { uint32_t cpusubtype; }; -MachOIdent parse_macho_(const uint8_t *buf, size_t len) { +MachOIdent parse_macho(const uint8_t *buf, size_t len) { MachOIdent out{}; // mach_header is the common prefix of mach_header and mach_header_64; // cputype/cpusubtype/filetype have identical offsets in both. @@ -153,18 +153,18 @@ MachOIdent parse_macho_(const uint8_t *buf, size_t len) { return out; } -bool validate_macho_(const char *staging_path, const std::string &exe_path) { +bool validate_macho(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(sizeof(struct mach_header)) || cur_n < static_cast(sizeof(struct mach_header))) { ESP_LOGE(TAG, "Mach-O header read failed"); return false; } - MachOIdent new_id = parse_macho_(new_buf, new_n); - MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + MachOIdent new_id = parse_macho(new_buf, new_n); + MachOIdent cur_id = parse_macho(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); return false; @@ -182,11 +182,11 @@ bool validate_macho_(const char *staging_path, const std::string &exe_path) { } #endif // __APPLE__ -bool validate_executable_(const char *staging_path, const std::string &exe_path) { +bool validate_executable(const char *staging_path, const std::string &exe_path) { #ifdef __linux__ - return validate_elf_(staging_path, exe_path); + return validate_elf(staging_path, exe_path); #elif defined(__APPLE__) - return validate_macho_(staging_path, exe_path); + return validate_macho(staging_path, exe_path); #else (void) staging_path; (void) exe_path; @@ -298,7 +298,7 @@ OTAResponseTypes HostOTABackend::end() { ::close(this->fd_); this->fd_ = -1; - if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + if (!validate_executable(this->staging_path_.c_str(), this->final_path_)) { ::unlink(this->staging_path_.c_str()); this->staging_path_.clear(); return OTA_RESPONSE_ERROR_UPDATE_END; diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 00c2e043102..b2bd7bd9bd7 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -1,5 +1,5 @@ #include "prometheus_handler.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_HOST) #include "esphome/core/application.h" namespace esphome::prometheus { diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 9dcbf7e6869..ae5bbcae74a 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index a764b74581f..5dd25ec2bb8 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -157,7 +157,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; - auto data = ptr + (stride * y_offset + x_offset) * 2; + const auto *data = ptr + (stride * y_offset + x_offset) * 2; SDL_UpdateTexture(this->texture_, &rect, data, stride * 2); } this->redraw_(rect); @@ -199,7 +199,7 @@ void Sdl::process_key(uint32_t keycode, bool down) { callback->second(down); } -Sdl *Sdl::instance_for_window_(uint32_t window_id) { +Sdl *Sdl::instance_for_window(uint32_t window_id) { SDL_Window *window = SDL_GetWindowFromID(window_id); if (window == nullptr) return nullptr; @@ -293,7 +293,7 @@ void Sdl::loop() { continue; } - Sdl *target = instance_for_window_(window_id); + Sdl *target = instance_for_window(window_id); if (target == nullptr) { // Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise // input that stops working leaves no trace at all. diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index c58de643cc8..c927004e1f6 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -42,7 +42,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { float get_setup_priority() const override { return setup_priority::HARDWARE; } void dump_config() override { LOG_DISPLAY("", "SDL", this); } template void add_key_listener(int32_t keycode, F &&callback) { - if (!this->key_callbacks_.count(keycode)) { + if (!this->key_callbacks_.contains(keycode)) { this->key_callbacks_[keycode] = CallbackManager(); } this->key_callbacks_[keycode].add(std::forward(callback)); @@ -66,7 +66,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool capture_bgr(uint8_t *dest, size_t row_stride) override; void handle_event_(const SDL_Event &event); /// The display owning the given window, or nullptr if it is not one of ours. - static Sdl *instance_for_window_(uint32_t window_id); + static Sdl *instance_for_window(uint32_t window_id); SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp index 5650c1c404b..73a16b896e2 100644 --- a/esphome/components/snapshot/gif.cpp +++ b/esphome/components/snapshot/gif.cpp @@ -50,9 +50,9 @@ struct BitWriter { uint32_t buffer{0}; unsigned count{0}; - void put(uint32_t code, unsigned size) { - this->buffer |= code << this->count; - this->count += size; + void put(uint32_t value, unsigned bits) { + this->buffer |= value << this->count; + this->count += bits; while (this->count >= 8) { this->out.push_back(static_cast(this->buffer & 0xFF)); this->buffer >>= 8; diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 58406f17ffd..fb99154cc06 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -55,7 +55,7 @@ std::string sanitise_filename(const char *const name, const char *extension, boo break; } char c = *p; - if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) { + if (!std::isalnum(static_cast(c)) && c != '.' && c != '_' && c != '-') { c = '_'; changed = true; } diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 7fb704536dd..833ccc425d3 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -9,11 +9,11 @@ #error This HostUartComponent implementation is not supported on this host OS #endif -#include -#include +#include +#include +#include #include #include -#include #include #include @@ -210,7 +210,6 @@ void HostUartComponent::write_array(const uint8_t *data, size_t len) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); } #endif - return; } bool HostUartComponent::peek_byte(uint8_t *data) { diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index bca62debf10..27869e969c4 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -2,6 +2,8 @@ #ifdef USE_HOST +#include + #include "esphome/core/component.h" #include "esphome/core/log.h" #include "uart_component.h" @@ -10,7 +12,7 @@ namespace esphome::uart { class HostUartComponent final : public UARTComponent, public Component { public: - virtual ~HostUartComponent(); + ~HostUartComponent(); void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -19,7 +21,7 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; - void set_name(std::string port_name) { port_name_ = port_name; }; + void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 873c5b5a497..50144b6df73 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 72d3bf75b1c..38098971f4f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include "esphome/core/progmem.h" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 234160fc290..f589155c97f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -331,7 +331,7 @@ #define USE_NOISE #define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 -#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 +#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_WIREGUARD @@ -420,7 +420,9 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 +#ifndef USE_HOST // web_server, captive_portal: no ESPAsyncWebServer on host #define USE_CAPTIVE_PORTAL +#endif #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 @@ -478,6 +480,7 @@ #define USE_SPI #define USE_SPI_PSRAM_DMA #define USE_VOICE_ASSISTANT +#ifndef USE_HOST #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST @@ -489,6 +492,7 @@ #define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP +#endif #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH @@ -691,6 +695,8 @@ #endif #ifdef USE_HOST +#define USE_ESPHOME_HOST_MAC_ADDRESS \ + { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART diff --git a/platformio.ini b/platformio.ini index 96f3d25b6eb..9e05356665a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -654,6 +654,23 @@ build_flags = build_unflags = ${common.build_unflags} +[env:host-tidy] +extends = env:host +; every library a header under esphome/ may include: a missing one is a +; fatal error that breaks the all-include analysis for every later header +lib_deps = + ${common.lib_deps} + ${common:idf-component-libs.lib_deps} + ${env:host.lib_deps} +build_flags = + ${env:host.build_flags} + ${flags:clangtidy.build_flags} + ; analyze with the same language model as the embedded targets + -fno-exceptions + -fno-rtti + -I/usr/include/SDL2 ; sdl + -D__LINUX__ ; runtime_image: JPEGDEC's host detection, see its __init__.py + ;;;;;;;; nRF52 ;;;;;;;; [env:nrf52] diff --git a/script/clang-tidy b/script/clang-tidy index a2becb59cc8..3f923403a38 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -68,6 +68,9 @@ def clang_options(idedata, environment): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] + # env:host compiles with the system g++, so there is no triplet and the + # host's own target and headers are the right ones. + native = environment.startswith("host") if triplet.startswith("xtensa-"): # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF # toolchain headers (xtruntime, xtensa/config) need the GCC core config @@ -83,6 +86,8 @@ def clang_options(idedata, environment): cmd.append("-D__XTENSA__") cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") + elif native: + cmd.append("-Qunused-arguments") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so # compile for the actual triplet. Espressif's RISC-V GCC -march adds @@ -118,7 +123,7 @@ def clang_options(idedata, environment): "-mfp16-format=ieee", "--param=min-pagesize=0", ) - else: + elif not native: cmd.extend( [ # disable built-in include directories from the host @@ -127,10 +132,11 @@ def clang_options(idedata, environment): ) # set flags + if not native: + # disable built-in include directories from the host + cmd.append("-nostdinc") cmd.extend( [ - # disable built-in include directories from the host - "-nostdinc", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers @@ -314,11 +320,17 @@ def run_tidy( if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. + disabled_checks.append("-clang-analyzer-core.FixedAddressDereference") + if args.environment.startswith("host"): + # glibc's assert() expands to a call the check mistakes for a + # constant condition. + disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"] + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")): # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher - # recursion on lvgl_esphome.h under the RP2 defines. + # recursion on lvgl_esphome.h under the RP2 defines and on the + # all-include file under the host defines. disabled_checks += [ - "-clang-analyzer-core.FixedAddressDereference", "-bugprone-pointer-arithmetic-on-polymorphic-object", "-cert-ctr56-cpp", ] @@ -327,6 +339,8 @@ def run_tidy( if disabled_checks: invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + # Vendored third-party headers are kept as upstream wrote them. + invocation.append("--exclude-header-filter=.*/http_request/httplib\\.h") invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -448,6 +462,11 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + # Not every tidy environment carries googletest (LibreTiny does not), + # so a platform grep that matched a unit test would fail on + # . The tests are analyzed by the full ESP32 IDF run, + # which has no --grep. + files = [f for f in files if not f.startswith("tests/")] if args.exclude_grep: excluded = set(filter_grep(files, args.exclude_grep)) @@ -464,11 +483,16 @@ def main(): # Add header file for checking (before early exit check) if args.all_headers and args.split_at in (None, 1): + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + # The path filter applies to headers too: a component left out because + # it does not build in this environment must not come back via the + # all-include file. + if args.files: + all_headers = [p for p in all_headers if file_name_re.search(p)] # When --changed is used, only include changed headers instead of all headers if args.changed: - all_headers = [ - os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) - ] changed_headers = filter_changed(all_headers) if changed_headers: build_all_include(changed_headers) @@ -476,7 +500,7 @@ def main(): else: print("No changed headers to check") else: - build_all_include() + build_all_include(all_headers) files.insert(0, temp_header_file) print(f"Added all-include header file, new total: {len(files)}")