From 2cd7c0eb36e98188894be0a48c400668f9ab31d1 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:31:16 +0000 Subject: [PATCH 001/111] [husb238] Add HUSB238 USB PD controller hub with binary sensors (#16572) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/husb238/__init__.py | 33 ++++++++++++ esphome/components/husb238/binary_sensor.py | 38 ++++++++++++++ esphome/components/husb238/husb238.cpp | 51 +++++++++++++++++++ esphome/components/husb238/husb238.h | 28 ++++++++++ tests/components/husb238/common.yaml | 11 ++++ tests/components/husb238/test.esp32-idf.yaml | 3 ++ .../components/husb238/test.esp8266-ard.yaml | 3 ++ 8 files changed, 168 insertions(+) create mode 100644 esphome/components/husb238/__init__.py create mode 100644 esphome/components/husb238/binary_sensor.py create mode 100644 esphome/components/husb238/husb238.cpp create mode 100644 esphome/components/husb238/husb238.h create mode 100644 tests/components/husb238/common.yaml create mode 100644 tests/components/husb238/test.esp32-idf.yaml create mode 100644 tests/components/husb238/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b45816640ed..168c5430806 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -266,6 +266,7 @@ esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering esphome/components/hub75/* @stuartparmenter +esphome/components/husb238/* @latonita esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py new file mode 100644 index 00000000000..92bf8c998ff --- /dev/null +++ b/esphome/components/husb238/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "husb238" +MULTI_CONF = True + +CONF_HUSB238_ID = "husb238_id" + +husb238_ns = cg.esphome_ns.namespace("husb238") +Husb238Component = husb238_ns.class_( + "Husb238Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Husb238Component), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x08)) +) + + +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) diff --git a/esphome/components/husb238/binary_sensor.py b/esphome/components/husb238/binary_sensor.py new file mode 100644 index 00000000000..78d5d78a9d6 --- /dev/null +++ b/esphome/components/husb238/binary_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, DEVICE_CLASS_PLUG, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_HUSB238_ID, Husb238Component + +DEPENDENCIES = ["husb238"] + +CONF_ATTACHED = "attached" +CONF_CC_DIRECTION = "cc_direction" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HUSB238_ID): cv.use_id(Husb238Component), + cv.Optional(CONF_ATTACHED): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + key=CONF_NAME, + ), + cv.Optional(CONF_CC_DIRECTION): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HUSB238_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_ATTACHED, hub.set_attached_binary_sensor) + await sensors(CONF_CC_DIRECTION, hub.set_cc_direction_binary_sensor) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp new file mode 100644 index 00000000000..8ca841a7e43 --- /dev/null +++ b/esphome/components/husb238/husb238.cpp @@ -0,0 +1,51 @@ +#include "husb238.h" +#include "esphome/core/log.h" + +namespace esphome::husb238 { + +ESPHOME_LOG_TAG(TAG, "husb238"); + +void Husb238Component::setup() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void Husb238Component::update() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + +#ifdef USE_BINARY_SENSOR + if (this->attached_binary_sensor_ != nullptr) { + this->attached_binary_sensor_->publish_state((status & PD_STATUS1_ATTACHED) != 0); + } + if (this->cc_direction_binary_sensor_ != nullptr) { + // The CC line only means something while a source is attached + if (status & PD_STATUS1_ATTACHED) { + this->cc_direction_binary_sensor_->publish_state((status & PD_STATUS1_CC_DIR) != 0); + } else { + this->cc_direction_binary_sensor_->invalidate_state(); + } + } +#endif +} + +void Husb238Component::dump_config() { + ESP_LOGCONFIG(TAG, "HUSB238:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "PD Attached", this->attached_binary_sensor_); + LOG_BINARY_SENSOR(" ", "CC Direction", this->cc_direction_binary_sensor_); +#endif +} + +} // namespace esphome::husb238 diff --git a/esphome/components/husb238/husb238.h b/esphome/components/husb238/husb238.h new file mode 100644 index 00000000000..5a3513db026 --- /dev/null +++ b/esphome/components/husb238/husb238.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::husb238 { + +static constexpr uint8_t REG_PD_STATUS1 = 0x01; +static constexpr uint8_t PD_STATUS1_ATTACHED = 1 << 6; +static constexpr uint8_t PD_STATUS1_CC_DIR = 1 << 7; + +class Husb238Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(attached) + SUB_BINARY_SENSOR(cc_direction) +#endif +}; + +} // namespace esphome::husb238 diff --git a/tests/components/husb238/common.yaml b/tests/components/husb238/common.yaml new file mode 100644 index 00000000000..94e055acf85 --- /dev/null +++ b/tests/components/husb238/common.yaml @@ -0,0 +1,11 @@ +husb238: + id: husb238_hub + i2c_id: i2c_bus + +binary_sensor: + - platform: husb238 + husb238_id: husb238_hub + attached: "PD Attached" + cc_direction: + name: "CC Direction" + entity_category: diagnostic diff --git a/tests/components/husb238/test.esp32-idf.yaml b/tests/components/husb238/test.esp32-idf.yaml new file mode 100644 index 00000000000..a2286c6e515 --- /dev/null +++ b/tests/components/husb238/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + husb238: !include common.yaml diff --git a/tests/components/husb238/test.esp8266-ard.yaml b/tests/components/husb238/test.esp8266-ard.yaml new file mode 100644 index 00000000000..103b71b6628 --- /dev/null +++ b/tests/components/husb238/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + husb238: !include common.yaml From 5a819f4b4807c4468f5c7557c56a52a00c8915a5 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Mon, 12 Oct 2026 21:34:04 +0000 Subject: [PATCH 002/111] [bh1745] Add BH1745 colour sensor (#6349) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bh1745/__init__.py | 75 ++++++++ esphome/components/bh1745/bh1745.cpp | 182 ++++++++++++++++++ esphome/components/bh1745/bh1745.h | 61 ++++++ esphome/components/bh1745/output/__init__.py | 28 +++ .../components/bh1745/output/bh1745_output.h | 16 ++ esphome/components/bh1745/sensor.py | 66 +++++++ tests/components/bh1745/common.yaml | 27 +++ tests/components/bh1745/test.esp32-idf.yaml | 3 + tests/components/bh1745/test.esp8266-ard.yaml | 3 + 10 files changed, 462 insertions(+) create mode 100644 esphome/components/bh1745/__init__.py create mode 100644 esphome/components/bh1745/bh1745.cpp create mode 100644 esphome/components/bh1745/bh1745.h create mode 100644 esphome/components/bh1745/output/__init__.py create mode 100644 esphome/components/bh1745/output/bh1745_output.h create mode 100644 esphome/components/bh1745/sensor.py create mode 100644 tests/components/bh1745/common.yaml create mode 100644 tests/components/bh1745/test.esp32-idf.yaml create mode 100644 tests/components/bh1745/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 168c5430806..2d3f8af71d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -65,6 +65,7 @@ esphome/components/bedjet/climate/* @jhansche esphome/components/bedjet/fan/* @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/beken_spi_led_strip/* @Mat931 +esphome/components/bh1745/* @latonita esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py new file mode 100644 index 00000000000..bd63fd4ef2d --- /dev/null +++ b/esphome/components/bh1745/__init__.py @@ -0,0 +1,75 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_INTEGRATION_TIME, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bh1745" +MULTI_CONF = True + +CONF_BH1745_ID = "bh1745_id" + +bh1745_ns = cg.esphome_ns.namespace("bh1745") + +BH1745Component = bh1745_ns.class_( + "BH1745Component", cg.PollingComponent, i2c.I2CDevice +) + +AdcGain = bh1745_ns.enum("AdcGain", is_class=True) +ADC_GAINS = { + "1X": AdcGain.ADC_GAIN_1X, + "2X": AdcGain.ADC_GAIN_2X, + "16X": AdcGain.ADC_GAIN_16X, +} + +MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) +MEASUREMENT_TIMES = { + 160: MeasurementTime.MEASUREMENT_TIME_160MS, + 320: MeasurementTime.MEASUREMENT_TIME_320MS, + 640: MeasurementTime.MEASUREMENT_TIME_640MS, + 1280: MeasurementTime.MEASUREMENT_TIME_1280MS, + 2560: MeasurementTime.MEASUREMENT_TIME_2560MS, + 5120: MeasurementTime.MEASUREMENT_TIME_5120MS, +} + + +def validate_measurement_time(value: Any) -> Any: + value = cv.positive_time_period_milliseconds(value).total_milliseconds + return cv.enum(MEASUREMENT_TIMES, int=True)(value) + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BH1745Component), + cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True), + cv.Optional( + CONF_INTEGRATION_TIME, default="160ms" + ): validate_measurement_time, + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x38)) +) + + +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) + + cg.add(var.set_adc_gain(config[CONF_GAIN])) + cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) + cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp new file mode 100644 index 00000000000..24744c3330d --- /dev/null +++ b/esphome/components/bh1745/bh1745.cpp @@ -0,0 +1,182 @@ +#include "bh1745.h" +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bh1745 { + +ESPHOME_LOG_TAG(TAG, "bh1745"); + +static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40; +static constexpr uint8_t REG_MODE_CONTROL1 = 0x41; +static constexpr uint8_t REG_MODE_CONTROL2 = 0x42; +static constexpr uint8_t REG_MODE_CONTROL3 = 0x44; +static constexpr uint8_t REG_RED_DATA_LSB = 0x50; +static constexpr uint8_t REG_INTERRUPT = 0x60; +static constexpr uint8_t REG_TH_LSB = 0x62; +static constexpr uint8_t REG_MANUFACTURER_ID = 0x92; + +static constexpr uint8_t MANUFACTURER_ID = 0xE0; +static constexpr uint8_t PART_ID = 0b001011; +static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F; +static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80; +static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10; +static constexpr uint8_t MODE_CONTROL2_VALID = 0x80; +static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02 +static constexpr uint8_t INTERRUPT_ENABLE = 0x01; + +static constexpr uint8_t BH1745_CHANNELS = 4; +static constexpr uint32_t RESET_DELAY_MS = 100; +static constexpr uint32_t BASE_MEAS_TIME_MS = 160; + +static uint32_t get_measurement_time_ms(MeasurementTime time) { + return BASE_MEAS_TIME_MS << static_cast(time); +} + +static uint8_t get_adc_gain(AdcGain gain) { + return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast(gain); +} + +static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) { + if (green == 0) + return 0.0f; + float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green; + return lx * scale; +} + +static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) { + if (green == 0) + return 0.0f; + float all = static_cast(static_cast(red) + green + blue); + float r_ratio = red / all; + float b_ratio = blue / all; + float ct; + if (low_clear_ratio) { + float b_eff = std::fmin(b_ratio * 3.13f, 1.0f); + ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio)); + } else { + float b_eff = std::fmin(b_ratio * 10.67f, 1.0f); + ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio)); + } + return clamp_at_most(ct, 10000.0f); +} + +void BH1745Component::setup() { + uint8_t manufacturer_id; + if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manufacturer_id != MANUFACTURER_ID) { + this->mark_failed(LOG_STR("Wrong manufacturer ID")); + return; + } + uint8_t system_control; + if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || + (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { + this->mark_failed(LOG_STR("Wrong part ID")); + return; + } + if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Light lost behind glass is scaled back up, as the other light sensors do + this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ / + (get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_)); + + this->set_timeout(RESET_DELAY_MS, [this]() { + if (this->interrupt_output_) { + // High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always + // true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs. + static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF}; + if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + // RGBC_EN makes the chip measure continuously; update() reads the latest result. + if (!this->write_byte(REG_MODE_CONTROL1, static_cast(this->measurement_time_)) || + !this->write_byte(REG_MODE_CONTROL2, static_cast(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) || + !this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->busy_ = false; + }); +} + +void BH1745Component::dump_config() { + ESP_LOGCONFIG(TAG, + "BH1745:\n" + " Gain: %ux\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f", + get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_), + this->glass_attenuation_factor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_); + LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_); + LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_); + LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_); +} + +void BH1745Component::update() { + if (this->busy_) + return; + + // Reading MODE_CONTROL2 clears VALID, so read it only once per poll. + uint8_t mode_control2; + if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((mode_control2 & MODE_CONTROL2_VALID) == 0) + return; + + uint8_t buffer[BH1745_CHANNELS * 2]; + if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + // Each channel is sent low byte first. + const uint16_t red = encode_uint16(buffer[1], buffer[0]); + const uint16_t green = encode_uint16(buffer[3], buffer[2]); + const uint16_t blue = encode_uint16(buffer[5], buffer[4]); + const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); + + if (this->red_counts_sensor_ != nullptr) + this->red_counts_sensor_->publish_state(red); + if (this->green_counts_sensor_ != nullptr) + this->green_counts_sensor_->publish_state(green); + if (this->blue_counts_sensor_ != nullptr) + this->blue_counts_sensor_->publish_state(blue); + if (this->clear_counts_sensor_ != nullptr) + this->clear_counts_sensor_->publish_state(clear); + + // The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25). + const bool low_clear_ratio = static_cast(clear) * 25 < static_cast(green) * 4; + if (this->illuminance_sensor_ != nullptr) + this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_)); + if (this->color_temperature_sensor_ != nullptr) + this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio)); +} + +void BH1745Component::set_interrupt_state(bool on) { + if (this->is_failed()) + return; + if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) { + this->status_set_warning(); + } +} + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h new file mode 100644 index 00000000000..06d0987d67d --- /dev/null +++ b/esphome/components/bh1745/bh1745.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bh1745 { + +enum class MeasurementTime : uint8_t { + MEASUREMENT_TIME_160MS = 0b000, + MEASUREMENT_TIME_320MS = 0b001, + MEASUREMENT_TIME_640MS = 0b010, + MEASUREMENT_TIME_1280MS = 0b011, + MEASUREMENT_TIME_2560MS = 0b100, + MEASUREMENT_TIME_5120MS = 0b101, +}; + +enum class AdcGain : uint8_t { + ADC_GAIN_1X = 0, + ADC_GAIN_2X = 1, + ADC_GAIN_16X = 2, +}; + +class BH1745Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; } + void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; } + void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; } + void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; } + void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; } + void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; } + void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; } + + /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. + void enable_interrupt_output() { this->interrupt_output_ = true; } + void set_interrupt_state(bool on); + + protected: + sensor::Sensor *red_counts_sensor_{nullptr}; + sensor::Sensor *green_counts_sensor_{nullptr}; + sensor::Sensor *blue_counts_sensor_{nullptr}; + sensor::Sensor *clear_counts_sensor_{nullptr}; + sensor::Sensor *illuminance_sensor_{nullptr}; + sensor::Sensor *color_temperature_sensor_{nullptr}; + + float glass_attenuation_factor_{1.0f}; + float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() + MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; + AdcGain adc_gain_{AdcGain::ADC_GAIN_1X}; + bool busy_{true}; // cleared once the chip is configured after the reset + bool interrupt_output_{false}; +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output/__init__.py new file mode 100644 index 00000000000..6c5da0396bd --- /dev/null +++ b/esphome/components/bh1745/output/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns + +DEPENDENCIES = ["bh1745"] + +BH1745InterruptPinOutput = bh1745_ns.class_( + "BH1745InterruptPinOutput", output.BinaryOutput +) + +CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput), + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + hub = await cg.get_variable(config[CONF_BH1745_ID]) + await cg.register_parented(var, hub) + cg.add(hub.enable_interrupt_output()) diff --git a/esphome/components/bh1745/output/bh1745_output.h b/esphome/components/bh1745/output/bh1745_output.h new file mode 100644 index 00000000000..c6f30304bd0 --- /dev/null +++ b/esphome/components/bh1745/output/bh1745_output.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esphome/components/output/binary_output.h" +#include "esphome/core/helpers.h" + +#include "../bh1745.h" + +namespace esphome::bh1745 { + +/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one. +class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented { + protected: + void write_state(bool state) override { this->parent_->set_interrupt_state(state); } +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/sensor.py b/esphome/components/bh1745/sensor.py new file mode 100644 index 00000000000..6c904466250 --- /dev/null +++ b/esphome/components/bh1745/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, + UNIT_COUNTS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +from . import CONF_BH1745_ID, BH1745Component + +DEPENDENCIES = ["bh1745"] + +color_channel_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_LIGHTBULB, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BH1745_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor) + await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor) + await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor) + await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/tests/components/bh1745/common.yaml b/tests/components/bh1745/common.yaml new file mode 100644 index 00000000000..bad906653a1 --- /dev/null +++ b/tests/components/bh1745/common.yaml @@ -0,0 +1,27 @@ +bh1745: + id: bh1745_hub + i2c_id: i2c_bus + gain: 16X + integration_time: 320ms + glass_attenuation_factor: 2.0 + +sensor: + - platform: bh1745 + bh1745_id: bh1745_hub + red_channel: + name: Red channel + green_channel: + name: Green channel + blue_channel: + name: Blue channel + clear_channel: + name: Clear channel + illuminance: + name: Illuminance + color_temperature: + name: Color temperature + +output: + - platform: bh1745 + id: bh1745_output + bh1745_id: bh1745_hub diff --git a/tests/components/bh1745/test.esp32-idf.yaml b/tests/components/bh1745/test.esp32-idf.yaml new file mode 100644 index 00000000000..f7aff8bedd9 --- /dev/null +++ b/tests/components/bh1745/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bh1745: !include common.yaml diff --git a/tests/components/bh1745/test.esp8266-ard.yaml b/tests/components/bh1745/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7132464c436 --- /dev/null +++ b/tests/components/bh1745/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bh1745: !include common.yaml From 93183c661ec4cbafa37a0eefdf059fb42fd3f155 Mon Sep 17 00:00:00 2001 From: gledian Date: Mon, 12 Oct 2026 23:36:27 +0200 Subject: [PATCH 003/111] [ecocomfort2] Add component for Fantini Cosmi VMC units via BLE (Part 1: hub + fan) (#17092) Co-authored-by: gledian Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ecocomfort2/__init__.py | 72 ++++++++ .../ecocomfort2/ecocomfort2_child.h | 20 +++ .../ecocomfort2/ecocomfort2_fan.cpp | 146 +++++++++++++++++ .../components/ecocomfort2/ecocomfort2_fan.h | 26 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 154 ++++++++++++++++++ .../components/ecocomfort2/ecocomfort2_hub.h | 76 +++++++++ esphome/components/ecocomfort2/fan.py | 23 +++ esphome/core/defines.h | 1 + tests/components/ecocomfort2/common.yaml | 24 +++ .../ecocomfort2/test.esp32-idf.yaml | 3 + 11 files changed, 546 insertions(+) create mode 100644 esphome/components/ecocomfort2/__init__.py create mode 100644 esphome/components/ecocomfort2/ecocomfort2_child.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_fan.h create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.cpp create mode 100644 esphome/components/ecocomfort2/ecocomfort2_hub.h create mode 100644 esphome/components/ecocomfort2/fan.py create mode 100644 tests/components/ecocomfort2/common.yaml create mode 100644 tests/components/ecocomfort2/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2d3f8af71d0..9de83996659 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,7 @@ esphome/components/ds248x/* @tomwellnitz esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov +esphome/components/ecocomfort2/* @gledian esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 00000000000..472ac8f7cf8 --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import ble_client, esp32_ble +from esphome.components.esp32_ble import ( + CONF_AUTH_REQ_MODE, + CONF_IO_CAPABILITY, + CONF_MAX_KEY_SIZE, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +DOMAIN = "ecocomfort2" +CONF_ECOCOMFORT2_ID = "ecocomfort2_id" + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ble_client"] +MULTI_CONF = True + +ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN) +Ecocomfort2Hub = ecocomfort2_ns.class_( + "Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + +ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub), + } +) + +_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT") + + +async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + _request_child_slot(str(parent)) + cg.add(parent.register_child(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) + + +def _final_validate(config: ConfigType) -> ConfigType: + # The unit only talks over an encrypted, bonded link + ble_conf = fv.full_config.get()[esp32_ble.DOMAIN] + if ( + ble_conf[CONF_IO_CAPABILITY] != "none" + or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond" + or ble_conf.get(CONF_MAX_KEY_SIZE) != 16 + ): + raise cv.Invalid( + "ecocomfort2 needs esp32_ble configured with io_capability: none, " + "auth_req_mode: sc_bond and max_key_size: 16" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 00000000000..b6d2fc87cb4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Hub; + +class Ecocomfort2Client : public Parented { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 00000000000..c9d64892c48 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan"); + +static constexpr int SPEED_COUNT = SPEED_VEL3; + +struct Preset { + const char *name; + uint8_t mode; + bool auto_mode; +}; + +// Order matters: the fan core persists the preset as an index into this list. +static constexpr Preset PRESETS[] = { + {"In", OPER_IN, false}, + {"Out", OPER_OUT, false}, + {"In-Out", OPER_IN_OUT, false}, + {"Sensor", OPER_SENSOR_OR_AUTO, false}, + {"Auto", OPER_SENSOR_OR_AUTO, true}, +}; +static constexpr const Preset &PRESET_IN_OUT = PRESETS[2]; +static constexpr const Preset &PRESET_SENSOR = PRESETS[3]; + +static const Preset *find_preset(const char *name) { + for (const auto &preset : PRESETS) { + if (preset.name == name) + return &preset; + } + return nullptr; +} + +static const Preset *find_preset(uint8_t mode, bool auto_mode) { + for (const auto &preset : PRESETS) { + if (preset.mode == mode && preset.auto_mode == auto_mode) + return &preset; + } + return nullptr; +} + +void Ecocomfort2Fan::setup() { + this->set_supported_preset_modes( + {PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name}); + // Restore the last preset and speed so turning on resumes them; on/off comes from the device. + auto restore = this->restore_state_(); + if (restore.has_value()) { + this->speed = restore->speed; + if (restore->preset_mode < std::size(PRESETS)) + this->set_preset_mode_(PRESETS[restore->preset_mode].name); + } +} + +void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); } + +fan::FanTraits Ecocomfort2Fan::get_traits() { + fan::FanTraits traits; + traits.set_speed(true); + traits.set_supported_speed_count(SPEED_COUNT); + this->wire_preset_modes_(traits); + return traits; +} + +void Ecocomfort2Fan::control(const fan::FanCall &call) { + if (!this->parent_->is_ready()) { + ESP_LOGW(TAG, "Not ready, cannot handle control call"); + return; + } + + // Any call that does not turn the fan off turns it on + auto state = call.get_state(); + this->state = !state.has_value() || *state; + // The core clears the preset in a speed call; here the preset is the air flow mode, so keep it. + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); + + const Preset *preset = find_preset(this->get_preset_mode().c_str()); + if (preset == nullptr) { + preset = &PRESET_IN_OUT; + this->set_preset_mode_(preset->name); + } + auto speed = call.get_speed(); + if (speed.has_value()) { + this->speed = *speed; + // A manual speed leaves automatic speed control + if (preset->auto_mode) { + preset = &PRESET_SENSOR; + this->set_preset_mode_(preset->name); + } + } + this->speed = clamp(this->speed, 1, SPEED_COUNT); + + if (!this->state) { + this->parent_->write_oper(OPER_OFF, SPEED_OFF); + } else { + this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast(this->speed)); + } + this->publish_state(); +} + +void Ecocomfort2Fan::on_status() { + uint8_t mode = this->parent_->get_mode(); + uint8_t raw_speed = this->parent_->get_speed(); + + // Off keeps the preset and speed so turning on resumes them + if (mode == OPER_OFF) { + if (!this->state) + return; + this->state = false; + this->publish_state(); + return; + } + + int new_speed = this->speed; + uint8_t base_speed = raw_speed & SPEED_MASK; + if (raw_speed & FLAG_BOOST) { + new_speed = SPEED_COUNT; + } else if (raw_speed & FLAG_NIGHT) { + new_speed = SPEED_SLEEP; + } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + new_speed = base_speed; + } + const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); + const char *new_preset = preset != nullptr ? preset->name : nullptr; + bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; + if (this->state && this->speed == new_speed && same_preset) + return; + + this->state = true; + this->speed = new_speed; + if (new_preset != nullptr) { + this->set_preset_mode_(new_preset); + } else { + this->clear_preset_mode_(); + } + this->publish_state(); +} + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 00000000000..81af55699fe --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/fan/fan.h" +#include "ecocomfort2_child.h" +#include "ecocomfort2_hub.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component { + public: + void setup() override; + void dump_config() override; + fan::FanTraits get_traits() override; + void on_status() override; + + protected: + void control(const fan::FanCall &call) override; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 00000000000..a20fdd9112c --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,154 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2"); + +static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); +static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + +void Ecocomfort2Hub::update() { + if (!this->is_ready()) { + ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); + return; + } + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + } +} + +void Ecocomfort2Hub::dump_config() { + ESP_LOGCONFIG(TAG, + "Ecocomfort2 Hub:\n" + " Address: %s", + this->parent_->address_str()); + LOG_UPDATE_INTERVAL(this); +} + +void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason); + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->cancel_timeout(READBACK_TIMEOUT_ID); + this->status_set_warning(); + this->char_handle_oper_ = 0; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. + auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); + if (chr == nullptr) { + ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->char_handle_oper_ = chr->handle; + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_set_warning(); + + // A bonded device may already have re-encrypted the link before discovery finished. + if (this->parent_->is_paired()) { + this->on_paired_(); + break; + } + ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str()); + if (this->parent_->pair() != ESP_OK) { + ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + }); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + break; + } + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); + break; + } + if (param->read.value_len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); + break; + } + this->mode_ = param->read.value[0]; + this->speed_ = param->read.value[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif + break; + } + default: + break; + } +} + +void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || + this->node_state != espbt::ClientState::ESTABLISHED) { + return; + } + if (!param->ble_security.auth_cmpl.success) { + this->cancel_timeout(AUTH_TIMEOUT_ID); + ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(), + param->ble_security.auth_cmpl.fail_reason); + this->parent_->disconnect(); + return; + } + this->on_paired_(); +} + +void Ecocomfort2Hub::on_paired_() { + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->status_clear_warning(); + ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->update(); +} + +void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { + if (!this->is_ready()) { + ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); + return; + } + uint8_t data[2]{mode, speed}; + ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); + return; + } + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); +} + +#ifdef ECOCOMFORT2_CHILD_COUNT +void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) { + this->children_.push_back(obj); + obj->set_parent(this); +} +#endif + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 00000000000..5a609d9283e --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "ecocomfort2_child.h" + +namespace esphome::ecocomfort2 { + +namespace espbt = esphome::esp32_ble_tracker; + +// C_OPER byte 0: operating mode +static constexpr uint8_t OPER_OFF = 0x00; +static constexpr uint8_t OPER_IN = 0x01; +static constexpr uint8_t OPER_OUT = 0x02; +static constexpr uint8_t OPER_IN_OUT = 0x03; +static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; + +// C_OPER byte 1: speed in the low nibble, flags in the high nibble +static constexpr uint8_t SPEED_OFF = 0x00; +static constexpr uint8_t SPEED_SLEEP = 0x01; +static constexpr uint8_t SPEED_VEL1 = 0x02; +static constexpr uint8_t SPEED_VEL2 = 0x03; +static constexpr uint8_t SPEED_VEL3 = 0x04; +static constexpr uint8_t SPEED_MASK = 0x0F; +static constexpr uint8_t FLAG_AUTO = 0x10; +static constexpr uint8_t FLAG_BOOST = 0x40; +static constexpr uint8_t FLAG_NIGHT = 0x80; + +/** Hub component for Ecocomfort 2.0 VMC via BLE. */ +class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { + public: + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BLUETOOTH; } + + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + +#ifdef ECOCOMFORT2_CHILD_COUNT + void register_child(Ecocomfort2Client *obj); +#endif + + /// True once the link is established and paired. + bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } + + /// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after. + void write_oper(uint8_t mode, uint8_t speed); + + /// Raw C_OPER values from the last readback. + uint8_t get_mode() const { return this->mode_; } + uint8_t get_speed() const { return this->speed_; } + + protected: + static constexpr uint32_t AUTH_TIMEOUT_ID = 0; + static constexpr uint32_t READBACK_TIMEOUT_ID = 1; + static constexpr uint32_t AUTH_TIMEOUT_MS = 10000; + static constexpr uint32_t READBACK_DELAY_MS = 300; + + void on_paired_(); + +#ifdef ECOCOMFORT2_CHILD_COUNT + StaticVector children_; +#endif + uint16_t char_handle_oper_{0}; + uint8_t mode_{OPER_OFF}; + uint8_t speed_{SPEED_OFF}; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 00000000000..e3dd8e09386 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = ( + fan.fan_schema(Ecocomfort2Fan) + .extend(cv.COMPONENT_SCHEMA) + .extend(ECOCOMFORT2_CLIENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) + await register_ecocomfort2_child(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 943458e3f64..a085cf9c0ac 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -430,6 +430,7 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml new file mode 100644 index 00000000000..b4951e804f8 --- /dev/null +++ b/tests/components/ecocomfort2/common.yaml @@ -0,0 +1,24 @@ +esp32_ble: + io_capability: none + auth_req_mode: sc_bond + max_key_size: 16 + +ble_client: + - mac_address: 01:02:03:04:05:06 + id: ecocomfort2_ble_1 + - mac_address: 01:02:03:04:05:07 + id: ecocomfort2_ble_2 + +ecocomfort2: + - id: ecocomfort2_hub_1 + ble_client_id: ecocomfort2_ble_1 + - id: ecocomfort2_hub_2 + ble_client_id: ecocomfort2_ble_2 + +fan: + - platform: ecocomfort2 + name: Ecocomfort2 Fan 1 + ecocomfort2_id: ecocomfort2_hub_1 + - platform: ecocomfort2 + name: Ecocomfort2 Fan 2 + ecocomfort2_id: ecocomfort2_hub_2 diff --git a/tests/components/ecocomfort2/test.esp32-idf.yaml b/tests/components/ecocomfort2/test.esp32-idf.yaml new file mode 100644 index 00000000000..956cd9c8135 --- /dev/null +++ b/tests/components/ecocomfort2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + ecocomfort2: !include common.yaml From 584d4878ebafc8807ab56d7752d241dcc282b709 Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 17:44:33 -0400 Subject: [PATCH 004/111] [lis3dh] Add LIS3DH accelerometer component (#17393) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/lis3dh/__init__.py | 15 ++ esphome/components/lis3dh/lis3dh.cpp | 195 ++++++++++++++++ esphome/components/lis3dh/lis3dh.h | 135 +++++++++++ esphome/components/lis3dh/motion.py | 214 ++++++++++++++++++ esphome/components/lis3dh/sensor.py | 41 ++++ tests/component_tests/lis3dh/__init__.py | 0 tests/component_tests/lis3dh/test_motion.py | 147 ++++++++++++ tests/components/lis3dh/common.yaml | 67 ++++++ tests/components/lis3dh/test.esp32-idf.yaml | 3 + tests/components/lis3dh/test.esp8266-ard.yaml | 3 + 11 files changed, 821 insertions(+) create mode 100644 esphome/components/lis3dh/__init__.py create mode 100644 esphome/components/lis3dh/lis3dh.cpp create mode 100644 esphome/components/lis3dh/lis3dh.h create mode 100644 esphome/components/lis3dh/motion.py create mode 100644 esphome/components/lis3dh/sensor.py create mode 100644 tests/component_tests/lis3dh/__init__.py create mode 100644 tests/component_tests/lis3dh/test_motion.py create mode 100644 tests/components/lis3dh/common.yaml create mode 100644 tests/components/lis3dh/test.esp32-idf.yaml create mode 100644 tests/components/lis3dh/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9de83996659..8f1271b76a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -320,6 +320,7 @@ esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lis2dw12/* @latonita esphome/components/lis2dw12_i2c/* @latonita esphome/components/lis2dw12_spi/* @latonita +esphome/components/lis3dh/* @zebble esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b diff --git a/esphome/components/lis3dh/__init__.py b/esphome/components/lis3dh/__init__.py new file mode 100644 index 00000000000..99c4ee91879 --- /dev/null +++ b/esphome/components/lis3dh/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "lis3dh" + +CONF_LIS3DH_ID = "lis3dh_id" + +# C++ namespace / class +lis3dh_ns = cg.esphome_ns.namespace("lis3dh") +LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp new file mode 100644 index 00000000000..22ca7ae56b2 --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -0,0 +1,195 @@ +#include "lis3dh.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis3dh { + +ESPHOME_LOG_TAG(TAG, "lis3dh"); + +// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every +// operating mode, since the chip leaves the unused low bits at zero. +static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f}; + +static const LogString *mode_to_str(LIS3DHOperatingMode mode) { + switch (mode) { + case LIS3DH_MODE_LOW_POWER: + return LOG_STR("low power (8-bit)"); + case LIS3DH_MODE_NORMAL: + return LOG_STR("normal (10-bit)"); + default: + return LOG_STR("high resolution (12-bit)"); + } +} + +// Output data rate in Hz, indexed by the ODR field minus one. +static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344}; + +void LIS3DHComponent::setup() { + MotionComponent::setup(); + + uint8_t who_am_i = 0; + if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { + ESP_LOGE(TAG, "Failed to configure sensor"); + this->mark_failed(); + return; + } +} + +bool LIS3DHComponent::setup_accelerometer_() { + // CTRL_REG1: output data rate, low-power flag and axis enable. + uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN; + if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER) + ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN; + if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1)) + return false; + + // CTRL_REG4: block data update, full-scale range and high-resolution flag. + uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4); + if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION) + ctrl_reg4 |= LIS3DH_CTRL_REG4_HR; + return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4); +} + +bool LIS3DHComponent::setup_temperature_() { + // Only power the ADC / temperature sensor when a temperature sensor is used. + uint8_t temp_cfg = + this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN); + return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg); +} + +bool LIS3DHComponent::setup_interrupt_() { + // Runs even with no interrupt configured: the chip usually stays powered across resets, + // so this drops whatever the previous firmware left in the interrupt registers. + // High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed + // from the threshold comparison. The output data registers stay unfiltered + // (FDS = 0), so acceleration / pitch / roll still report the full gravity vector. + uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00; + if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2)) + return false; + + if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) || + !this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) || + !this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_)) + return false; + + // Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read. + uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0; + if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5)) + return false; + + // Route the IA1 engine to the chosen physical pad and set the pad polarity. + uint8_t ctrl_reg3 = 0; + uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY; + if (this->interrupt_axes_cfg_ != 0) { + if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) { + ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1; + } else { + ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1; + } + } + if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6)) + return false; + if (this->interrupt_axes_cfg_ == 0) + return true; + + // With the high-pass filter enabled, reading REFERENCE captures the current + // acceleration as the filter's zero point (removing the standing gravity bias). + if (this->interrupt_high_pass_) { + uint8_t reference = 0; + this->read_byte(LIS3DH_REG_REFERENCE, &reference); + } + + // Clear any latched request left over from the motion that woke the device. + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + return true; +} + +void LIS3DHComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LIS3DH:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, + " Range: ±%ug\n" + " Operating mode: %s\n" + " Data rate: %uHz", + 2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)), + (unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1])); + if (this->interrupt_axes_cfg_ != 0) { + ESP_LOGCONFIG(TAG, + " Motion interrupt: %s (%s%s%s)\n" + " Threshold: %u steps\n" + " Duration: %u samples", + this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"), + this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"), + this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""), + this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""), + this->interrupt_threshold_, this->interrupt_duration_); + } + MotionComponent::dump_config(); +} + +void LIS3DHComponent::on_shutdown() { + // Clear a latched request from motion seen while awake, so it does not wake the device straight away. + if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) { + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + } +} + +bool LIS3DHComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + uint8_t raw[6]; + if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + this->status_set_warning(LOG_STR("Failed to read acceleration data")); + return false; + } + + float scale; + progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); + + // Data is little-endian and left-justified in a signed 16-bit register. + int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); + int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); + int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty() || this->publish_temperature_()) + this->status_clear_warning(); + return true; +} + +bool LIS3DHComponent::publish_temperature_() { + uint8_t raw[2]; + if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + this->status_set_warning(LOG_STR("Failed to read temperature data")); + return false; + } + // Signed, left-justified, relative value with 1 °C per count of the high byte; the + // low byte carries the fractional bits in the modes that have them. + float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/lis3dh.h b/esphome/components/lis3dh/lis3dh.h new file mode 100644 index 00000000000..15c19933c7b --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.h @@ -0,0 +1,135 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::lis3dh { + +// Register map (see ST datasheet DocID18198) +static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte +static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F; +static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F; +static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable +static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter +static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing +static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution +static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching +static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity +static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference +static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment) +static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30; +static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31; +static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32; +static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33; + +static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33; + +// Sub-address auto-increment bit: OR into the register for multi-byte reads. +static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80; + +// CTRL_REG1 +static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen +static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable +// CTRL_REG2 +static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt +// CTRL_REG4 +static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable +static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update +// CTRL_REG5 +static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt +// CTRL_REG3 / CTRL_REG6 interrupt routing +static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low +// TEMP_CFG_REG +static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80; +static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40; + +// The temperature sensor is uncalibrated and reports relative changes only, +// so this fixed reference is just a rough starting point for the °C output. +static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f; + +// Full-scale range: value is the index into the scale tables and the +// FS field of CTRL_REG4 (shifted left by 4). +enum LIS3DHRange : uint8_t { + LIS3DH_RANGE_2G = 0, + LIS3DH_RANGE_4G = 1, + LIS3DH_RANGE_8G = 2, + LIS3DH_RANGE_16G = 3, +}; + +// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4). +enum LIS3DHDataRate : uint8_t { + LIS3DH_DATA_RATE_1HZ = 0x1, + LIS3DH_DATA_RATE_10HZ = 0x2, + LIS3DH_DATA_RATE_25HZ = 0x3, + LIS3DH_DATA_RATE_50HZ = 0x4, + LIS3DH_DATA_RATE_100HZ = 0x5, + LIS3DH_DATA_RATE_200HZ = 0x6, + LIS3DH_DATA_RATE_400HZ = 0x7, + LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only + LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power +}; + +// Operating mode: selects the resolution (and thus power usage). +enum LIS3DHOperatingMode : uint8_t { + LIS3DH_MODE_LOW_POWER = 0, // 8-bit + LIS3DH_MODE_NORMAL = 1, // 10-bit + LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit +}; + +// Physical interrupt pad the motion interrupt is routed to. +enum LIS3DHInterruptPin : uint8_t { + LIS3DH_INT_PIN_INT1 = 0, + LIS3DH_INT_PIN_INT2 = 1, +}; + +class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + + void set_range(LIS3DHRange range) { this->range_ = range; } + void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; } + void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; } + // threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python. + void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched, + bool active_high, bool high_pass) { + this->interrupt_pin_ = pin; + this->interrupt_threshold_ = threshold; + this->interrupt_duration_ = duration; + this->interrupt_axes_cfg_ = axes_cfg; + this->interrupt_latched_ = latched; + this->interrupt_active_high_ = active_high; + this->interrupt_high_pass_ = high_pass; + } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool setup_accelerometer_(); + bool setup_temperature_(); + bool setup_interrupt_(); + bool publish_temperature_(); + + LIS3DHRange range_{LIS3DH_RANGE_2G}; + LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ}; + LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION}; + + // Motion (activity) interrupt configuration; a zero axis mask means disabled. + LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1}; + uint8_t interrupt_threshold_{0}; + uint8_t interrupt_duration_{0}; + uint8_t interrupt_axes_cfg_{0}; + bool interrupt_latched_{false}; + bool interrupt_active_high_{true}; + bool interrupt_high_pass_{false}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py new file mode 100644 index 00000000000..ea4b4d3b81a --- /dev/null +++ b/esphome/components/lis3dh/motion.py @@ -0,0 +1,214 @@ +import logging +import math + +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.motion import AXES, motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType + +from . import LIS3DHComponent, lis3dh_ns + +_LOGGER = logging.getLogger(__name__) + +CONF_OPERATING_MODE = "operating_mode" +CONF_AXES = "axes" +CONF_LATCHED = "latched" +CONF_ACTIVE_HIGH = "active_high" +CONF_HIGH_PASS_FILTER = "high_pass_filter" + +# Enum values must match the C++ enums. +LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") +RANGE_OPTIONS = { + "2G": LIS3DHRange.LIS3DH_RANGE_2G, + "4G": LIS3DHRange.LIS3DH_RANGE_4G, + "8G": LIS3DHRange.LIS3DH_RANGE_8G, + "16G": LIS3DHRange.LIS3DH_RANGE_16G, +} + +LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") +DATA_RATE_OPTIONS = { + "1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ, + "10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ, + "25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ, + "50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ, + "100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ, + "200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ, + "400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ, + # 1620HZ is only available in low-power mode. + "1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ, + # 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode. + "1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ, +} + +# Samples per second for each data rate key; INT1_DURATION counts samples. +ODR_HZ = { + "1HZ": 1, + "10HZ": 10, + "25HZ": 25, + "50HZ": 50, + "100HZ": 100, + "200HZ": 200, + "400HZ": 400, + "1620HZ": 1620, + "1344HZ": 1344, +} +DURATION_MAX_REG = 0x7F + +LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") +OPERATING_MODE_OPTIONS = { + "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, + "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, + "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, +} + +LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") +INTERRUPT_PIN_OPTIONS = { + "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, + "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, +} + +# INT1_CFG high-event bit per axis. +INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} + +# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits. +THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186} +THRESHOLD_MAX_REG = 0x7F + + +def _threshold_register(threshold_g: float, range_key: str) -> int: + """Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted.""" + return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1) + + +def _duration_register(duration_ms: int, odr_key: str) -> int: + """Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate.""" + return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) + + +def _axes(value: object) -> list[str]: + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + +# Without high_pass_filter the threshold includes gravity, so it should exceed 1g. +INTERRUPT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PIN, default="INT1"): cv.enum( + INTERRUPT_PIN_OPTIONS, upper=True + ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( + cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float + ), + cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_LATCHED, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, + } +) + + +def _validate_odr_mode(config: ConfigType) -> ConfigType: + # 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there. + odr = config[CONF_ACCELEROMETER_ODR] + low_power = config[CONF_OPERATING_MODE] == "LOW_POWER" + if odr == "1620HZ" and not low_power: + raise cv.Invalid( + "'1620HZ' is only available with 'operating_mode: LOW_POWER'", + path=[CONF_ACCELEROMETER_ODR], + ) + if odr == "1344HZ" and low_power: + raise cv.Invalid( + "'1344HZ' is not available with 'operating_mode: LOW_POWER' " + "(that combination runs at 5376Hz)", + path=[CONF_ACCELEROMETER_ODR], + ) + return config + + +def _validate_interrupt(config: ConfigType) -> ConfigType: + if (interrupt := config.get(CONF_INTERRUPT)) is None: + return config + range_key = config[CONF_ACCELEROMETER_RANGE] + threshold = interrupt[CONF_THRESHOLD] + max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000 + if threshold > max_g: + raise cv.Invalid( + f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'", + path=[CONF_INTERRUPT, CONF_THRESHOLD], + ) + if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0: + _LOGGER.warning( + "lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt " + "asserted at rest; set 'high_pass_filter: true' to compare against motion only", + threshold, + ) + odr_key = config[CONF_ACCELEROMETER_ODR] + duration_ms = interrupt[CONF_DURATION].total_milliseconds + if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG: + max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key]) + raise cv.Invalid( + f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'", + path=[CONF_INTERRUPT, CONF_DURATION], + ) + return config + + +CONFIG_SCHEMA = cv.All( + motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + DATA_RATE_OPTIONS, upper=True + ), + cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum( + OPERATING_MODE_OPTIONS, upper=True + ), + cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x18)), + _validate_odr_mode, + _validate_interrupt, +) + + +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE])) + + if (interrupt := config.get(CONF_INTERRUPT)) is not None: + threshold = _threshold_register( + interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE] + ) + axes_cfg = 0 + for axis in interrupt[CONF_AXES]: + axes_cfg |= INT_CFG_HIGH_EVENT[axis] + cg.add( + var.set_interrupt( + interrupt[CONF_PIN], + threshold, + _duration_register( + interrupt[CONF_DURATION].total_milliseconds, + config[CONF_ACCELEROMETER_ODR], + ), + axes_cfg, + interrupt[CONF_LATCHED], + interrupt[CONF_ACTIVE_HIGH], + interrupt[CONF_HIGH_PASS_FILTER], + ) + ) diff --git a/esphome/components/lis3dh/sensor.py b/esphome/components/lis3dh/sensor.py new file mode 100644 index 00000000000..15d19bcc0c0 --- /dev/null +++ b/esphome/components/lis3dh/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_LIS3DH_ID, LIS3DHComponent + +# The LIS3DH temperature sensor is uncalibrated and only reports relative +# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty. +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LIS3DH_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/component_tests/lis3dh/__init__.py b/tests/component_tests/lis3dh/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/lis3dh/test_motion.py b/tests/component_tests/lis3dh/test_motion.py new file mode 100644 index 00000000000..1d86761d4ce --- /dev/null +++ b/tests/component_tests/lis3dh/test_motion.py @@ -0,0 +1,147 @@ +"""Tests for the lis3dh config cross-checks and interrupt threshold maths.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lis3dh.motion import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_DURATION, + CONF_HIGH_PASS_FILTER, + CONF_INTERRUPT, + CONF_OPERATING_MODE, + CONF_THRESHOLD, + _duration_register, + _threshold_register, + _validate_interrupt, + _validate_odr_mode, +) +import esphome.config_validation as cv +from esphome.core import TimePeriod + + +def _interrupt_config( + range_key: str, + odr_key: str, + threshold_g: float, + duration_ms: int, + high_pass: bool = True, +) -> dict: + return { + CONF_ACCELEROMETER_RANGE: range_key, + CONF_ACCELEROMETER_ODR: odr_key, + CONF_INTERRUPT: { + CONF_THRESHOLD: threshold_g, + CONF_DURATION: TimePeriod(milliseconds=duration_ms), + CONF_HIGH_PASS_FILTER: high_pass, + }, + } + + +@pytest.mark.parametrize("mode", ["NORMAL", "HIGH_RESOLUTION"]) +def test_1620hz_needs_low_power(mode: str) -> None: + """1620Hz exists only in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: mode} + with pytest.raises(cv.Invalid, match="only available with"): + _validate_odr_mode(config) + + +def test_1344hz_is_rejected_in_low_power() -> None: + """The 1344Hz code runs at 5376Hz in low power mode.""" + config = {CONF_ACCELEROMETER_ODR: "1344HZ", CONF_OPERATING_MODE: "LOW_POWER"} + with pytest.raises(cv.Invalid, match="not available with"): + _validate_odr_mode(config) + + +def test_1620hz_low_power_is_accepted() -> None: + """The one valid 1620Hz combination passes through unchanged.""" + config = {CONF_ACCELEROMETER_ODR: "1620HZ", CONF_OPERATING_MODE: "LOW_POWER"} + assert _validate_odr_mode(config) is config + + +@pytest.mark.parametrize( + ("threshold_g", "range_key", "expected"), + [ + (0.0, "2G", 1), + (0.05, "16G", 1), + (1.1, "2G", 69), + (0.2, "2G", 13), + (2.032, "2G", 127), + (23.622, "16G", 127), + ], +) +def test_threshold_register(threshold_g: float, range_key: str, expected: int) -> None: + """INT1_THS is the nearest step and never zero.""" + assert _threshold_register(threshold_g, range_key) == expected + + +@pytest.mark.parametrize( + ("threshold_g", "range_key"), + [(2.04, "2G"), (3.0, "2G"), (4.07, "4G"), (23.7, "16G")], +) +def test_threshold_above_range_is_rejected(threshold_g: float, range_key: str) -> None: + """A threshold the range cannot represent would never fire, so it fails validation.""" + config = _interrupt_config(range_key, "100HZ", threshold_g, 0) + with pytest.raises(cv.Invalid, match="'threshold' can be at most"): + _validate_interrupt(config) + + +def test_threshold_within_range_is_accepted() -> None: + """The largest representable threshold is still valid.""" + config = _interrupt_config("2G", "100HZ", 2.032, 0) + assert _validate_interrupt(config) is config + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key", "expected"), + [ + (0, "100HZ", 0), + (20, "100HZ", 2), + (1, "1620HZ", 2), + (10000, "1HZ", 10), + (4, "100HZ", 0), + ], +) +def test_duration_register(duration_ms: int, odr_key: str, expected: int) -> None: + """INT1_DURATION is the nearest number of samples at the data rate.""" + assert _duration_register(duration_ms, odr_key) == expected + + +@pytest.mark.parametrize( + ("duration_ms", "odr_key"), [(1280, "100HZ"), (128000, "1HZ"), (79, "1620HZ")] +) +def test_duration_above_register_is_rejected(duration_ms: int, odr_key: str) -> None: + """A duration that needs more than 127 samples fails validation.""" + config = _interrupt_config("2G", odr_key, 1.1, duration_ms) + with pytest.raises(cv.Invalid, match="'duration' can be at most"): + _validate_interrupt(config) + + +def test_duration_at_register_limit_is_accepted() -> None: + """127 samples is the largest INT1_DURATION value.""" + config = _interrupt_config("2G", "100HZ", 1.1, 1270) + assert _validate_interrupt(config) is config + + +def test_sub_gravity_threshold_without_filter_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Gravity alone can hold the pad asserted below 1g unless the high-pass filter is on.""" + config = _interrupt_config("2G", "100HZ", 0.5, 0, high_pass=False) + with caplog.at_level(logging.WARNING): + assert _validate_interrupt(config) is config + assert "high_pass_filter: true" in caplog.text + + +@pytest.mark.parametrize(("threshold_g", "high_pass"), [(1.1, False), (0.5, True)]) +def test_threshold_without_gravity_problem_is_silent( + caplog: pytest.LogCaptureFixture, threshold_g: float, high_pass: bool +) -> None: + """Above 1g, or with the filter on, no warning is logged.""" + config = _interrupt_config("2G", "100HZ", threshold_g, 0, high_pass=high_pass) + with caplog.at_level(logging.WARNING): + _validate_interrupt(config) + assert caplog.text == "" diff --git a/tests/components/lis3dh/common.yaml b/tests/components/lis3dh/common.yaml new file mode 100644 index 00000000000..54f492ecfa7 --- /dev/null +++ b/tests/components/lis3dh/common.yaml @@ -0,0 +1,67 @@ +motion: + - platform: lis3dh + id: lis3dh_main + i2c_id: i2c_bus + accelerometer_range: 2G + accelerometer_odr: 100HZ + operating_mode: HIGH_RESOLUTION + axis_map: + x: y + y: x + z: -z + # Motion (activity) interrupt - keeps the INT pad asserted while moving so it + # can be used as the deep_sleep wakeup_pin. + interrupt: + pin: INT1 + threshold: 1.1g + duration: 0ms + axes: [x, y, z] + latched: true + active_high: true + high_pass_filter: false + + # A second device covering the other side of each option: low power mode with + # the rate that only exists there, routed to INT2, active low, unlatched, and + # with the high-pass filter on. + - platform: lis3dh + id: lis3dh_low_power + i2c_id: i2c_bus + address: 0x19 + accelerometer_range: 16G + accelerometer_odr: 1620HZ + operating_mode: LOW_POWER + interrupt: + pin: INT2 + threshold: 0.05g + duration: 1ms + axes: [z] + latched: false + active_high: false + high_pass_filter: true + +sensor: + - platform: lis3dh + lis3dh_id: lis3dh_main + type: temperature + name: "LIS3DH Temperature" + + - platform: motion + motion_id: lis3dh_main + type: acceleration_x + name: "Accel X" + - platform: motion + motion_id: lis3dh_main + type: acceleration_y + name: "Accel Y" + - platform: motion + motion_id: lis3dh_main + type: acceleration_z + name: "Accel Z" + - platform: motion + motion_id: lis3dh_main + type: pitch + name: "Pitch" + - platform: motion + motion_id: lis3dh_main + type: roll + name: "Roll" diff --git a/tests/components/lis3dh/test.esp32-idf.yaml b/tests/components/lis3dh/test.esp32-idf.yaml new file mode 100644 index 00000000000..41a8a051658 --- /dev/null +++ b/tests/components/lis3dh/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lis3dh: !include common.yaml diff --git a/tests/components/lis3dh/test.esp8266-ard.yaml b/tests/components/lis3dh/test.esp8266-ard.yaml new file mode 100644 index 00000000000..63c7f2a94ac --- /dev/null +++ b/tests/components/lis3dh/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + lis3dh: !include common.yaml From 83e5e32e2bcdbb6b15ad0761952999e8b77aa845 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Mon, 12 Oct 2026 14:52:05 -0700 Subject: [PATCH 005/111] [i2s_clockless_led_strip] Drives LED strips using I2S and DMA (#20255) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../i2s_clockless_led_strip/__init__.py | 1 + .../i2s_clockless_led_strip.cpp | 254 ++++++++++++++++++ .../i2s_clockless_led_strip.h | 56 ++++ .../i2s_clockless_led_strip/light.py | 86 ++++++ .../test.esp32-s3-idf.yaml | 11 + 6 files changed, 409 insertions(+) create mode 100644 esphome/components/i2s_clockless_led_strip/__init__.py create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp create mode 100644 esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h create mode 100644 esphome/components/i2s_clockless_led_strip/light.py create mode 100644 tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8f1271b76a8..ec2bddd5489 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -276,6 +276,7 @@ esphome/components/i2c_device/* @gabest11 esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt +esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 diff --git a/esphome/components/i2s_clockless_led_strip/__init__.py b/esphome/components/i2s_clockless_led_strip/__init__.py new file mode 100644 index 00000000000..038e865f3ab --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "i2s_clockless_led_strip" diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp new file mode 100644 index 00000000000..bf53d96a83f --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -0,0 +1,254 @@ +#include "i2s_clockless_led_strip.h" + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::i2s_clockless_led_strip { + +ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); + +constexpr const char *const ERROR_ALLOCATION = "Allocation error"; +constexpr const char *const ERROR_I2S = "I2S error"; + +// These values are hardcoded for an LED strip at 800 kbps. +// +// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100 +// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes +// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode. +constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000; +constexpr size_t I2S_BYTES_PER_SAMPLE = 3; + +// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa. +constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) { + return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000; +} +constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; } + +// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal. +// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5. +constexpr uint32_t I2S_RESET_DURATION_US = 300; +constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US); +constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE; + +// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback +// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be +// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper +// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by +// the ESP-IDF programming guide when using 24-bit samples. +constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500; +constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3; +constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); + +I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) + : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + channel_colors_(channel_colors), + num_leds_(num_leds), + pin_(pin) {} + +void I2SClocklessLedStrip::dump_config() { + ESP_LOGCONFIG(TAG, + "I2S Clockless LED Strip:\n" + " Pin: %u", + this->pin_); + char channel_colors[5]; + ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" + " Number of LEDs: %" PRIu16 "\n" + " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", + this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + I2S_BUFFER_DURATION_US_ACTUAL); +} + +float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } + +void I2SClocklessLedStrip::setup() { + if (!this->allocate_buffers_()) { + this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + return; + } + + i2s_chan_config_t chan_config = { + .id = I2S_NUM_AUTO, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 2, + .dma_frame_num = I2S_BUFFER_SAMPLES, + .auto_clear_after_cb = false, + .auto_clear_before_cb = false, + .allow_pd = false, + .intr_priority = 0, + }; + esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err)); + } else { + i2s_tdm_config_t tdm_config = { + .clk_cfg = + { + .sample_rate_hz = I2S_SAMPLE_RATE_HZ, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_384, + }, + .slot_cfg = + { + .data_bit_width = I2S_DATA_BIT_WIDTH_24BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_TDM_SLOT0, + .ws_width = 1, + .big_endian = true, + .total_slot = I2S_TDM_AUTO_SLOT_NUM, + }, + .gpio_cfg = + { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_GPIO_UNUSED, + .ws = I2S_GPIO_UNUSED, + .dout = gpio_num_t(this->pin_), + .din = I2S_GPIO_UNUSED, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err)); + } else { + i2s_event_callbacks_t event_callbacks = { + .on_sent = i2s_on_sent_callback, + }; + err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); + } else { + err = i2s_channel_enable(this->tx_handle_); + if (err == ESP_OK) + return; // Success! + ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); + } + } + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = {}; + } + this->mark_failed(LOG_STR(ERROR_I2S)); +} + +bool I2SClocklessLedStrip::allocate_buffers_() { + const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->i2s_data_ = allocator.allocate(i2s_data_bytes); + if (this->i2s_data_ != nullptr) { + this->color_data_ = allocator.allocate(this->color_data_bytes_); + if (this->color_data_ != nullptr) { + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ != nullptr) { + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_); + memset(this->effect_data_, 0, this->num_leds_); + return true; + } + allocator.deallocate(this->color_data_, this->color_data_bytes_); + this->color_data_ = nullptr; + } + allocator.deallocate(this->i2s_data_, i2s_data_bytes); + this->i2s_data_ = nullptr; + } + return false; +} + +light::LightTraits I2SClocklessLedStrip::get_traits() { + auto traits = light::LightTraits(); + if (this->channel_colors_.has_white()) { + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({light::ColorMode::RGB}); + } + return traits; +} + +void I2SClocklessLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; + + if (this->i2s_data_ready_.load(std::memory_order_acquire)) { + // Busy sending the last frame, try again later. + this->schedule_show(); + return; + } + + const size_t color_data_bytes = this->color_data_bytes_; + uint8_t *color_data = this->color_data_; + uint8_t *i2s_data = this->i2s_data_; + for (size_t i = 0; i < color_data_bytes; i++) { + const uint8_t color_byte = *(color_data++); + *(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5); + *(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1); + *(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1); + } + + this->i2s_data_sent_ = 0; + this->i2s_data_ready_.store(true, std::memory_order_release); + + this->mark_shown_(); +} + +bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + auto *const self = static_cast(user_ctx); + auto *const dma_buf = static_cast(event->dma_buf); + const size_t dma_buf_size = event->size; + + bool dma_buf_filled = false; + if (self->i2s_data_ready_.load(std::memory_order_acquire)) { + const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + if (self->i2s_data_sent_ < i2s_data_bytes) { + const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_; + if (i2s_data_remaining >= dma_buf_size) { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size); + } else { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining); + memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining); + } + dma_buf_filled = true; + } + self->i2s_data_sent_ += dma_buf_size; + if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) { + self->i2s_data_ready_.store(false, std::memory_order_release); + } + } + // We only need to clear the buffer when the first byte is non-zero. Zero is not a value + // that can appear within the I2S data that gets copied into the front of the buffer so + // if the first byte is zero then the buffer must have already been cleared. + if (!dma_buf_filled && dma_buf[0] != 0) { + memset(dma_buf, 0, dma_buf_size); + } + return false; +} + +void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } + +light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h new file mode 100644 index 00000000000..0c516a6bc8d --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -0,0 +1,56 @@ +#pragma once + +#ifdef USE_ESP32 +#include +#endif + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" + +#include + +namespace esphome::i2s_clockless_led_strip { + +class I2SClocklessLedStrip final : public light::AddressableLight { + public: + I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors); + + void dump_config() override; + + float get_setup_priority() const override; + void setup() override; + + light::LightTraits get_traits() override; + + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override; + void write_state(light::LightState *state) override; + + protected: + static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + bool allocate_buffers_(); + light::ESPColorView get_view_internal(int32_t index) const override; + + const size_t color_data_bytes_; + uint8_t *effect_data_{nullptr}; + uint8_t *color_data_{nullptr}; + uint8_t *i2s_data_{nullptr}; + size_t i2s_data_sent_{0}; + i2s_chan_handle_t tx_handle_{}; + const light::ChannelColors channel_colors_; + const uint16_t num_leds_; + const uint8_t pin_; + std::atomic i2s_data_ready_{false}; +}; + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/light.py b/esphome/components/i2s_clockless_led_strip/light.py new file mode 100644 index 00000000000..90a4f1c3540 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/light.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, light +from esphome.components.const import CONF_CHANNEL_COLORS +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN +from esphome.core import CORE +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["esp32"] + +i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip") +I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_( + "I2SClocklessLedStrip", light.AddressableLight +) + +# These variants define SOC_I2S_SUPPORTS_TDM +I2S_TDM_PORTS = { + esp32.VARIANT_ESP32C3: 1, + esp32.VARIANT_ESP32C5: 1, + esp32.VARIANT_ESP32C6: 1, + esp32.VARIANT_ESP32C61: 1, + esp32.VARIANT_ESP32H2: 1, + esp32.VARIANT_ESP32H4: 1, + esp32.VARIANT_ESP32P4: 3, + esp32.VARIANT_ESP32S3: 2, +} + +CONFIG_SCHEMA = cv.All( + esp32.only_on_variant( + supported=list(I2S_TDM_PORTS.keys()), + msg_prefix="I2S Clockless LED Strip", + ), + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors, + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +@dataclass +class I2SClocklessLedStripData: + num_ports: int = 0 + + +def _get_data() -> I2SClocklessLedStripData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SClocklessLedStripData() + return CORE.data[DOMAIN] + + +def _final_validate(_: ConfigType) -> None: + _get_data().num_ports += 1 + variant = esp32.get_esp32_variant() + num_ports_available = I2S_TDM_PORTS.get(variant, 0) + if _get_data().num_ports > num_ports_available: + raise cv.Invalid( + f"Only {num_ports_available} I2S TDM ports are supported on {variant}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + esp32.include_builtin_idf_component("esp_driver_i2s") + esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_PIN], + config[CONF_NUM_LEDS], + light.channel_colors_struct(config[CONF_CHANNEL_COLORS]), + ) + + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..7331691f4db --- /dev/null +++ b/tests/components/i2s_clockless_led_strip/test.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +light: + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip1 + pin: GPIO1 + num_leds: 100 + channel_colors: GRB + - platform: i2s_clockless_led_strip + id: i2s_clockless_led_strip2 + pin: GPIO2 + num_leds: 5 + channel_colors: RWGB From d7dd25a1bc1700b5d5c8c875be00f3c8896b3183 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:57:16 +0200 Subject: [PATCH 006/111] [ld2410s] Add HLK-LD2410S presence radar component (#10452) Co-authored-by: NovakI Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ld2410s/__init__.py | 40 +++ esphome/components/ld2410s/binary_sensor.py | 23 ++ esphome/components/ld2410s/ld2410s.cpp | 314 +++++++++++++++++ esphome/components/ld2410s/ld2410s.h | 62 ++++ tests/components/ld2410s/common.yaml | 7 + tests/components/ld2410s/test.esp32-idf.yaml | 3 + .../components/ld2410s/test.esp8266-ard.yaml | 3 + tests/components/ld2410s/test.rp2040-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 84 +++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 75 ++++ .../fixtures/uart_mock_ld2410s_recovery.yaml | 100 ++++++ tests/integration/test_uart_mock_ld2410s.py | 329 ++++++++++++++++++ 13 files changed, 1044 insertions(+) create mode 100644 esphome/components/ld2410s/__init__.py create mode 100644 esphome/components/ld2410s/binary_sensor.py create mode 100644 esphome/components/ld2410s/ld2410s.cpp create mode 100644 esphome/components/ld2410s/ld2410s.h create mode 100644 tests/components/ld2410s/common.yaml create mode 100644 tests/components/ld2410s/test.esp32-idf.yaml create mode 100644 tests/components/ld2410s/test.esp8266-ard.yaml create mode 100644 tests/components/ld2410s/test.rp2040-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml create mode 100644 tests/integration/test_uart_mock_ld2410s.py diff --git a/CODEOWNERS b/CODEOWNERS index ec2bddd5489..2b77a16db89 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -306,6 +306,7 @@ esphome/components/kuntze/* @ssieb esphome/components/lc709203f/* @ilikecake esphome/components/lcd_menu/* @numo68 esphome/components/ld2410/* @regevbr @sebcaps +esphome/components/ld2410s/* @NovakIrs esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py new file mode 100644 index 00000000000..1c39c7eab32 --- /dev/null +++ b/esphome/components/ld2410s/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@NovakIrs"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld2410s" +MULTI_CONF = True + +CONF_LD2410S_ID = "ld2410s_id" + +ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) +LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2410SComponent), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + baud_rate=115200, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/ld2410s/binary_sensor.py b/esphome/components/ld2410s/binary_sensor.py new file mode 100644 index 00000000000..83e0a36cbac --- /dev/null +++ b/esphome/components/ld2410s/binary_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp new file mode 100644 index 00000000000..6462236ac93 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -0,0 +1,314 @@ +#include "ld2410s.h" +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ld2410s { + +ESPHOME_LOG_TAG(TAG, "ld2410s"); + +static constexpr uint8_t SHORT_DATA_FRAME_HEADER = 0x6E; +static constexpr uint8_t SHORT_DATA_FRAME_FOOTER[] PROGMEM = {0x62}; +static constexpr uint8_t SHORT_DATA_FRAME_SIZE = 5; // header, state, distance low, distance high, footer +static constexpr uint8_t STD_DATA_FRAME_HEADER[] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t STD_DATA_FRAME_FOOTER[] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; +static constexpr uint8_t CMD_FRAME_HEADER[] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t LONG_HEADER_SIZE = 4; +static constexpr uint8_t LENGTH_FIELD_SIZE = 2; +static constexpr uint8_t LONG_PAYLOAD_POS = LONG_HEADER_SIZE + LENGTH_FIELD_SIZE; + +static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word of an acknowledgement +static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; +static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; +static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; +// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes +static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; +static constexpr uint8_t CMD_FRAME_MAX_SIZE = + LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); + +// Everything the module needs at start: enter configuration mode, pick the output mode, leave it +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, + CONFIG_MODE_END_CMD}; +static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); +static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } +static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +static constexpr uint32_t ACK_TIMEOUT_MS = 300; +static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command +static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering +static constexpr uint32_t LINK_TIMEOUT_MS = 5000; // the module streams frames continuously; silence means it is gone + +static constexpr uint8_t DATA_TYPE_STANDARD = 0x01; + +void LD2410SComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LD2410S:"); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); +#endif +} + +void LD2410SComponent::loop() { + uint8_t chunk[16]; + // Take the count once so one pass handles a bounded number of bytes + size_t avail = this->available(); + while (avail > 0) { + const size_t len = std::min(avail, sizeof(chunk)); + if (!this->read_array(chunk, len)) { + ESP_LOGV(TAG, "UART read of %u bytes failed", static_cast(len)); + break; + } + for (size_t i = 0; i < len; i++) { + this->receive_byte_(chunk[i]); + } + avail -= len; + } + const uint32_t now = App.get_loop_component_start_time(); + this->run_init_sequence_(now); + if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) + ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, + this->dropped_frames_); + this->dropped_frames_ = 0; + this->status_set_warning(); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; + } +} + +void LD2410SComponent::run_init_sequence_(uint32_t now) { + if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { + return; + } + const uint16_t command = init_command(this->init_step_); + if (this->awaiting_ack_) { + if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, + REINIT_PAUSE_MS / 1000); + // Leave configuration mode whatever state the module is in, then begin again + this->send_command_(CONFIG_MODE_END_CMD); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now + REINIT_PAUSE_MS; + return; + } + ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); + } + this->send_command_(command); + this->awaiting_ack_ = true; + this->next_send_at_ = now + ACK_TIMEOUT_MS; +} + +void LD2410SComponent::send_command_(uint16_t command) { + uint8_t frame[CMD_FRAME_MAX_SIZE]; + progmem_memcpy(frame, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + uint8_t pos = LONG_PAYLOAD_POS; + frame[pos++] = command & 0xFF; + frame[pos++] = command >> 8; + switch (command) { + case OUTPUT_MODE_SWITCH_CMD: + memset(&frame[pos], 0, OUTPUT_MODE_MINIMAL_SIZE); + pos += OUTPUT_MODE_MINIMAL_SIZE; + break; + case CONFIG_MODE_START_CMD: + frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; + frame[pos++] = CONFIG_MODE_START_VALUE >> 8; + break; + default: + break; + } + frame[LONG_HEADER_SIZE] = pos - LONG_PAYLOAD_POS; // payload length, little-endian + frame[LONG_HEADER_SIZE + 1] = 0; + progmem_memcpy(&frame[pos], CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + pos += sizeof(CMD_FRAME_FOOTER); + this->write_array(frame, pos); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(CMD_FRAME_MAX_SIZE)]; + ESP_LOGVV(TAG, "> %s", format_hex_pretty_to(hex_buf, frame, pos, ' ')); +#endif +} + +void LD2410SComponent::receive_byte_(uint8_t byte) { + if (this->rx_len_ >= RX_BUFFER_SIZE) { + this->reset_frame_(); + } + this->rx_buffer_[this->rx_len_++] = byte; + + if (this->frame_type_ == FrameType::NONE) { + if (this->rx_len_ == 1 && byte == SHORT_DATA_FRAME_HEADER) { + this->frame_type_ = FrameType::SHORT_DATA; + this->expected_len_ = SHORT_DATA_FRAME_SIZE; + return; + } + // The two long headers are told apart once all four bytes are in + uint8_t std_header[LONG_HEADER_SIZE]; + uint8_t cmd_header[LONG_HEADER_SIZE]; + progmem_memcpy(std_header, STD_DATA_FRAME_HEADER, LONG_HEADER_SIZE); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + const bool std_prefix = memcmp(this->rx_buffer_, std_header, this->rx_len_) == 0; + const bool cmd_prefix = memcmp(this->rx_buffer_, cmd_header, this->rx_len_) == 0; + if (std_prefix || cmd_prefix) { + if (this->rx_len_ == LONG_HEADER_SIZE) { + this->frame_type_ = std_prefix ? FrameType::STD_DATA : FrameType::COMMAND; + } + return; + } + // Not a header: drop what was collected, and try this byte as the start of the next frame + const bool retry = this->rx_len_ > 1; + this->reset_frame_(); + if (retry) { + this->receive_byte_(byte); + } + return; + } + + if (this->expected_len_ == 0) { + if (this->rx_len_ < LONG_PAYLOAD_POS) { + return; + } + // Standard data and command frames carry a little-endian payload length after the header + const uint16_t payload_len = + encode_uint16(this->rx_buffer_[LONG_HEADER_SIZE + 1], this->rx_buffer_[LONG_HEADER_SIZE]); + if (payload_len > RX_BUFFER_SIZE - LONG_PAYLOAD_POS - sizeof(CMD_FRAME_FOOTER)) { + ESP_LOGV(TAG, "Dropping frame with a %u byte payload, larger than the receive buffer", payload_len); + this->count_dropped_frame_(); + this->reset_frame_(); + return; + } + this->expected_len_ = LONG_PAYLOAD_POS + payload_len + sizeof(CMD_FRAME_FOOTER); + return; + } + + if (this->rx_len_ < this->expected_len_) { + return; + } + if (this->handle_frame_() || this->rescanning_) { + this->reset_frame_(); + return; + } + // A truncated long frame swallowed the bytes that followed it; try them again as new frames, once + uint8_t rest[RX_BUFFER_SIZE]; + const uint8_t rest_len = this->rx_len_ - 1; + memcpy(rest, &this->rx_buffer_[1], rest_len); + this->reset_frame_(); + this->rescanning_ = true; + for (uint8_t i = 0; i < rest_len; i++) { + this->receive_byte_(rest[i]); + } + this->rescanning_ = false; +} + +bool LD2410SComponent::handle_frame_() { + const uint8_t *footer_src; + uint8_t footer_size = sizeof(CMD_FRAME_FOOTER); + uint8_t payload_pos = LONG_PAYLOAD_POS; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + footer_src = SHORT_DATA_FRAME_FOOTER; + footer_size = sizeof(SHORT_DATA_FRAME_FOOTER); + payload_pos = 1; + break; + case FrameType::STD_DATA: + footer_src = STD_DATA_FRAME_FOOTER; + break; + default: + footer_src = CMD_FRAME_FOOTER; + break; + } + uint8_t footer[sizeof(CMD_FRAME_FOOTER)]; + progmem_memcpy(footer, footer_src, footer_size); + if (memcmp(&this->rx_buffer_[this->rx_len_ - footer_size], footer, footer_size) != 0) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(RX_BUFFER_SIZE)]; + ESP_LOGV(TAG, "Footer does not match header: %s", + format_hex_pretty_to(hex_buf, this->rx_buffer_, this->rx_len_, ' ')); +#endif + this->count_dropped_frame_(); + return false; + } + const uint8_t *payload = &this->rx_buffer_[payload_pos]; + const uint16_t payload_len = this->rx_len_ - payload_pos - footer_size; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + case FrameType::STD_DATA: + if (!this->handle_data_frame_(payload, payload_len)) { + this->count_dropped_frame_(); + break; + } + this->last_frame_at_ = App.get_loop_component_start_time(); + this->dropped_frames_ = 0; + break; + default: + this->handle_command_ack_(payload, payload_len); + break; + } + return true; +} + +bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { + uint8_t state; + if (this->frame_type_ == FrameType::SHORT_DATA) { + // [state][distance low][distance high] + state = payload[0]; + } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { + // [type][state][distance low][distance high]...; the module only sends these before the init + // sequence switches it to minimal output + state = payload[1]; + } else { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; + } + // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to + // carry the header and footer bytes + if (state > 3) { + ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); + return false; + } + this->publish_target_(state > 1); + return true; +} + +void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) { + if (len < 4) { + ESP_LOGV(TAG, "Acknowledgement too short (%u bytes)", len); + return; + } + const uint16_t command_word = encode_uint16(payload[1], payload[0]); + const uint16_t ack = encode_uint16(payload[3], payload[2]); + if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); + return; + } + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + return; + } + this->awaiting_ack_ = false; + this->init_timeouts_ = 0; + this->init_step_++; + this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; + if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + ESP_LOGD(TAG, "Setup done"); + this->last_frame_at_ = App.get_loop_component_start_time(); + this->status_clear_warning(); + } +} + +void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target); + } +#endif +} + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h new file mode 100644 index 00000000000..4af809836ce --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::ld2410s { + +// Largest frame read here is the standard data frame: 4 header + 2 length + payload + 4 footer +static constexpr size_t RX_BUFFER_SIZE = 96; +static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); + +class LD2410SComponent final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(target) +#endif + + public: + void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence + void loop() override; + void dump_config() override; + + protected: + enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; + + void receive_byte_(uint8_t byte); + /// Returns false when the footer does not match, so the bytes can be scanned again. + bool handle_frame_(); + /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. + bool handle_data_frame_(const uint8_t *payload, uint16_t len); + void handle_command_ack_(const uint8_t *payload, uint16_t len); + void count_dropped_frame_() { + if (this->dropped_frames_ < UINT8_MAX) { + this->dropped_frames_++; + } + } + void reset_frame_() { + this->rx_len_ = 0; + this->expected_len_ = 0; + this->frame_type_ = FrameType::NONE; + } + void run_init_sequence_(uint32_t now); + void send_command_(uint16_t command); + void publish_target_(bool target); + + uint32_t next_send_at_{0}; + uint32_t last_frame_at_{0}; + uint8_t rx_buffer_[RX_BUFFER_SIZE]; + uint8_t rx_len_{0}; + uint8_t expected_len_{0}; + uint8_t init_step_{0}; + uint8_t init_timeouts_{0}; + uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 + FrameType frame_type_{FrameType::NONE}; + bool awaiting_ack_{false}; + bool rescanning_{false}; +}; + +} // namespace esphome::ld2410s diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml new file mode 100644 index 00000000000..33b213d363c --- /dev/null +++ b/tests/components/ld2410s/common.yaml @@ -0,0 +1,7 @@ +ld2410s: + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + has_target: + name: Presence diff --git a/tests/components/ld2410s/test.esp32-idf.yaml b/tests/components/ld2410s/test.esp32-idf.yaml new file mode 100644 index 00000000000..a82d5fd3431 --- /dev/null +++ b/tests/components/ld2410s/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.esp8266-ard.yaml b/tests/components/ld2410s/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7346cb4670e --- /dev/null +++ b/tests/components/ld2410s/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/components/ld2410s/test.rp2040-ard.yaml b/tests/components/ld2410s/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c3969bdf0df --- /dev/null +++ b/tests/components/ld2410s/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + ld2410s: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml new file mode 100644 index 00000000000..113e0881e6f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -0,0 +1,84 @@ +esphome: + name: uart-mock-ld2410s-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frame, state 2 (target), distance 300 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, state 2, distance 300 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5] + # Short frame, state 1 (no target) -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x01, 0x00, 0x00, 0x62] + # Noise byte directly followed by a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0xAA, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Partial standard header byte directly followed by a short frame, state 3 -> presence on + - delay: 100ms + inject_rx: [0xF4, 0x6E, 0x03, 0x2C, 0x01, 0x62] + # Short frame with the impossible state 7, dropped, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x07, 0x00, 0x00, 0x62, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard data frame, type 1, with the impossible state 7, dropped, then a short frame, state 2 + # -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x07, 0x2C, 0x01, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, 0x6E, + 0x02, 0x2C, 0x01, 0x62, + ] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml new file mode 100644 index 00000000000..5f06985c7b5 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -0,0 +1,75 @@ +esphome: + name: uart-mock-ld2410s-link-lost + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + responses: + # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Output-mode switch to minimal, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Config-mode end, acknowledged + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + injections: + # Short frames every second for 7 s, longer than the 5 s link timeout, so they must keep the link alive + # t=1 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=2 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=3 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=4 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=5 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # t=6 s: short frame, state 0 -> presence off + - delay: 1000ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # t=7 s: short frame, state 2 -> presence on + - delay: 1000ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Then silence: the link watchdog must start the init sequence over + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml new file mode 100644 index 00000000000..3502ccb85ca --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2410s_recovery.yaml @@ -0,0 +1,100 @@ +esphome: + name: uart-mock-ld2410s-recovery + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2410s's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# No responses: the init commands go unanswered, so the test also covers the resend and give-up path; +# this does not affect receiving data frames +uart_mock: + id: mock_uart + baud_rate: 115200 + auto_start: false + injections: + # Short frame, state 2 -> presence on (baseline) + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Standard header with a 255 byte length field (over the 96 byte buffer), three payload bytes, + # then a short frame, state 0 -> frame dropped, presence off + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame declaring 5 payload bytes but carrying 3, then a short frame, state 2: the short + # frame is read as the rest of the declared length, so the frame stays one byte short + - delay: 100ms + inject_rx: [0xF4, 0xF3, 0xF2, 0xF1, 0x05, 0x00, 0x01, 0x02, 0x2C, 0x6E, 0x02, 0x2C, 0x01, 0x62] + # Short frame, state 0: its first byte completes the truncated frame (footer mismatch); the + # rescan of the swallowed bytes rescues the state 2 short frame -> presence on, and leaves this + # frame's header pending, so the rest of it completes -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Short frame, state 2 -> presence on + - delay: 100ms + inject_rx: [0x6E, 0x02, 0x2C, 0x01, 0x62] + # Command frame with zeros where the footer belongs, then a short frame, state 0 -> presence off + - delay: 100ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6E, 0x00, + 0x00, 0x00, 0x62, + ] + # 100 bytes that never start a header, then a short frame, state 2 -> presence on + - delay: 100ms + inject_rx: + [ + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x6E, 0x02, 0x2C, 0x01, 0x62, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + # Standard frame of the largest size that fits (86 byte payload: type 1, state 2, distance 300, + # 82 padding bytes) -> presence on + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, 0x56, 0x00, 0x01, 0x02, 0x2C, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xF7, 0xF6, 0xF5, + ] + # Short frame, state 0 -> presence off + - delay: 100ms + inject_rx: [0x6E, 0x00, 0x00, 0x00, 0x62] + +ld2410s: + id: ld2410s_dev + uart_id: mock_uart + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py new file mode 100644 index 00000000000..8cd9b4b8dd1 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -0,0 +1,329 @@ +"""Integration tests for the LD2410S component with mock UART. + +test_uart_mock_ld2410s: + 1. Init sequence - config-mode start, output-mode switch and config-mode end go + out in order, each after the previous one is acknowledged, then the + component logs "Setup done" and clears its warning + 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard + data frame + 3. Resync - a noise byte or a partial header directly before a short frame + does not hide that frame + 4. A short frame and a standard frame whose state byte is above 3 are + dropped without publishing, and the short frame after each still publishes + +test_uart_mock_ld2410s_recovery (the mock never answers the init commands): + 1. Config-mode start is resent after the acknowledgement timeout + 2. Once the retries run out, config-mode end is sent and the component warns + that it is starting over + 3. A standard frame whose length field exceeds the receive buffer is dropped + 4. A truncated standard frame swallows the bytes after it up to its declared + length and fails the footer check; the swallowed bytes are rescanned, so + the short frames hidden in them still publish + 5. A command frame with a bad footer is dropped + 6. 100 bytes that never start a header are skipped + 7. A standard frame of the largest size that fits (86 byte payload) is accepted + +test_uart_mock_ld2410s_link_lost: + 1. Short frames arriving for longer than the 5 s link timeout keep the link up + 2. 5 s of silence logs "No data for 5 s (0 frames dropped), starting over", sets the warning flag + and sends a new config-mode start +""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import APIClient, ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" +CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + +# has_target after each injection: short 2, short 0, standard frame state 2, +# short 1, noise + short 2, short 0, partial header + short 3, +# short 7 (dropped) + short 0, standard frame state 7 (dropped) + short 2 +EXPECTED_PRESENCE = [True, False, True, False, True, False, True, False, True] +# The short frame and the standard frame with state 7 are logged as dropped +EXPECTED_DROPPED_STATES = ["7", "7"] + +# has_target after each recovery phase (see uart_mock_ld2410s_recovery.yaml). +# The truncated frame swallows the state 2 short frame after it plus the header +# byte of the following state 0 short frame; the footer mismatch rescans those +# bytes, which publishes the swallowed frame and resumes the next one. +EXPECTED_RECOVERY_PRESENCE = [ + True, # baseline short frame + False, # short frame after the dropped oversized length field + True, # rescued: short frame swallowed by the truncated standard frame + False, # short frame whose header byte completed the truncated frame + True, # short frame + False, # short frame after the command frame with a bad footer + True, # short frame after 100 junk bytes + False, # short frame + True, # maximum-size standard frame + False, # short frame +] +# The truncated frame takes the next 6 bytes to reach its declared length +TRUNCATED_FRAME_HEX = "F4 F3 F2 F1 05 00 01 02 2C 6E 02 2C 01 62 6E" +BAD_COMMAND_FRAME_HEX = "FD FC FB FA 04 00 FE 01 00 00 00 00 00 00" + + +def _tx_frame(line: str) -> str | None: + """Return the hex payload of a uart_mock TX log line, or None.""" + if "uart_mock" not in line or "TX " not in line or "bytes: " not in line: + return None + return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip() + + +async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None: + """Subscribe the collector past the initial states, then start the mock scenario.""" + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(collector.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + +async def _wait_for_presence( + collector: SensorStateCollector, waiter: asyncio.Future[bool] +) -> None: + try: + await asyncio.wait_for(waiter, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the LD2410S init sequence, presence parsing and receiver resync.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + setup_done = loop.create_future() + warning_cleared = loop.create_future() + dropped_states: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): + warning_cleared.set_result(True) + if "Dropping frame with impossible state " in line: + dropped_states.append( + ANSI_ESCAPE.sub("", line.split("state ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(asyncio.gather(setup_done, warning_cleared), 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + assert collector.binary_states["has_target"] == EXPECTED_PRESENCE + assert dropped_states == EXPECTED_DROPPED_STATES + # Data frames must not restart the init sequence + assert len(tx_frames) == 3, tx_frames + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the unanswered-command retry, and that bad frames do not stop later ones.""" + loop = asyncio.get_running_loop() + tx_frames: list[tuple[float, str]] = [] + resent = loop.create_future() + gave_up = loop.create_future() + oversized_dropped = loop.create_future() + footer_mismatches: list[str] = [] + + def line_callback(line: str) -> None: + if (frame := _tx_frame(line)) is not None: + tx_frames.append((loop.time(), frame)) + if "No acknowledgement for command 00FF, resending" in line and not ( + resent.done() + ): + resent.set_result(True) + if ( + "No acknowledgement for command 00FF, starting over in 15 s" in line + and not gave_up.done() + ): + gave_up.set_result(True) + if ( + "Dropping frame with a 255 byte payload, larger than the receive buffer" + in line + and not oversized_dropped.done() + ): + oversized_dropped.set_result(True) + if "Footer does not match header: " in line: + footer_mismatches.append( + ANSI_ESCAPE.sub("", line.split("header: ", 1)[1]).strip() + ) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) + >= len(EXPECTED_RECOVERY_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await _start_scenario(client, collector) + await _wait_for_presence(collector, all_presence) + try: + await asyncio.wait_for(asyncio.gather(resent, gave_up), 5.0) + except TimeoutError: + pytest.fail(f"No resend or give-up warning. TX frames: {tx_frames}") + # Let any unexpected extra update arrive before comparing + await asyncio.sleep(0.3) + + # Recovery shows in the published states alone; the footer log below is + # only a cross-check that needs the fixture's VERBOSE logger level + assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE + assert oversized_dropped.done(), "Oversized length field was not dropped" + assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX] + + frames = [frame for _, frame in tx_frames] + # First send plus INIT_MAX_TIMEOUTS (2) resends, then config-mode end; + # the restart after the 15 s pause is out of the test's time window + assert frames == [CONFIG_START_TX] * 3 + [CONFIG_END_TX] + # Resends follow the 300 ms acknowledgement timeout + gaps = [b[0] - a[0] for a, b in zip(tx_frames, tx_frames[1:], strict=False)] + assert all(0.2 <= gap <= 1.0 for gap in gaps), gaps + + +# has_target after each short frame in uart_mock_ld2410s_link_lost.yaml +EXPECTED_LINK_PRESENCE = [True, False, True, False, True, False, True] +LINK_TIMEOUT_S = 5.0 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2410s_link_lost( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that short frames keep the link alive and silence restarts the init.""" + loop = asyncio.get_running_loop() + tx_frames: list[str] = [] + rx_injected_at: list[float] = [] + setup_done = loop.create_future() + link_lost = loop.create_future() + warning_set_after_loss = loop.create_future() + restarted = loop.create_future() + config_start_before_loss = 0 + + def line_callback(line: str) -> None: + nonlocal config_start_before_loss + if (frame := _tx_frame(line)) is not None: + tx_frames.append(frame) + if frame == CONFIG_START_TX and link_lost.done() and not restarted.done(): + restarted.set_result(True) + if "uart_mock" in line and "Injecting " in line and "(injection " in line: + rx_injected_at.append(loop.time()) + if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): + setup_done.set_result(True) + if ( + "No data for 5 s (0 frames dropped), starting over" in line + and not link_lost.done() + ): + config_start_before_loss = tx_frames.count(CONFIG_START_TX) + link_lost.set_result(loop.time()) + if ( + link_lost.done() + and "ld2410s set Warning flag" in line + and not warning_set_after_loss.done() + ): + warning_set_after_loss.set_result(True) + + collector = SensorStateCollector( + sensor_names=[], binary_sensor_names=["has_target"] + ) + all_presence = collector.add_waiter( + lambda: ( + len(collector.binary_states["has_target"]) >= len(EXPECTED_LINK_PRESENCE) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + try: + await asyncio.wait_for(setup_done, 5.0) + except TimeoutError: + pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") + + await _start_scenario(client, collector) + try: + await asyncio.wait_for(all_presence, timeout=10.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for presence updates. Received: " + f"{collector.binary_states['has_target']}" + ) + assert collector.binary_states["has_target"] == EXPECTED_LINK_PRESENCE + + try: + lost_at = await asyncio.wait_for(link_lost, timeout=7.0) + await asyncio.wait_for( + asyncio.gather(warning_set_after_loss, restarted), timeout=1.0 + ) + except TimeoutError: + pytest.fail( + f"No link-lost warning, warning flag or restart. TX frames: {tx_frames}" + ) + + # The stream ran past the link timeout, so only the silence after it counts + silence = lost_at - rx_injected_at[-1] + assert silence >= LINK_TIMEOUT_S - 0.5, ( + f"Link declared lost {silence:.2f} s after the last frame" + ) + + # The init sequence starts over with a new config-mode start + assert config_start_before_loss == 1, tx_frames + assert tx_frames.count(CONFIG_START_TX) == 2, tx_frames From b88eafcd00070de1c894b764430f21f3a860743a Mon Sep 17 00:00:00 2001 From: Ryan Gammon Date: Mon, 12 Oct 2026 15:05:34 -0700 Subject: [PATCH 007/111] [bq27220] Add BQ27220 battery fuel gauge component (#17702) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq27220/__init__.py | 2 + esphome/components/bq27220/bq27220.cpp | 93 ++++++++++++++ esphome/components/bq27220/bq27220.h | 44 +++++++ esphome/components/bq27220/sensor.py | 123 +++++++++++++++++++ tests/components/bq27220/common.yaml | 19 +++ tests/components/bq27220/test.esp32-idf.yaml | 3 + 7 files changed, 285 insertions(+) create mode 100644 esphome/components/bq27220/__init__.py create mode 100644 esphome/components/bq27220/bq27220.cpp create mode 100644 esphome/components/bq27220/bq27220.h create mode 100644 esphome/components/bq27220/sensor.py create mode 100644 tests/components/bq27220/common.yaml create mode 100644 tests/components/bq27220/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2b77a16db89..87a89a14f8a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 00000000000..2a5d7bc1d7a --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 00000000000..d2f7961e020 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bq27220 { + +ESPHOME_LOG_TAG(TAG, "bq27220"); + +// Standard command registers (16-bit, little-endian), SLUUBD4A +static constexpr uint8_t REG_CONTROL = 0x00; +static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K +static constexpr uint8_t REG_VOLTAGE = 0x08; // mV +static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed +static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh +static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh +static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging +static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // % +static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // % +// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer +static constexpr uint8_t REG_MAC_DATA = 0x40; +static constexpr uint16_t DEVICE_NUMBER = 0x0220; +static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; + +bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { + uint8_t data[2]; + if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { + return false; + } + value = encode_uint16(data[1], data[0]); + return true; +} + +void BQ27220Component::setup() { + const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian + if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to communicate with BQ27220")); + return; + } + delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read + uint16_t device_number = 0; + if (!this->read_word_(REG_MAC_DATA, device_number)) { + this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); + return; + } + if (device_number != DEVICE_NUMBER) { + ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); + this->mark_failed(); + } +} + +void BQ27220Component::update() { + bool ok = true; + this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; }); + this->publish_(this->current_sensor_, REG_CURRENT, ok, + [](uint16_t raw) { return static_cast(raw) * 0.001f; }); + this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; }); + this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok, + [](uint16_t raw) { return float(raw); }); + this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok, + [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); + this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); + + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); + } +} + +void BQ27220Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_); + LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_); + LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_); + LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_); +} + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 00000000000..d88b76a20f0 --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -0,0 +1,44 @@ +#pragma once + +// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf +// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bq27220 { + +class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) + SUB_SENSOR(temperature) + SUB_SENSOR(remaining_capacity) + SUB_SENSOR(full_charge_capacity) + SUB_SENSOR(time_to_empty) + SUB_SENSOR(state_of_health) + + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool read_word_(uint8_t reg, uint16_t &value); + /// Read a standard command register and publish it through the sensor, if one is configured. + /// A failed read publishes NAN and clears `ok`. + template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { + if (sensor == nullptr) + return; + uint16_t raw; + if (!this->read_word_(reg, raw)) { + ok = false; + sensor->publish_state(NAN); + return; + } + sensor->publish_state(convert(raw)); + } +}; + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 00000000000..a3fcec22432 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_MILLIAMPERE_HOUR +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CURRENT, + CONF_ID, + CONF_TEMPERATURE, + CONF_VOLTAGE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BATTERY, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +bq27220_ns = cg.esphome_ns.namespace("bq27220") +BQ27220Component = bq27220_ns.class_( + "BQ27220Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REMAINING_CAPACITY = "remaining_capacity" +CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity" +CONF_TIME_TO_EMPTY = "time_to_empty" +CONF_STATE_OF_HEALTH = "state_of_health" + +# Other SLUUBD4A standard commands are intentionally not exposed to keep the +# component focused; they can be added later if there is demand: TimeToFull +# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24), +# DesignCapacity (0x3C). + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ27220Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x55)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor) + await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor) + await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor) + await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml new file mode 100644 index 00000000000..99d1e1f7f85 --- /dev/null +++ b/tests/components/bq27220/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: bq27220 + id: bq27220_id + voltage: + name: "Battery Voltage" + current: + name: "Battery Current" + battery_level: + name: "Battery" + temperature: + name: "Battery Temperature" + remaining_capacity: + name: "Battery Remaining" + full_charge_capacity: + name: "Battery Full Capacity" + time_to_empty: + name: "Time to Empty" + state_of_health: + name: "Battery Health" diff --git a/tests/components/bq27220/test.esp32-idf.yaml b/tests/components/bq27220/test.esp32-idf.yaml new file mode 100644 index 00000000000..fe4dd41cef8 --- /dev/null +++ b/tests/components/bq27220/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq27220: !include common.yaml From 3ddbf8b3efd588601ea3eda75f5b302c15013270 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:12:11 +0200 Subject: [PATCH 008/111] [lp5562] Add LP5562 4-channel I2C LED driver output component (#18453) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/lp5562/__init__.py | 29 ++++++++ esphome/components/lp5562/lp5562_output.cpp | 72 +++++++++++++++++++ esphome/components/lp5562/lp5562_output.h | 35 +++++++++ esphome/components/lp5562/output.py | 35 +++++++++ tests/components/lp5562/common.yaml | 21 ++++++ tests/components/lp5562/test.esp32-idf.yaml | 4 ++ tests/components/lp5562/test.esp8266-ard.yaml | 4 ++ tests/components/lp5562/test.rp2040-ard.yaml | 4 ++ 9 files changed, 205 insertions(+) create mode 100644 esphome/components/lp5562/__init__.py create mode 100644 esphome/components/lp5562/lp5562_output.cpp create mode 100644 esphome/components/lp5562/lp5562_output.h create mode 100644 esphome/components/lp5562/output.py create mode 100644 tests/components/lp5562/common.yaml create mode 100644 tests/components/lp5562/test.esp32-idf.yaml create mode 100644 tests/components/lp5562/test.esp8266-ard.yaml create mode 100644 tests/components/lp5562/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 87a89a14f8a..09fdec59a0c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,6 +331,7 @@ esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow +esphome/components/lp5562/* @DennisGaida esphome/components/lps22/* @nagisa esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py new file mode 100644 index 00000000000..b8ace97fc9d --- /dev/null +++ b/esphome/components/lp5562/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@DennisGaida"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lp5562" +MULTI_CONF = True + +lp5562_ns = cg.esphome_ns.namespace("lp5562") +LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LP5562Output), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +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) diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp new file mode 100644 index 00000000000..356ef2c0669 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -0,0 +1,72 @@ +#include "lp5562_output.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lp5562 { + +ESPHOME_LOG_TAG(TAG, "lp5562"); + +// see table 26 in the datasheet https://web.archive.org/web/20240327023921/https://www.ti.com/lit/ds/symlink/lp5562.pdf +static constexpr uint8_t LP5562_REG_ENABLE = 0x00; +static constexpr uint8_t LP5562_REG_CONFIG = 0x08; +static constexpr uint8_t LP5562_REG_RESET = 0x0d; +static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; + +static constexpr uint8_t LP5562_RESET_VALUE = 0xff; + +static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; +static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register + +// PWM duty-cycle registers, indexed the same way as the LP5562_CHANNELS enum in output.py +static const uint8_t LP5562_REG_PWM[4] PROGMEM = { + 0x02, // blue + 0x03, // green + 0x04, // red + 0x0e, // white +}; + +void LP5562Output::setup() { + // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result + this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); + delay(1); + // power up the chip and enable the internal 32 kHz oscillator, see + // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 + if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + this->mark_failed(); + return; + } + delay(1); + // use the internal clock, and drive every channel directly from its PWM register instead of the + // internal blink/fade engines + if (!this->write_byte(LP5562_REG_CONFIG, LP5562_CONFIG_INT_CLK_EN) || + !this->write_byte(LP5562_REG_LED_MAP, LP5562_LED_MAP_ALL_PWM_REGISTERS)) { + this->mark_failed(); + } +} + +void LP5562Output::dump_config() { + ESP_LOGCONFIG(TAG, "LP5562:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Setting up LP5562 failed!"); + } +} + +void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { + if (this->is_failed()) + return; + if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); +} + +void LP5562Channel::write_state(float state) { + const uint8_t max_duty = 255; + const auto duty = static_cast(roundf(state * max_duty)); + this->parent_->set_channel_value_(this->channel_, duty); +} + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h new file mode 100644 index 00000000000..a67a4b0658b --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::lp5562 { + +class LP5562Output; + +class LP5562Channel final : public output::FloatOutput, public Parented { + public: + void set_channel(uint8_t channel) { this->channel_ = channel; } + + protected: + void write_state(float state) override; + + uint8_t channel_{0}; +}; + +/// LP5562 4-channel (RGBW) I2C LED driver. +class LP5562Output final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + friend class LP5562Channel; + + void set_channel_value_(uint8_t channel, uint8_t value); +}; + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py new file mode 100644 index 00000000000..4866eb7b9c9 --- /dev/null +++ b/esphome/components/lp5562/output.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import LP5562Output, lp5562_ns + +DEPENDENCIES = ["lp5562"] + +LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) +CONF_LP5562_ID = "lp5562_id" + +# Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp +LP5562_CHANNELS = { + "blue": 0, + "green": 1, + "red": 2, + "white": 3, +} + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LP5562Channel), + cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), + cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_LP5562_ID]) + cg.add(var.set_channel(config[CONF_CHANNEL])) + await output.register_output(var, config) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml new file mode 100644 index 00000000000..c1608f1a3cf --- /dev/null +++ b/tests/components/lp5562/common.yaml @@ -0,0 +1,21 @@ +lp5562: + - address: 0x30 + id: lp5562_1 + +output: + - platform: lp5562 + id: lp5562_red + channel: red + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_green + channel: green + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_blue + channel: blue + lp5562_id: lp5562_1 + - platform: lp5562 + id: lp5562_white + channel: white + lp5562_id: lp5562_1 diff --git a/tests/components/lp5562/test.esp32-idf.yaml b/tests/components/lp5562/test.esp32-idf.yaml new file mode 100644 index 00000000000..b47e39c3898 --- /dev/null +++ b/tests/components/lp5562/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.esp8266-ard.yaml b/tests/components/lp5562/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4a98b9388ab --- /dev/null +++ b/tests/components/lp5562/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lp5562/test.rp2040-ard.yaml b/tests/components/lp5562/test.rp2040-ard.yaml new file mode 100644 index 00000000000..319a7c71a65 --- /dev/null +++ b/tests/components/lp5562/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From a68cf669aab3d4554a31b29ce25f0c5199e5f37a Mon Sep 17 00:00:00 2001 From: Zebble Date: Mon, 12 Oct 2026 18:39:11 -0400 Subject: [PATCH 009/111] [tca8418] Add TCA8418 keypad component (#18521) Co-authored-by: Claude Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tca8418/__init__.py | 156 ++++++++++++++++ .../tca8418/binary_sensor/__init__.py | 143 +++++++++++++++ .../binary_sensor/tca8418_binary_sensor.h | 26 +++ esphome/components/tca8418/tca8418.cpp | 170 ++++++++++++++++++ esphome/components/tca8418/tca8418.h | 85 +++++++++ esphome/core/defines.h | 1 + tests/component_tests/tca8418/__init__.py | 0 .../tca8418/test_final_validate.py | 156 ++++++++++++++++ .../component_tests/tca8418/test_validate.py | 142 +++++++++++++++ tests/components/tca8418/common.yaml | 36 ++++ tests/components/tca8418/test.esp32-idf.yaml | 6 + .../components/tca8418/test.esp8266-ard.yaml | 6 + 13 files changed, 928 insertions(+) create mode 100644 esphome/components/tca8418/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/__init__.py create mode 100644 esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h create mode 100644 esphome/components/tca8418/tca8418.cpp create mode 100644 esphome/components/tca8418/tca8418.h create mode 100644 tests/component_tests/tca8418/__init__.py create mode 100644 tests/component_tests/tca8418/test_final_validate.py create mode 100644 tests/component_tests/tca8418/test_validate.py create mode 100644 tests/components/tca8418/common.yaml create mode 100644 tests/components/tca8418/test.esp32-idf.yaml create mode 100644 tests/components/tca8418/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 09fdec59a0c..ea3cdaed294 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -581,6 +581,7 @@ esphome/components/t6615/* @tylermenezes esphome/components/tas2780/* @remcom esphome/components/tas58xx/* @mrtoy-me @remcom esphome/components/tc74/* @sethgirvan +esphome/components/tca8418/* @zebble esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py new file mode 100644 index 00000000000..67b38dc21ee --- /dev/null +++ b/esphome/components/tca8418/__init__.py @@ -0,0 +1,156 @@ +import logging + +from esphome import automation +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c, key_provider +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c"] +DOMAIN = "tca8418" +AUTO_LOAD = ["key_provider", "gpio_expander"] +MULTI_CONF = True + +# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs +_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") + + +async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: + _request_listener_slot(str(keypad)) + cg.add(keypad.register_listener(var)) + + +CONF_GPI_EVENTS = "gpi_events" +CONF_KEYPAD_ID = "keypad_id" + +# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside +# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9) +MATRIX_COLUMNS = 10 +MATRIX_KEY_MAX = 80 +GPI_KEY_MIN = 97 +GPI_KEY_MAX = 114 + + +def _matrix_key(row: int, col: int) -> int: + return row * MATRIX_COLUMNS + col + 1 + + +tca8418_ns = cg.esphome_ns.namespace("tca8418") +TCA8418Component = tca8418_ns.class_( + "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice +) + + +def _ascii_string(value: str) -> str: + """A string of single byte characters, so it lines up with the matrix.""" + value = cv.string_strict(value) + if not value.isascii(): + raise cv.Invalid( + "Only ASCII characters can be used, since each key is one character" + ) + return value + + +def _validate(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS, 0) + columns = config.get(CONF_COLUMNS, 0) + if (keys := config.get(CONF_KEYS)) is not None: + if rows == 0: + raise cv.Invalid( + "'keys' maps the key matrix, so 'rows' and 'columns' are required", + path=[CONF_KEYS], + ) + if len(keys) != rows * columns: + raise cv.Invalid( + f"'keys' must have exactly {rows * columns} characters " + f"({rows} rows x {columns} columns), got {len(keys)}", + path=[CONF_KEYS], + ) + # Individual inputs report as 97..114, which are also the codes for the + # letters 'a' to 'r'. Both reach on_key and key collectors as a number. + if ( + keys is not None + and config[CONF_GPI_EVENTS] + and ( + clashing := sorted( + {k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX} + ) + ) + ): + _LOGGER.warning( + "In '%s', the key(s) %s cannot be told apart from an individual input " + "on the '%s' and key collector paths, because both are reported as the " + "same number. Binary sensors are unaffected. Use other characters, or " + "set '%s: false', if this matters.", + CONF_KEYS, + ", ".join(f"'{k}'" for k in clashing), + CONF_ON_KEY, + CONF_GPI_EVENTS, + ) + if rows == 0 and not config[CONF_GPI_EVENTS]: + raise cv.Invalid( + "Nothing to report: set 'rows' and 'columns' for a key matrix, " + "or leave 'gpi_events' enabled to report individual inputs" + ) + return config + + +_MATRIX_MSG = ( + "'rows' and 'columns' must both be set to use the key matrix, " + "or both left unset to use every pin as an individual input" +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TCA8418Component), + # The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1). + # Leave both unset to use every pin as an individual input instead. + cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=8 + ), + cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=10 + ), + cv.Optional(CONF_KEYS): _ascii_string, + # Report pins that are not part of the matrix as individual inputs. + cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.Optional(CONF_ON_KEY): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x34)), + _validate, +) + + +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 (rows := config.get(CONF_ROWS)) is not None: + columns = config[CONF_COLUMNS] + cg.add(var.set_rows(rows)) + cg.add(var.set_columns(columns)) + if (keys := config.get(CONF_KEYS)) is not None: + # Indexed by key number - 1, with 0 where no key is assigned. + table = [0] * _matrix_key(rows - 1, columns - 1) + for index, char in enumerate(keys.encode()): + table[_matrix_key(*divmod(index, columns)) - 1] = char + array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) + cg.add(var.set_keys(array, len(table))) + cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) + if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + for conf in config.get(CONF_ON_KEY, []): + await automation.build_callback_automation( + var, "add_on_key_callback", [(cg.uint8, "x")], conf + ) diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py new file mode 100644 index 00000000000..3e1e766c579 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -0,0 +1,143 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_KEY, CONF_ROW +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import ( + CONF_GPI_EVENTS, + CONF_KEYPAD_ID, + GPI_KEY_MAX, + GPI_KEY_MIN, + MATRIX_COLUMNS, + MATRIX_KEY_MAX, + TCA8418Component, + _ascii_string, + _matrix_key, + register_listener, + tca8418_ns, +) + +DEPENDENCIES = ["tca8418"] + +CONF_KEY_CODE = "key_code" + +TCA8418BinarySensor = tca8418_ns.class_( + "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff +) + + +def _key_code(value: int) -> int: + value = cv.int_(value) + if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX: + return value + raise cv.Invalid( + f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, " + f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs" + ) + + +_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + { + cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), + cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=7 + ), + cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=9 + ), + cv.Optional(CONF_KEY): cv.All( + cv.string, + _ascii_string, + cv.Length(min=1, max=1, msg="Key must be a single character"), + ), + cv.Optional(CONF_KEY_CODE): _key_code, + } + ), + cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE), +) + + +def _final_validate(config: ConfigType) -> None: + """Resolve the key to the number the device reports, checked against the keypad's matrix.""" + full_config = fv.full_config.get() + keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1] + keypad = full_config.get_config_for_path(keypad_path) + rows = keypad.get(CONF_ROWS, 0) + columns = keypad.get(CONF_COLUMNS, 0) + + if (row := config.get(CONF_ROW)) is not None: + col = config[CONF_COL] + if row >= rows or col >= columns: + raise cv.Invalid( + f"Position {row}/{col} is outside the {rows} x {columns} key " + "matrix of the keypad it belongs to", + path=[CONF_ROW], + ) + config[CONF_KEY_CODE] = _matrix_key(row, col) + return + + if (key_code := config.get(CONF_KEY_CODE)) is not None: + if key_code <= MATRIX_KEY_MAX: + row, col = divmod(key_code - 1, MATRIX_COLUMNS) + if row >= rows or col >= columns: + raise cv.Invalid( + f"Key number {key_code} is position {row}/{col}, which is " + f"outside the {rows} x {columns} key matrix of the keypad it " + "belongs to", + path=[CONF_KEY_CODE], + ) + return + if not keypad[CONF_GPI_EVENTS]: + raise cv.Invalid( + f"Key number {key_code} is an individual input, but the keypad has " + f"'{CONF_GPI_EVENTS}' turned off", + path=[CONF_KEY_CODE], + ) + # 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the + # matrix cannot also report on its own. + offset = key_code - GPI_KEY_MIN + if offset < 8: + in_matrix, pin = offset < rows, f"ROW{offset}" + else: + in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}" + if in_matrix: + raise cv.Invalid( + f"Key number {key_code} is {pin}, which the key matrix is using, so " + "it cannot report as an individual input", + path=[CONF_KEY_CODE], + ) + return + + if (key := config.get(CONF_KEY)) is not None: + keys = keypad.get(CONF_KEYS) + if keys is None: + raise cv.Invalid( + f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set", + path=[CONF_KEY], + ) + if (index := keys.find(key)) < 0: + raise cv.Invalid( + f"'{key}' is not one of the keypad's keys", path=[CONF_KEY] + ) + if keys.count(key) > 1: + raise cv.Invalid( + f"'{key}' appears more than once in the keypad's keys, so it does " + f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead", + path=[CONF_KEY], + ) + config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns)) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) + await register_listener(keypad, var) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h new file mode 100644 index 00000000000..d6d58b93bc4 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../tca8418.h" + +namespace esphome::tca8418 { + +/// One key of a TCA8418, matched by the key number codegen resolved from the config +class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { + public: + explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} + + void key_pressed(uint8_t key) override { + if (key == this->key_) + this->publish_state(true); + } + void key_released(uint8_t key) override { + if (key == this->key_) + this->publish_state(false); + } + + protected: + const uint8_t key_; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp new file mode 100644 index 00000000000..7add67e9b09 --- /dev/null +++ b/esphome/components/tca8418/tca8418.cpp @@ -0,0 +1,170 @@ +#include "tca8418.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::tca8418 { + +ESPHOME_LOG_TAG(TAG, "tca8418"); + +// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner. +static constexpr uint32_t POLL_INTERVAL_MS = 50; + +void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); } + +void TCA8418Component::setup() { + // The configuration register reads back, so use it to check the device is there. + uint8_t config; + if (!this->read_byte(TCA8418_REG_CFG, &config)) { + ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); + this->mark_failed(); + return; + } + + if (!this->configure_pins_()) { + ESP_LOGE(TAG, "Failed to configure pins"); + this->mark_failed(); + return; + } + + // Report key events, and keep the interrupt asserted while events remain queued. + if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { + ESP_LOGE(TAG, "Failed to write the configuration register"); + this->mark_failed(); + return; + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // The interrupt output is active low, so a falling edge means an event was + // queued. The handler only wakes the loop; the queue is read from there. + this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + + // Discard anything the device queued before it was configured, and clear + // every interrupt flag: leaving one set holds the interrupt output asserted. + if (!this->drain_events_(false)) { + ESP_LOGE(TAG, "Failed to clear the event queue"); + this->mark_failed(); + return; + } + + // With an interrupt pin there is nothing to do until it fires. Stay running + // if the device still has something queued, since that produces no new edge. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::configure_pins_() { + // Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9. + uint8_t matrix[3] = {0, 0, 0}; + if (this->rows_ > 0) { + matrix[0] = static_cast((1U << this->rows_) - 1U); + const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_; + matrix[1] = static_cast((1U << low_columns) - 1U); + if (this->columns_ > 8) + matrix[2] = static_cast((1U << (this->columns_ - 8U)) - 1U); + } + + // Pins outside the matrix report individually when they are in event mode. + // The device ignores these bits for pins that belong to the matrix. + const uint8_t gpi[3] = { + static_cast(this->gpi_events_ ? ~matrix[0] : 0), + static_cast(this->gpi_events_ ? ~matrix[1] : 0), + static_cast(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0), + }; + + for (uint8_t i = 0; i < 3; i++) { + // Enable the internal pull-ups (a 0 bit enables the pull-up) so that + // buttons can simply switch their pin to ground. + if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) || + !this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) { + return false; + } + } + return true; +} + +void TCA8418Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + + if (!this->drain_events_(true)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::drain_events_(bool dispatch) { + uint8_t count; + if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count)) + return false; + count &= TCA8418_EVENT_COUNT_MASK; + // An idle poll has no flags to clear, unless INT is still asserted from a failed clear + if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read())) + return true; + // The overflow flag stays clear with a full queue, so check the count + if (dispatch && count >= TCA8418_FIFO_DEPTH) { + ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost"); + } + + uint8_t event; + for (uint8_t i = 0; i < count; i++) { + if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event)) + return false; + if (dispatch) + this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0); + } + return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL); +} + +uint8_t TCA8418Component::key_char_(uint8_t key) const { + if (key == 0 || key > this->keys_size_) + return 0; + return progmem_read_byte(&this->keys_[key - 1]); +} + +void TCA8418Component::dispatch_(uint8_t key, bool pressed) { + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + +#ifdef TCA8418_LISTENER_COUNT + for (auto *listener : this->listeners_) { + if (pressed) { + listener->key_pressed(key); + } else { + listener->key_released(key); + } + } +#endif + + if (!pressed) + return; + + // Key collectors and automations receive the mapped character when a key map + // is configured, and the key number otherwise. + const uint8_t key_char = this->key_char_(key); + this->send_key_(key_char != 0 ? key_char : key); +} + +void TCA8418Component::dump_config() { + ESP_LOGCONFIG(TAG, + "TCA8418:\n" + " Individual inputs: %s", + YESNO(this->gpi_events_)); + if (this->rows_ > 0) { + ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_); + } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h new file mode 100644 index 00000000000..383182c1e0f --- /dev/null +++ b/esphome/components/tca8418/tca8418.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tca8418 { + +// Register map (see TI datasheet SCPS231) +static constexpr uint8_t TCA8418_REG_CFG = 0x01; +static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; +static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; +static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO +static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A; +static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D; +static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20; +static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C; + +// CFG +static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events +static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain +// INT_STAT +static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag +// KEY_LCK_EC: the low nibble is the number of queued events +static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F; +// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number +static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80; +static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; +// The event queue holds ten entries, which bounds how many can be read at once. +static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; + +class TCA8418Listener { + public: + /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). + virtual void key_pressed(uint8_t key) {} + virtual void key_released(uint8_t key) {} +}; + +class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_rows(uint8_t rows) { this->rows_ = rows; } + void set_columns(uint8_t columns) { this->columns_ = columns; } + void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; } + void set_keys(const uint8_t *keys, uint8_t size) { + this->keys_ = keys; + this->keys_size_ = size; + } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + +#ifdef TCA8418_LISTENER_COUNT + void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#endif + + /// Wakes the loop so queued events are read. Runs in interrupt context. + static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + + protected: + /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. + bool configure_pins_(); + /// Read every queued key event, dispatching them if asked, then clear the interrupt flags. + /// Returns false on I2C failure. + bool drain_events_(bool dispatch); + void dispatch_(uint8_t key, bool pressed); + /// The character mapped to a matrix key, or 0 when it has none. + uint8_t key_char_(uint8_t key) const; + + const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 + InternalGPIOPin *interrupt_pin_{nullptr}; + uint32_t last_poll_{0}; +#ifdef TCA8418_LISTENER_COUNT + StaticVector listeners_; +#endif + uint8_t rows_{0}; + uint8_t columns_{0}; + uint8_t keys_size_{0}; + bool gpi_events_{true}; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a085cf9c0ac..87dc703b375 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -202,6 +202,7 @@ #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 +#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK diff --git a/tests/component_tests/tca8418/__init__.py b/tests/component_tests/tca8418/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tca8418/test_final_validate.py b/tests/component_tests/tca8418/test_final_validate.py new file mode 100644 index 00000000000..48569f3f9e8 --- /dev/null +++ b/tests/component_tests/tca8418/test_final_validate.py @@ -0,0 +1,156 @@ +"""Tests for how the tca8418 binary sensor resolves its key against the keypad.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import CONF_GPI_EVENTS +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_COL, + CONF_ID, + CONF_KEY, + CONF_NAME, + CONF_ROW, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "keypad" + + +def _full_config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = "123456789*0#", + gpi_events: bool = True, +) -> Config: + """A full config carrying one keypad, as the ID pass leaves it.""" + hub: ConfigType = { + CONF_ID: ID(HUB_ID, is_declaration=True, type="tca8418"), + CONF_ROWS: rows, + CONF_COLUMNS: columns, + CONF_GPI_EVENTS: gpi_events, + } + if keys is not None: + hub[CONF_KEYS] = keys + full = Config() + full["tca8418"] = [hub] + full.declare_ids.append((hub[CONF_ID], ["tca8418", 0, CONF_ID])) + return full + + +def _sensor(**selector: object) -> ConfigType: + config: ConfigType = { + "keypad_id": ID(HUB_ID, is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + } + config.update(selector) + return config + + +def _validated(config: ConfigType) -> ConfigType: + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +@pytest.fixture +def keypad(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + + +# A key number is worked out here so the sensor only compares numbers at run +# time. Matrix keys are row * 10 + column + 1. +@pytest.mark.parametrize( + ("selector", "key_code"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, 1), + ({CONF_ROW: 3, CONF_COL: 2}, 33), + ({CONF_KEY: "1"}, 1), + ({CONF_KEY: "5"}, 12), + ({CONF_KEY: "#"}, 33), + ({CONF_KEY_CODE: 12}, 12), + ], +) +def test_key_is_resolved(keypad: None, selector: ConfigType, key_code: int) -> None: + assert _validated(_sensor(**selector))[CONF_KEY_CODE] == key_code + + +def test_position_outside_the_matrix_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="outside the 4 x 3 key matrix"): + _validated(_sensor(row=0, col=3)) + + +def test_key_number_outside_the_matrix_is_rejected(keypad: None) -> None: + """Key 5 is ROW0/COL4, which a three column matrix never reports.""" + with pytest.raises(cv.Invalid, match="position 0/4, which is outside"): + _validated(_sensor(key_code=5)) + + +def test_key_number_of_a_pin_the_matrix_uses_is_rejected(keypad: None) -> None: + """97 is ROW0, which belongs to the matrix, so it cannot report on its own.""" + with pytest.raises(cv.Invalid, match="ROW0, which the key matrix is using"): + _validated(_sensor(key_code=97)) + + +def test_individual_input_outside_the_matrix_passes(keypad: None) -> None: + """ROW5 is past the four rows of the matrix, so it reports on its own.""" + assert _validated(_sensor(key_code=102))[CONF_KEY_CODE] == 102 + + +def test_column_beyond_the_matrix_passes(keypad: None) -> None: + """COL3 is past the three columns of the matrix; 105 is COL0.""" + assert _validated(_sensor(key_code=108))[CONF_KEY_CODE] == 108 + + +def test_individual_input_needs_gpi_events( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(gpi_events=False) + ) + + with pytest.raises(cv.Invalid, match="'gpi_events' turned off"): + _validated(_sensor(key_code=102)) + + +def test_character_without_a_key_map_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config(keys=None)) + + with pytest.raises(cv.Invalid, match="which is not set"): + _validated(_sensor(key="5")) + + +def test_character_not_in_the_key_map_is_rejected(keypad: None) -> None: + with pytest.raises(cv.Invalid, match="not one of the keypad's keys"): + _validated(_sensor(key="Z")) + + +def test_repeated_character_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A character appearing twice does not name one key.""" + set_core_config( + PlatformFramework.ESP32_IDF, full_config=_full_config(keys="1234567891*0") + ) + + with pytest.raises(cv.Invalid, match="appears more than once"): + _validated(_sensor(key="1")) + + +def test_key_given_as_a_number_is_accepted(keypad: None) -> None: + """'key: 5' is a reasonable way to name the key labelled 5.""" + assert _validated(_sensor(key=5))[CONF_KEY_CODE] == 12 diff --git a/tests/component_tests/tca8418/test_validate.py b/tests/component_tests/tca8418/test_validate.py new file mode 100644 index 00000000000..fbadd42363d --- /dev/null +++ b/tests/component_tests/tca8418/test_validate.py @@ -0,0 +1,142 @@ +"""Tests for the tca8418 keypad and binary sensor configuration schemas.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +from esphome.components.tca8418 import ( + CONF_GPI_EVENTS, + CONFIG_SCHEMA as HUB_SCHEMA, + _validate, +) +from esphome.components.tca8418.binary_sensor import ( + CONF_KEY_CODE, + CONFIG_SCHEMA as SENSOR_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_NAME, CONF_ROW +from esphome.core import ID +from esphome.types import ConfigType + + +def _config( + *, + rows: int = 4, + columns: int = 3, + keys: str | None = None, + gpi_events: bool = True, +) -> ConfigType: + config: ConfigType = {CONF_GPI_EVENTS: gpi_events} + if rows: + config[CONF_ROWS] = rows + if columns: + config[CONF_COLUMNS] = columns + if keys is not None: + config[CONF_KEYS] = keys + return config + + +def test_a_matrix_with_a_key_map_passes() -> None: + _validate(_config(keys="123456789*0#")) + + +def test_individual_inputs_only_passes() -> None: + """No matrix at all is fine as long as the pins report on their own.""" + _validate(_config(rows=0, columns=0)) + + +@pytest.mark.parametrize("half", [{CONF_ROWS: 4}, {CONF_COLUMNS: 3}]) +def test_half_a_matrix_is_rejected(half: ConfigType) -> None: + config = {CONF_ID: ID("k", is_declaration=True), "i2c_id": ID("bus"), **half} + with pytest.raises(cv.Invalid, match="must both be set"): + HUB_SCHEMA(config) + + +def test_key_map_without_a_matrix_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="'rows' and 'columns' are required"): + _validate(_config(rows=0, columns=0, keys="123")) + + +@pytest.mark.parametrize("keys", ["12345678901", "12345678"]) +def test_key_map_of_the_wrong_length_is_rejected(keys: str) -> None: + """A key map names every key of the matrix, so its length is fixed.""" + with pytest.raises(cv.Invalid, match="must have exactly 12 characters"): + _validate(_config(keys=keys)) + + +def test_a_keypad_that_reports_nothing_is_rejected() -> None: + with pytest.raises(cv.Invalid, match="Nothing to report"): + _validate(_config(rows=0, columns=0, gpi_events=False)) + + +def test_letters_that_clash_with_individual_inputs_warn( + caplog: pytest.LogCaptureFixture, +) -> None: + """97 to 114 are individual inputs and also the codes for 'a' to 'r'.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#")) + + assert "cannot be told apart" in caplog.text + assert "'a', 'b', 'c'" in caplog.text + + +def test_other_characters_do_not_warn(caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + _validate(_config(keys="123456789*0#")) + + assert caplog.text == "" + + +def test_no_warning_without_individual_inputs( + caplog: pytest.LogCaptureFixture, +) -> None: + """With the individual inputs off there is nothing for a letter to clash with.""" + with caplog.at_level(logging.WARNING): + _validate(_config(keys="abc456789*0#", gpi_events=False)) + + assert caplog.text == "" + + +def _sensor(**selector: object) -> ConfigType: + return { + "keypad_id": ID("keypad", is_declaration=False, type="tca8418"), + CONF_NAME: "Key", + **selector, + } + + +# Checks made before the keypad is consulted: a key is named exactly one way, +# a position has both halves, a character is one ASCII character, and a key +# number is one the device uses (81 to 96 are unused, numbering stops at 114). +@pytest.mark.parametrize( + ("selector", "error"), + [ + ({CONF_ROW: 0, CONF_COL: 0}, None), + ({CONF_KEY: "5"}, None), + ({CONF_KEY: 5}, None), + ({CONF_KEY_CODE: 12}, None), + ({CONF_KEY_CODE: 102}, None), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY: "5"}, "more than one of"), + ({CONF_ROW: 0, CONF_COL: 0, CONF_KEY_CODE: 12}, "more than one of"), + ({CONF_KEY: "5", CONF_KEY_CODE: 12}, "more than one of"), + ({}, "exactly one of"), + ({CONF_ROW: 0}, "must be given together"), + ({CONF_COL: 0}, "must be given together"), + ({CONF_KEY: "ab"}, "single character"), + ({CONF_KEY: ""}, "single character"), + ({CONF_KEY: "\u00e9"}, "Only ASCII characters"), + ({CONF_KEY_CODE: 0}, "Key numbers run from"), + ({CONF_KEY_CODE: 81}, "Key numbers run from"), + ({CONF_KEY_CODE: 96}, "Key numbers run from"), + ({CONF_KEY_CODE: 115}, "Key numbers run from"), + ], +) +def test_sensor_schema(selector: ConfigType, error: str | None) -> None: + if error is None: + SENSOR_SCHEMA(_sensor(**selector)) + return + with pytest.raises(cv.Invalid, match=error): + SENSOR_SCHEMA(_sensor(**selector)) diff --git a/tests/components/tca8418/common.yaml b/tests/components/tca8418/common.yaml new file mode 100644 index 00000000000..4a6b7150c4e --- /dev/null +++ b/tests/components/tca8418/common.yaml @@ -0,0 +1,36 @@ +tca8418: + # A 4x3 key matrix, with the remaining pins reporting as individual inputs. + - id: matrix_keypad_device + i2c_id: i2c_bus + address: 0x34 + rows: 4 + columns: 3 + keys: "123456789*0#" + # The interrupt output is open drain, so it needs a pull-up. Use the + # internal one unless the board already has its own. + interrupt_pin: + number: ${interrupt_pin} + mode: INPUT_PULLUP + on_key: + - logger.log: + format: "Key: %c" + args: [x] + # Every pin reporting as an individual input, polled over I2C. + - id: inputs_only_device + i2c_id: i2c_bus + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: matrix_keypad_device + row: 0 + col: 0 + name: "Key top left" + - platform: tca8418 + keypad_id: matrix_keypad_device + key: "5" + name: "Key five" + - platform: tca8418 + keypad_id: inputs_only_device + key_code: 97 + name: "Input ROW0" diff --git a/tests/components/tca8418/test.esp32-idf.yaml b/tests/components/tca8418/test.esp32-idf.yaml new file mode 100644 index 00000000000..0621ab995e3 --- /dev/null +++ b/tests/components/tca8418/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO13 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + tca8418: !include common.yaml diff --git a/tests/components/tca8418/test.esp8266-ard.yaml b/tests/components/tca8418/test.esp8266-ard.yaml new file mode 100644 index 00000000000..9a9351cde82 --- /dev/null +++ b/tests/components/tca8418/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO12 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + tca8418: !include common.yaml From f9f41ef16e5ea8a61398ad250f483a48b39df10a Mon Sep 17 00:00:00 2001 From: JiriPrchal <163323169+JiriPrchal@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:45:08 +0200 Subject: [PATCH 010/111] [MAX31888] Add temperature sensor (#13567) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/max31888/__init__.py | 2 + esphome/components/max31888/max31888.cpp | 95 +++++++++++++++++++ esphome/components/max31888/max31888.h | 21 ++++ esphome/components/max31888/sensor.py | 36 +++++++ esphome/components/one_wire/one_wire_bus.cpp | 3 + tests/components/max31888/common.yaml | 9 ++ tests/components/max31888/test.esp32-idf.yaml | 3 + .../components/max31888/test.esp8266-ard.yaml | 3 + .../components/max31888/test.rp2040-ard.yaml | 3 + tests/test_build_components/common/README.md | 5 + .../common/one_wire/esp32-idf.yaml | 10 ++ .../common/one_wire/esp8266-ard.yaml | 10 ++ .../common/one_wire/rp2040-ard.yaml | 10 ++ 14 files changed, 211 insertions(+) create mode 100644 esphome/components/max31888/__init__.py create mode 100644 esphome/components/max31888/max31888.cpp create mode 100644 esphome/components/max31888/max31888.h create mode 100644 esphome/components/max31888/sensor.py create mode 100644 tests/components/max31888/common.yaml create mode 100644 tests/components/max31888/test.esp32-idf.yaml create mode 100644 tests/components/max31888/test.esp8266-ard.yaml create mode 100644 tests/components/max31888/test.rp2040-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/esp32-idf.yaml create mode 100644 tests/test_build_components/common/one_wire/esp8266-ard.yaml create mode 100644 tests/test_build_components/common/one_wire/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ea3cdaed294..8c28f879772 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell esphome/components/max31865/* @DAVe3283 +esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger esphome/components/max6956/* @looping40 esphome/components/max7219digit/* @rspaargaren diff --git a/esphome/components/max31888/__init__.py b/esphome/components/max31888/__init__.py new file mode 100644 index 00000000000..880005d6e77 --- /dev/null +++ b/esphome/components/max31888/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@JiriPrchal"] +DOMAIN = "max31888" diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp new file mode 100644 index 00000000000..5ff8e6f1ef0 --- /dev/null +++ b/esphome/components/max31888/max31888.cpp @@ -0,0 +1,95 @@ +#include "max31888.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::max31888 { + +ESPHOME_LOG_TAG(TAG, "max31888"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + +static constexpr uint8_t MAX31888_COMMAND_START_CONVERSION = 0x44; +static constexpr uint8_t MAX31888_COMMAND_READ = 0x33; +static constexpr uint8_t MAX31888_COMMAND_SOFT_RESET = 0x82; +static constexpr uint8_t MAX31888_REGISTER_FIFO_DATA = 0x08; +static constexpr uint8_t MAX31888_FIFO_READ_LENGTH = 0x01; // number of bytes to read, minus one +static constexpr uint16_t MAX31888_CONVERSION_TIME_MS = 20; +static constexpr float MAX31888_DEGREES_PER_LSB = 0.005f; + +// Sends a command and checks the checksum the device appends to it (Maxim CRC-16 over the command byte, +// sent inverted and low byte first) +bool MAX31888Sensor::send_checked_command_(uint8_t command) { + if (!this->send_command_(command)) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + const uint8_t low = this->bus_->read8(); + const uint8_t high = this->bus_->read8(); + if (crc16(&command, 1, 0, 0xa001, false, true) != encode_uint16(high, low)) { + this->status_set_warning(LOG_STR("command checksum invalid")); + return false; + } + return true; +} + +void MAX31888Sensor::setup() { + if (!this->check_address_or_index_()) + return; + if (!this->send_checked_command_(MAX31888_COMMAND_SOFT_RESET)) { + this->mark_failed(); + } +} + +void MAX31888Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "MAX31888 Sensor:"); + if (this->address_ == 0) { + ESP_LOGW(TAG, " Unable to select an address"); + return; + } + LOG_ONE_WIRE_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void MAX31888Sensor::update() { + if (this->address_ == 0) + return; + + if (!this->send_checked_command_(MAX31888_COMMAND_START_CONVERSION)) { + this->publish_state(NAN); + return; + } + + this->set_timeout(CONVERSION_TIMEOUT_ID, MAX31888_CONVERSION_TIME_MS, [this] { + int16_t raw; + if (!this->read_temperature_(raw)) { + this->publish_state(NAN); + return; + } + const float temperature = raw * MAX31888_DEGREES_PER_LSB; + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + this->status_clear_warning(); + this->publish_state(temperature); + }); +} + +bool MAX31888Sensor::read_temperature_(int16_t &raw) { + // The checksum covers the command and its two arguments as well as the data + uint8_t frame[7] = {MAX31888_COMMAND_READ, MAX31888_REGISTER_FIFO_DATA, MAX31888_FIFO_READ_LENGTH}; + if (!this->send_command_(frame[0])) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + this->bus_->write8(frame[1]); + this->bus_->write8(frame[2]); + for (size_t i = 3; i < sizeof(frame); i++) { + frame[i] = this->bus_->read8(); + } + if (crc16(frame, 5, 0, 0xa001, false, true) != encode_uint16(frame[6], frame[5])) { + this->status_set_warning(LOG_STR("checksum invalid")); + return false; + } + raw = static_cast(encode_uint16(frame[3], frame[4])); + return true; +} + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/max31888.h b/esphome/components/max31888/max31888.h new file mode 100644 index 00000000000..666f301cb04 --- /dev/null +++ b/esphome/components/max31888/max31888.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/one_wire/one_wire.h" + +namespace esphome::max31888 { + +class MAX31888Sensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool send_checked_command_(uint8_t command); + /// Reads the latest conversion from the FIFO; false when the bus or the checksum fails + bool read_temperature_(int16_t &raw); +}; + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/sensor.py b/esphome/components/max31888/sensor.py new file mode 100644 index 00000000000..2809e7531a0 --- /dev/null +++ b/esphome/components/max31888/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import one_wire, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +max31888_ns = cg.esphome_ns.namespace("max31888") + +MAX31888Sensor = max31888_ns.class_( + "MAX31888Sensor", + cg.PollingComponent, + sensor.Sensor, + one_wire.OneWireDevice, +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MAX31888Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(one_wire.one_wire_device_schema()) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index 0a4880cf733..22d3afb7754 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -10,6 +10,7 @@ static const uint8_t DALLAS_MODEL_DS1822 = 0x22; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_MODEL_DS1825 = 0x3B; static const uint8_t DALLAS_MODEL_DS28EA00 = 0x42; +static const uint8_t MAXIM_MODEL_MAX31888 = 0x54; const uint8_t ONE_WIRE_ROM_SELECT = 0x55; const uint8_t ONE_WIRE_ROM_SEARCH = 0xF0; @@ -76,6 +77,8 @@ const LogString *OneWireBus::get_model_str(uint8_t model) { return LOG_STR("DS1825"); case DALLAS_MODEL_DS28EA00: return LOG_STR("DS28EA00"); + case MAXIM_MODEL_MAX31888: + return LOG_STR("MAX31888"); default: return LOG_STR("Unknown"); } diff --git a/tests/components/max31888/common.yaml b/tests/components/max31888/common.yaml new file mode 100644 index 00000000000..7b9ada2e823 --- /dev/null +++ b/tests/components/max31888/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: max31888 + one_wire_id: one_wire_bus + address: 0x1C0000031EDD2A54 + name: MAX31888 Temperature 1 + - platform: max31888 + one_wire_id: one_wire_bus + name: MAX31888 Temperature 2 + index: 1 diff --git a/tests/components/max31888/test.esp32-idf.yaml b/tests/components/max31888/test.esp32-idf.yaml new file mode 100644 index 00000000000..943a8c54a9d --- /dev/null +++ b/tests/components/max31888/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp32-idf.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.esp8266-ard.yaml b/tests/components/max31888/test.esp8266-ard.yaml new file mode 100644 index 00000000000..29ed6be26aa --- /dev/null +++ b/tests/components/max31888/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/esp8266-ard.yaml + max31888: !include common.yaml diff --git a/tests/components/max31888/test.rp2040-ard.yaml b/tests/components/max31888/test.rp2040-ard.yaml new file mode 100644 index 00000000000..80de52bae2e --- /dev/null +++ b/tests/components/max31888/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + one_wire: !include ../../test_build_components/common/one_wire/rp2040-ard.yaml + max31888: !include common.yaml diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 010313db7f9..4a89a1fb916 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -125,6 +125,11 @@ Components using `modbus` packages automatically get `uart` as well. - **RP2040**: SCL=GPIO5, SDA=GPIO4 - **BK72xx**: SCL=P20, SDA=P21 +### 1-Wire +- **ESP32 IDF**: GPIO4 +- **ESP8266**: GPIO13 +- **RP2040**: GPIO10 + ### I2C Low Frequency (10kHz) Same pin allocations as standard I2C, but with 10kHz frequency for components requiring slower speeds. diff --git a/tests/test_build_components/common/one_wire/esp32-idf.yaml b/tests/test_build_components/common/one_wire/esp32-idf.yaml new file mode 100644 index 00000000000..b34e87a7589 --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp32-idf tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO4 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/esp8266-ard.yaml b/tests/test_build_components/common/one_wire/esp8266-ard.yaml new file mode 100644 index 00000000000..c4fefffd3eb --- /dev/null +++ b/tests/test_build_components/common/one_wire/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for esp8266-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO13 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} diff --git a/tests/test_build_components/common/one_wire/rp2040-ard.yaml b/tests/test_build_components/common/one_wire/rp2040-ard.yaml new file mode 100644 index 00000000000..8023264e40e --- /dev/null +++ b/tests/test_build_components/common/one_wire/rp2040-ard.yaml @@ -0,0 +1,10 @@ +# Common 1-Wire configuration for rp2040-ard tests +# Provides a shared 1-Wire bus that all components can use + +substitutions: + one_wire_pin: GPIO10 + +one_wire: + - platform: gpio + id: one_wire_bus + pin: ${one_wire_pin} From 1c315c3bf29752958ae9b22d71128bc63dc67c81 Mon Sep 17 00:00:00 2001 From: tchilov <31223839+tchilov@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:49:07 +0200 Subject: [PATCH 011/111] [ns2009] Add Nsiway NS2009 touchscreen controller component (#10597) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/ns2009/__init__.py | 8 +++ .../components/ns2009/touchscreen/__init__.py | 34 ++++++++++ .../components/ns2009/touchscreen/ns2009.cpp | 65 +++++++++++++++++++ .../components/ns2009/touchscreen/ns2009.h | 25 +++++++ tests/components/ns2009/common.yaml | 27 ++++++++ tests/components/ns2009/test.esp32-idf.yaml | 8 +++ tests/components/ns2009/test.esp8266-ard.yaml | 8 +++ tests/components/ns2009/test.rp2040-ard.yaml | 8 +++ 9 files changed, 185 insertions(+) create mode 100644 esphome/components/ns2009/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/__init__.py create mode 100644 esphome/components/ns2009/touchscreen/ns2009.cpp create mode 100644 esphome/components/ns2009/touchscreen/ns2009.h create mode 100644 tests/components/ns2009/common.yaml create mode 100644 tests/components/ns2009/test.esp32-idf.yaml create mode 100644 tests/components/ns2009/test.esp8266-ard.yaml create mode 100644 tests/components/ns2009/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8c28f879772..45aa0c49ab3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -413,6 +413,8 @@ esphome/components/noblex/* @AGalfra esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 +esphome/components/ns2009/* @tchilov +esphome/components/ns2009/touchscreen/* @tchilov esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @clydebarrow @guillempages diff --git a/esphome/components/ns2009/__init__.py b/esphome/components/ns2009/__init__.py new file mode 100644 index 00000000000..7cd4038a95a --- /dev/null +++ b/esphome/components/ns2009/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv + +CODEOWNERS = ["@tchilov"] +DOMAIN = "ns2009" + +CONFIG_SCHEMA = cv.invalid( + "This component should be used as platform of the Touchscreen component." +) diff --git a/esphome/components/ns2009/touchscreen/__init__.py b/esphome/components/ns2009/touchscreen/__init__.py new file mode 100644 index 00000000000..49bcf7a2462 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +CODEOWNERS = ["@tchilov"] +DEPENDENCIES = ["i2c"] + +ns2009_ns = cg.esphome_ns.namespace("ns2009") +NS2009Component = ns2009_ns.class_( + "NS2009Component", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema(calibration_required=True) + .extend( + { + cv.GenerateID(): cv.declare_id(NS2009Component), + cv.Optional(CONF_THRESHOLD, default=40): cv.uint8_t, + } + ) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await i2c.register_i2c_device(var, config) + await touchscreen.register_touchscreen(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) diff --git a/esphome/components/ns2009/touchscreen/ns2009.cpp b/esphome/components/ns2009/touchscreen/ns2009.cpp new file mode 100644 index 00000000000..ae1bdfe3736 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.cpp @@ -0,0 +1,65 @@ +#include "ns2009.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ns2009 { + +ESPHOME_LOG_TAG(TAG, "ns2009"); + +static constexpr uint8_t GET_X = 0xC0; +static constexpr uint8_t GET_Y = 0xD0; +static constexpr uint8_t GET_Z = 0xE2; + +void NS2009Component::setup() { + if (!this->read_byte(GET_Z).has_value()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void NS2009Component::update_touches() { + auto data_z = this->read_byte(GET_Z); + if (!data_z.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("pressure")); + this->skip_update_ = true; + return; + } + uint8_t z = *data_z; + if (z <= this->threshold_) { + return; + } + + auto x = this->read_axis_(GET_X); + if (!x.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("X position")); + this->skip_update_ = true; + return; + } + auto y = this->read_axis_(GET_Y); + if (!y.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("Y position")); + this->skip_update_ = true; + return; + } + + ESP_LOGV(TAG, "X %4d Y %4d Z %3d", *x, *y, z); + this->add_raw_touch_position_(0, *x, *y, z); +} + +optional NS2009Component::read_axis_(uint8_t cmd) { + auto data = this->read_bytes<2>(cmd); + if (!data.has_value()) { + return {}; + } + return encode_uint16((*data)[0], (*data)[1]) >> 4; // 12 bit followed by 4 0's +} + +void NS2009Component::dump_config() { + ESP_LOGCONFIG(TAG, + "NS2009 Touchscreen:\n" + " Threshold: %u", + this->threshold_); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::ns2009 diff --git a/esphome/components/ns2009/touchscreen/ns2009.h b/esphome/components/ns2009/touchscreen/ns2009.h new file mode 100644 index 00000000000..75b675bfe96 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" + +namespace esphome::ns2009 { + +class NS2009Component : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Set the threshold for the touch detection. + void set_threshold(uint8_t threshold) { this->threshold_ = threshold; } + + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + /// Read a 12 bit axis value, or nothing when the read fails + optional read_axis_(uint8_t cmd); + + /// @brief The threshold at which a touch is registered. + uint8_t threshold_{40}; +}; + +} // namespace esphome::ns2009 diff --git a/tests/components/ns2009/common.yaml b/tests/components/ns2009/common.yaml new file mode 100644 index 00000000000..f2472d2d5db --- /dev/null +++ b/tests/components/ns2009/common.yaml @@ -0,0 +1,27 @@ +display: + - platform: mipi_spi + model: ILI9341 + id: ns_display + dc_pin: ${dc_pin} + cs_pin: ${cs_pin} + color_depth: 8 + invert_colors: false + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: ns2009 + id: ns_touchscreen + i2c_id: i2c_bus + display: ns_display + transform: + mirror_y: true + calibration: + x_min: 400 + x_max: 3700 + y_min: 300 + y_max: 3700 + on_touch: + - logger.log: + format: Touch at (%d, %d) + args: [touch.x, touch.y] diff --git a/tests/components/ns2009/test.esp32-idf.yaml b/tests/components/ns2009/test.esp32-idf.yaml new file mode 100644 index 00000000000..80b28696a12 --- /dev/null +++ b/tests/components/ns2009/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO15 + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.esp8266-ard.yaml b/tests/components/ns2009/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ca78f2b0291 --- /dev/null +++ b/tests/components/ns2009/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ns2009: !include common.yaml diff --git a/tests/components/ns2009/test.rp2040-ard.yaml b/tests/components/ns2009/test.rp2040-ard.yaml new file mode 100644 index 00000000000..c10519f9d03 --- /dev/null +++ b/tests/components/ns2009/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + dc_pin: GPIO3 + cs_pin: GPIO1 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + ns2009: !include common.yaml From c5baae35d57b3ef185f8943bb36c7afddd049d16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:51:48 -0400 Subject: [PATCH 012/111] [ezsp_proxy_tap] Add SiLabs EZSP auto-ACK implementation (#20304) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ezsp_proxy_tap/__init__.py | 34 ++++ .../ezsp_proxy_tap/ash_acknowledger.cpp | 126 ++++++++++++ .../ezsp_proxy_tap/ash_acknowledger.h | 54 +++++ .../ezsp_proxy_tap/ash_protocol.cpp | 19 ++ .../components/ezsp_proxy_tap/ash_protocol.h | 39 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.cpp | 38 ++++ .../ezsp_proxy_tap/ezsp_proxy_tap.h | 49 +++++ .../components/serial_proxy/serial_proxy.cpp | 14 ++ .../components/serial_proxy/serial_proxy.h | 9 + esphome/core/defines.h | 1 + .../ezsp_proxy_tap/ash_acknowledger_test.cpp | 185 ++++++++++++++++++ tests/components/ezsp_proxy_tap/common.yaml | 15 ++ .../ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml | 27 +++ .../ezsp_proxy_tap/test.esp32-idf.yaml | 3 + .../ezsp_proxy_tap/test.esp8266-ard.yaml | 3 + .../ezsp_proxy_tap/test.rp2040-ard.yaml | 3 + 17 files changed, 620 insertions(+) create mode 100644 esphome/components/ezsp_proxy_tap/__init__.py create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_acknowledger.h create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ash_protocol.h create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp create mode 100644 esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h create mode 100644 tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp create mode 100644 tests/components/ezsp_proxy_tap/common.yaml create mode 100644 tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp32-idf.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml create mode 100644 tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 45aa0c49ab3..23bac1efae1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ esphome/components/exponential_moving_average/* @clydebarrow esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento +esphome/components/ezsp_proxy_tap/* @kbx81 @puddly esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi diff --git a/esphome/components/ezsp_proxy_tap/__init__.py b/esphome/components/ezsp_proxy_tap/__init__.py new file mode 100644 index 00000000000..207e0cb8b15 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@puddly"] +DEPENDENCIES = ["serial_proxy"] +DOMAIN = "ezsp_proxy_tap" + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap") +EzspProxyTap = ezsp_proxy_tap_ns.class_( + "EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(EzspProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_EZSP_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp new file mode 100644 index 00000000000..f8010f46186 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp @@ -0,0 +1,126 @@ +#include "ash_acknowledger.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +// Control byte of an RSTACK, and the only ASH version byte that can follow it +static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1; +static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02; +static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code +// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte +static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3; +static constexpr size_t ASH_CRC_SIZE = 2; +// Smallest legal frame on the wire: a bare control byte plus its CRC +static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; + +void AshFrameScanner::reset() { + this->index_ = 0; + this->crc_ = ASH_CRC_INIT; + this->escaped_ = false; + this->poisoned_ = false; + this->discarding_ = false; +} + +bool AshFrameScanner::feed(uint8_t byte) { + if (byte == ASH_FLAG_BYTE) { + // Running the CRC over the body and its trailing CRC bytes leaves zero when correct + const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ && + this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0; + if (valid) { + this->frame_length_ = this->index_ - ASH_CRC_SIZE; + } + this->reset(); + return valid; + } + + // CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame + if (byte == ASH_CANCEL_BYTE) { + this->reset(); + return false; + } + + if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) { + return false; + } + + switch (byte) { + case ASH_SUBSTITUTE_BYTE: + this->discarding_ = true; + return false; + + case ASH_XON_BYTE: + case ASH_XOFF_BYTE: + return false; + + case ASH_ESCAPE_BYTE: + this->escaped_ = true; + return false; + + default: + break; + } + + uint8_t value = byte; + if (this->escaped_) { + this->escaped_ = false; + value = byte ^ ASH_XOR_BYTE; + } + + if (this->index_ >= MAX_ASH_FRAME_SIZE) { + this->poisoned_ = true; + return false; + } + + if (this->index_ < ASH_HEADER_SIZE) { + this->header_[this->index_] = value; + } + this->index_++; + this->crc_ = crc16be(&value, 1, this->crc_); + return false; +} + +void AshAcknowledger::reset() { + this->scanner_.reset(); + this->rx_sequence_ = 0; + this->synced_ = false; +} + +bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); } + +bool AshAcknowledger::handle_frame_() { + const uint8_t *body = this->scanner_.header(); + const uint8_t control = body[0]; + + // An RSTACK restarts the NCP's frame numbering + if (control == ASH_RSTACK_CONTROL) { + if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) { + this->rx_sequence_ = 0; + this->synced_ = true; + } + return false; + } + + if ((control & 0x80) != 0) { + return false; // ACK/NAK/RST/ERROR: nothing is owed for these + } + + if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) { + return false; + } + + const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE; + const bool re_tx = (control & 0x08) != 0; + // Without an RSTACK, the session began before we were watching: follow the NCP's numbering + if (!this->synced_) { + this->rx_sequence_ = frame_num; + this->synced_ = true; + } + if (frame_num == this->rx_sequence_) { + this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; + return true; + } + return re_tx; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.h b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h new file mode 100644 index 00000000000..1c39e8ec4ad --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ash_protocol.h" + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only +// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes +// arrive and acknowledging needs nothing past the header. +class AshFrameScanner { + public: + // Returns true when the byte completes a frame with a valid CRC + bool feed(uint8_t byte); + void reset(); + + // The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body. + // Valid only until the next feed() call, which begins overwriting them. + const uint8_t *header() const { return this->header_; } + size_t length() const { return this->frame_length_; } + + private: + uint8_t header_[ASH_HEADER_SIZE]; + size_t index_{0}; // body bytes read so far in the frame being read + size_t frame_length_{0}; // body length of the last completed frame + uint16_t crc_{ASH_CRC_INIT}; + bool escaped_{false}; + bool discarding_{false}; + bool poisoned_{false}; +}; + +// Works out which acknowledgements an NCP is owed while a client has handed them to this +// device. Whether to acknowledge at all is the client's decision, made by putting the port +// in PROTOCOL mode, so this only follows the NCP's frame numbering. +class AshAcknowledger { + public: + // Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again. + void reset(); + + // Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num(). + bool feed(uint8_t byte); + uint8_t ack_num() const { return this->rx_sequence_; } + + protected: + bool handle_frame_(); + + AshFrameScanner scanner_; + uint8_t rx_sequence_{0}; + bool synced_{false}; +}; + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.cpp b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp new file mode 100644 index 00000000000..bbe9cacc2de --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp @@ -0,0 +1,19 @@ +#include "ash_protocol.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT); + + output[0] = ASH_FLAG_BYTE; + output[1] = control; + output[2] = crc >> 8; + output[3] = crc & 0xFF; + output[4] = ASH_FLAG_BYTE; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.h b/esphome/components/ezsp_proxy_tap/ash_protocol.h new file mode 100644 index 00000000000..4694c745018 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission +static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission +static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it +// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF. +static constexpr uint8_t ASH_WAKE_BYTE = 0xFF; + +static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220; +static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2; + +// The control byte, then the first Data Field byte (an RSTACK's version) +static constexpr size_t ASH_HEADER_SIZE = 2; + +// Protocol limits +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) + +// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; + +// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs +// escaping, so the size is fixed. +static constexpr size_t ASH_ACK_FRAME_SIZE = 5; + +// Writes the ACK frame for `ack_num` into `output` +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num); + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp new file mode 100644 index 00000000000..c8557fcc9be --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp @@ -0,0 +1,38 @@ +#include "ezsp_proxy_tap.h" + +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/core/log.h" + +namespace esphome::ezsp_proxy_tap { + +ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap"); + +void EzspProxyTap::setup() { this->parent_->set_tap(this); } + +void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (this->acknowledger_.feed(data[i])) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, this->acknowledger_.ack_num()); + this->parent_->write_from_tap(frame, sizeof(frame)); + ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num()); + } + } +} + +void EzspProxyTap::on_protocol_enabled() { + // Numbering from an earlier session is stale + this->acknowledger_.reset(); +} + +void EzspProxyTap::on_device_disconnected() { + // The frame numbering ended with the NCP's power; whatever boots next starts over + this->acknowledger_.reset(); +} + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h new file mode 100644 index 00000000000..2e5a444bec9 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "ash_acknowledger.h" + +namespace esphome::ezsp_proxy_tap { + +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, sent +// only while a client has put the port in PROTOCOL mode, which only an EZSP client does. +class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + // ACKs depend only on what the NCP sends + void on_client_tx(const uint8_t *data, size_t len) override {} + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_enabled() override; + // Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh + void on_protocol_disabled() override {} + void on_device_disconnected() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + AshAcknowledger acknowledger_; +}; + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index d512362fa01..9623eb21b3c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -277,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); #ifdef USE_SERIAL_PROXY_TAP + const bool entering_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL; const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; + if (entering_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_enabled(); + } + // Only for an explicit client request, not for reset_mode_() at the end of a session: // an ordinary disconnect says nothing about the device, whereas a client deliberately // asking for raw bytes usually precedes changing what the device is. @@ -426,6 +432,14 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: void SerialProxy::on_usb_connection_changed_(bool connected) { ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif #ifdef USE_API if (api::global_api_server == nullptr) { return; diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 1a406bc0891..2b8f933f889 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -89,11 +89,20 @@ class SerialProxyTap { /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; + /// A client turned protocol handling on for this port. The tap has seen none of the + /// traffic since it was last on, so anything it remembers from then is stale. + virtual void on_protocol_enabled() = 0; + /// A client explicitly turned protocol handling off for this port. Distinct from the /// automatic reset when a session ends: this one means a client intends to do something /// else with the device -- reflash it, most likely -- so anything the tap believes about /// it should be treated as suspect. virtual void on_protocol_disabled() = 0; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 87dc703b375..a1b32b39f55 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP_NOW_HOSTED #define USE_EVENT +#define USE_EZSP_PROXY_TAP #define USE_FAN #define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp new file mode 100644 index 00000000000..5ee22ae9d46 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/ash_acknowledger_test.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include + +#include "esphome/components/ezsp_proxy_tap/ash_acknowledger.h" +#include "esphome/components/ezsp_proxy_tap/ash_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap::testing { + +namespace { + +// Whole frames as they appear on the wire, each ending in its FLAG. RSTACK and ACK(1) +// are the examples in Silicon Labs UG101. +const std::vector RSTACK = {0xC1, 0x02, 0x02, 0x9B, 0x7B, 0x7E}; +const std::vector DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0x50, 0x9B, 0x98, 0x7E}; +const std::vector DATA_FRAME_1 = {0x10, 0x43, 0x21, 0xA8, 0x50, 0x9F, 0xC2, 0x7E}; +const std::vector DATA_FRAME_0_RETRANSMITTED = {0x08, 0x43, 0x21, 0xA8, 0x50, 0x99, 0xB5, 0x7E}; +const std::vector DATA_FRAME_5 = {0x50, 0x43, 0x21, 0xA8, 0x50, 0x8E, 0xAA, 0x7E}; +const std::vector DATA_FRAME_5_RETRANSMITTED = {0x58, 0x43, 0x21, 0xA8, 0x50, 0x8C, 0x87, 0x7E}; +const std::vector DATA_FRAME_6 = {0x60, 0x43, 0x21, 0xA8, 0x50, 0x82, 0x44, 0x7E}; +// Data frame 0 with a 2-byte and a 3-byte data field, below and at the minimum +const std::vector SHORT_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA0, 0x40, 0x7E}; +const std::vector MIN_DATA_FRAME_0 = {0x00, 0x43, 0x21, 0xA8, 0xC1, 0x08, 0x7E}; +// Body {0x00, 0x7E, 0x7D, 0x11, 0x13, 0x18, 0x1A}: every reserved byte, each escaped +const std::vector ESCAPED_FRAME = {0x00, 0x7D, 0x5E, 0x7D, 0x5D, 0x7D, 0x31, 0x7D, + 0x33, 0x7D, 0x38, 0x7D, 0x3A, 0x46, 0x8C, 0x7E}; + +const std::vector RESERVED_BYTES = {ASH_FLAG_BYTE, ASH_ESCAPE_BYTE, ASH_XON_BYTE, + ASH_XOFF_BYTE, ASH_SUBSTITUTE_BYTE, ASH_CANCEL_BYTE}; + +// Data frame 0 with `data_size` bytes of 0x42 and a valid CRC, none of which needs escaping +std::vector unstuffed_frame(size_t data_size) { + std::vector frame(1 + data_size, 0x42); + frame[0] = 0x00; + const uint16_t crc = crc16be(frame.data(), frame.size(), ASH_CRC_INIT); + frame.push_back(crc >> 8); + frame.push_back(crc & 0xFF); + frame.push_back(ASH_FLAG_BYTE); + return frame; +} + +std::vector concat(std::vector a, const std::vector &b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// Returns whether the last byte completed a valid frame +bool feed(AshFrameScanner &scanner, const std::vector &bytes) { + bool complete = false; + for (uint8_t byte : bytes) + complete = scanner.feed(byte); + return complete; +} + +// Feeds whole frames and returns the ACK owed after the last, or -1 if none is +int feed(AshAcknowledger &acknowledger, const std::vector &bytes) { + bool owed = false; + for (uint8_t byte : bytes) + owed = acknowledger.feed(byte); + return owed ? acknowledger.ack_num() : -1; +} + +} // namespace + +TEST(AshProtocol, AckFrameMatchesReference) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, 1); + EXPECT_EQ(std::vector(frame, frame + sizeof(frame)), (std::vector{0x7E, 0x81, 0x60, 0x59, 0x7E})); +} + +TEST(AshProtocol, AckFramesNeedNoEscaping) { + for (uint8_t ack_num = 0; ack_num <= ASH_MAX_SEQUENCE; ack_num++) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, ack_num); + EXPECT_EQ(frame[0], ASH_FLAG_BYTE); + EXPECT_EQ(frame[4], ASH_FLAG_BYTE); + for (size_t i = 1; i < 4; i++) { + EXPECT_EQ(std::count(RESERVED_BYTES.begin(), RESERVED_BYTES.end(), frame[i]), 0) + << "ack_num " << int(ack_num) << " byte " << i; + } + } +} + +TEST(AshFrameScanner, UnescapesReservedBytes) { + AshFrameScanner scanner; + // The CRC covers the unescaped body, so a valid frame proves every byte was restored + ASSERT_TRUE(feed(scanner, ESCAPED_FRAME)); + EXPECT_EQ(scanner.length(), 7u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x7E})); +} + +TEST(AshFrameScanner, RejectsBadCrc) { + AshFrameScanner scanner; + std::vector corrupt = DATA_FRAME_0; + corrupt[corrupt.size() - 2] ^= 0x01; + EXPECT_FALSE(feed(scanner, corrupt)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelDiscardsPartialFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, SubstituteDiscardsUntilFlag) { + AshFrameScanner scanner; + std::vector substituted = DATA_FRAME_0; + substituted.insert(substituted.begin() + 2, ASH_SUBSTITUTE_BYTE); + EXPECT_FALSE(feed(scanner, substituted)); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshFrameScanner, CancelEndsSubstitute) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({0x10, ASH_SUBSTITUTE_BYTE, 0x43, ASH_CANCEL_BYTE}, DATA_FRAME_0))); +} + +TEST(AshFrameScanner, IgnoresWakeBetweenFrames) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, concat({ASH_WAKE_BYTE, ASH_FLAG_BYTE, ASH_WAKE_BYTE, ASH_WAKE_BYTE}, DATA_FRAME_0))); + EXPECT_EQ(scanner.length(), 5u); +} + +TEST(AshFrameScanner, IgnoresFlowControlInsideFrame) { + AshFrameScanner scanner; + std::vector interrupted = DATA_FRAME_0; + interrupted.insert(interrupted.begin() + 3, ASH_XOFF_BYTE); + interrupted.insert(interrupted.begin() + 1, ASH_XON_BYTE); + ASSERT_TRUE(feed(scanner, interrupted)); + EXPECT_EQ(scanner.length(), 5u); + EXPECT_EQ(std::vector(scanner.header(), scanner.header() + ASH_HEADER_SIZE), + (std::vector{0x00, 0x43})); +} + +TEST(AshFrameScanner, RejectsOversizedFrame) { + AshFrameScanner scanner; + EXPECT_TRUE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE))); + EXPECT_EQ(scanner.length(), 1 + ASH_MAX_DATA_FIELD_SIZE); + EXPECT_FALSE(feed(scanner, unstuffed_frame(ASH_MAX_DATA_FIELD_SIZE + 1))); + EXPECT_TRUE(feed(scanner, DATA_FRAME_0)); +} + +TEST(AshAcknowledger, AcknowledgesFramesInSequence) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + // A repeat not marked as a retransmission is out of sequence + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), -1); + // A retransmission means the NCP missed our ACK, so it gets the same one again + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0_RETRANSMITTED), 1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_1), 2); +} + +TEST(AshAcknowledger, RstackRestartsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + EXPECT_EQ(feed(acknowledger, RSTACK), -1); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, IgnoresShortDataFrames) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, SHORT_DATA_FRAME_0), -1); + EXPECT_EQ(feed(acknowledger, MIN_DATA_FRAME_0), 1); +} + +TEST(AshAcknowledger, SyncsToFirstFrameMidSession) { + AshAcknowledger acknowledger; + // The NCP is retransmitting a frame nobody acknowledged before the tap was watching + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5_RETRANSMITTED), 6); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_6), 7); +} + +TEST(AshAcknowledger, ResetForgetsNumbering) { + AshAcknowledger acknowledger; + EXPECT_EQ(feed(acknowledger, DATA_FRAME_0), 1); + acknowledger.reset(); + EXPECT_EQ(feed(acknowledger, DATA_FRAME_5), 6); +} + +} // namespace esphome::ezsp_proxy_tap::testing diff --git a/tests/components/ezsp_proxy_tap/common.yaml b/tests/components/ezsp_proxy_tap/common.yaml new file mode 100644 index 00000000000..64391dc080b --- /dev/null +++ b/tests/components/ezsp_proxy_tap/common.yaml @@ -0,0 +1,15 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +# The port owns the UART and carries every byte; ezsp_proxy_tap only taps it +serial_proxy: + - id: ezsp_serial + name: Zigbee + port_type: TTL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_serial diff --git a/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4c64260e869 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test-usb.esp32-s3-idf.yaml @@ -0,0 +1,27 @@ +wifi: + ssid: MySSID + password: password1 + power_save_mode: none + +api: + +usb_host: + +usb_uart: + - type: CDC_ACM + vid: 0x303A + pid: 0x831A + channels: + - id: ezsp_usb_channel + baud_rate: 460800 + +# The tapped port may be a USB CDC ACM channel just as well as a hardware UART: +# ezsp_proxy_tap never touches the UART itself, so it does not care which it is. +serial_proxy: + - id: ezsp_usb_serial + uart_id: ezsp_usb_channel + name: Zigbee + port_type: USB_SERIAL + +ezsp_proxy_tap: + serial_proxy_id: ezsp_usb_serial diff --git a/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml new file mode 100644 index 00000000000..afdbf0e6760 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml new file mode 100644 index 00000000000..fd21fecf7f6 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + ezsp_proxy_tap: !include common.yaml diff --git a/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a979e3fda19 --- /dev/null +++ b/tests/components/ezsp_proxy_tap/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + ezsp_proxy_tap: !include common.yaml From 88668f448e0638149e000963867867c5b0d38b88 Mon Sep 17 00:00:00 2001 From: B48D81EFCC <111175947+B48D81EFCC@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:16 +0900 Subject: [PATCH 013/111] [max17048] Add MAX17048 Li+ Cell Fuel Gauge IC (#13585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Martín Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/max17048/__init__.py | 2 + esphome/components/max17048/max17048.cpp | 77 +++++++++++++++++++ esphome/components/max17048/max17048.h | 20 +++++ esphome/components/max17048/sensor.py | 66 ++++++++++++++++ tests/components/max17048/common.yaml | 8 ++ tests/components/max17048/test.esp32-idf.yaml | 3 + .../components/max17048/test.esp8266-ard.yaml | 3 + .../components/max17048/test.rp2040-ard.yaml | 3 + 9 files changed, 183 insertions(+) create mode 100644 esphome/components/max17048/__init__.py create mode 100644 esphome/components/max17048/max17048.cpp create mode 100644 esphome/components/max17048/max17048.h create mode 100644 esphome/components/max17048/sensor.py create mode 100644 tests/components/max17048/common.yaml create mode 100644 tests/components/max17048/test.esp32-idf.yaml create mode 100644 tests/components/max17048/test.esp8266-ard.yaml create mode 100644 tests/components/max17048/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 23bac1efae1..050f4d19d92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -343,6 +343,7 @@ esphome/components/m5stack_8angle/* @rnauber esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell +esphome/components/max17048/* @B48D81EFCC esphome/components/max31865/* @DAVe3283 esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger diff --git a/esphome/components/max17048/__init__.py b/esphome/components/max17048/__init__.py new file mode 100644 index 00000000000..ecd6a79dd5c --- /dev/null +++ b/esphome/components/max17048/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "max17048" diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp new file mode 100644 index 00000000000..67f9dbaac3d --- /dev/null +++ b/esphome/components/max17048/max17048.cpp @@ -0,0 +1,77 @@ +#include "max17048.h" +#include "esphome/core/log.h" + +namespace esphome::max17048 { + +// MAX17048/MAX17049 1 and 2 cell fuel gauges with ModelGauge +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf + +ESPHOME_LOG_TAG(TAG, "max17048"); + +static constexpr uint8_t REG_VCELL = 0x02; // cell voltage +static constexpr uint8_t REG_SOC = 0x04; // state of charge +static constexpr uint8_t REG_VERSION = 0x08; // 0x001x +static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed + +static constexpr uint16_t VERSION_MASK = 0xFFF0; +static constexpr uint16_t VERSION_FAMILY = 0x0010; + +static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f; +static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f; +static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f; + +void MAX17048Component::setup() { + uint16_t version; + if (!this->read_byte_16(REG_VERSION, &version)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((version & VERSION_MASK) != VERSION_FAMILY) { + ESP_LOGW(TAG, "Unexpected version 0x%04X", version); + } +} + +void MAX17048Component::update() { + bool ok = true; + uint16_t raw; + if (this->battery_voltage_sensor_ != nullptr) { + if (this->read_byte_16(REG_VCELL, &raw)) { + this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB); + } else { + ok = false; + } + } + if (this->battery_level_sensor_ != nullptr) { + if (this->read_byte_16(REG_SOC, &raw)) { + this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB); + } else { + ok = false; + } + } + if (this->rate_sensor_ != nullptr) { + if (this->read_byte_16(REG_CRATE, &raw)) { + this->rate_sensor_->publish_state(static_cast(raw) * CRATE_PERCENT_PER_HOUR_PER_LSB); + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Register read failed")); + } +} + +void MAX17048Component::dump_config() { + ESP_LOGCONFIG(TAG, "MAX17048:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Rate", this->rate_sensor_); +} + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/max17048.h b/esphome/components/max17048/max17048.h new file mode 100644 index 00000000000..906d4a7c3f3 --- /dev/null +++ b/esphome/components/max17048/max17048.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::max17048 { + +class MAX17048Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_level) + SUB_SENSOR(rate) + + public: + void setup() override; + void dump_config() override; + void update() override; +}; + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py new file mode 100644 index 00000000000..30cd0ec59f0 --- /dev/null +++ b/esphome/components/max17048/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BATTERY_VOLTAGE, + CONF_ID, + CONF_RATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +UNIT_PERCENT_PER_HOUR = "%/h" + +max17048_ns = cg.esphome_ns.namespace("max17048") +MAX17048Component = max17048_ns.class_( + "MAX17048Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MAX17048Component), + cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT_PER_HOUR, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x36)) +) + + +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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_RATE, var.set_rate_sensor) diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml new file mode 100644 index 00000000000..de0247ed4e2 --- /dev/null +++ b/tests/components/max17048/common.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: max17048 + battery_voltage: + name: Battery Voltage + battery_level: + name: Battery Level + rate: + name: Battery Rate diff --git a/tests/components/max17048/test.esp32-idf.yaml b/tests/components/max17048/test.esp32-idf.yaml new file mode 100644 index 00000000000..e9930c953d3 --- /dev/null +++ b/tests/components/max17048/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.esp8266-ard.yaml b/tests/components/max17048/test.esp8266-ard.yaml new file mode 100644 index 00000000000..95f0f76f143 --- /dev/null +++ b/tests/components/max17048/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + max17048: !include common.yaml diff --git a/tests/components/max17048/test.rp2040-ard.yaml b/tests/components/max17048/test.rp2040-ard.yaml new file mode 100644 index 00000000000..7f24480cdce --- /dev/null +++ b/tests/components/max17048/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + max17048: !include common.yaml From d08d324a8342897c9fce17e1d45cfc3a67375e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 00:54:35 +0200 Subject: [PATCH 014/111] [bq25186] Add bq25186 1-Cell, 1A Linear Battery Charger component (part 1) (#15700) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/bq25186/__init__.py | 314 ++++++++++++++++++ esphome/components/bq25186/bq25186.cpp | 79 +++++ esphome/components/bq25186/bq25186.h | 44 +++ tests/component_tests/bq25186/__init__.py | 0 .../bq25186/test_pack_registers.py | 87 +++++ tests/components/bq25186/common.yaml | 25 ++ tests/components/bq25186/test.esp32-idf.yaml | 3 + .../components/bq25186/test.esp8266-ard.yaml | 3 + 9 files changed, 556 insertions(+) create mode 100644 esphome/components/bq25186/__init__.py create mode 100644 esphome/components/bq25186/bq25186.cpp create mode 100644 esphome/components/bq25186/bq25186.h create mode 100644 tests/component_tests/bq25186/__init__.py create mode 100644 tests/component_tests/bq25186/test_pack_registers.py create mode 100644 tests/components/bq25186/common.yaml create mode 100644 tests/components/bq25186/test.esp32-idf.yaml create mode 100644 tests/components/bq25186/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 050f4d19d92..d5866286fdb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -101,6 +101,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py new file mode 100644 index 00000000000..e57860786f5 --- /dev/null +++ b/esphome/components/bq25186/__init__.py @@ -0,0 +1,314 @@ +from collections.abc import Callable +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, ID, Lambda +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@Rapsssito"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq25186" +MULTI_CONF = True + +CONF_CONFIGURE_ON_BOOT = "configure_on_boot" +CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" +CONF_FAST_CHARGE_CURRENT = "fast_charge_current" +CONF_CHARGE_ENABLED = "charge_enabled" +CONF_FLASH_CHARGING_MODE = "flash_charging_mode" +CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm" +CONF_TERMINATION_PERCENT = "termination_percent" +CONF_VINDPM_MODE = "vindpm_mode" +CONF_THERMAL_REGULATION = "thermal_regulation" +CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit" +CONF_BATTERY_UVLO = "battery_uvlo" +CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt" +CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt" +CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt" +CONF_TS_AUTO_FUNCTION = "ts_auto_function" +CONF_VLOWV = "vlowv" +CONF_RECHARGE_THRESHOLD = "recharge_threshold" +CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm" +CONF_SAFETY_TIMER = "safety_timer" +CONF_LONG_PRESS_DURATION = "long_press_duration" +CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin" +CONF_AUTOWAKE = "autowake" +CONF_INPUT_CURRENT_LIMIT = "input_current_limit" +CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action" +CONF_WAKE1_TIMER = "wake1_timer" +CONF_WAKE2_TIMER = "wake2_timer" +CONF_ENABLE_PUSH_BUTTON = "enable_push_button" +CONF_SYSTEM_REGULATION = "system_regulation" +CONF_SYS_MODE = "sys_mode" +CONF_DISABLE_VDPPM = "disable_vdppm" +CONF_TS_HOT = "ts_hot" +CONF_TS_COLD = "ts_cold" +CONF_TS_WARM_DISABLE = "ts_warm_disable" +CONF_TS_COOL_DISABLE = "ts_cool_disable" +CONF_TS_ICHG = "ts_ichg" +CONF_TS_VRCG = "ts_vrcg" +CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt" +CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt" +CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" +CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" + +bq25186_ns = cg.esphome_ns.namespace("bq25186") +BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) + +REG_VBAT_CTRL = 0x03 +REG_ICHG_CTRL = 0x04 +REG_CHARGECTRL0 = 0x05 +REG_CHARGECTRL1 = 0x06 +REG_IC_CTRL = 0x07 +REG_TMR_ILIM = 0x08 +REG_SHIP_RST = 0x09 +REG_SYS_REG = 0x0A +REG_TS_CONTROL = 0x0B +REG_MASK_ID = 0x0C +REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 +WATCHDOG_DISABLED = 0x03 + +TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} +VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} +THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3} +BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3} +BATTERY_UVLO_OPTIONS = { + "3.0v": 0, + "2.8v": 3, + "2.6v": 4, + "2.4v": 5, + "2.2v": 6, + "2.0v": 7, +} +VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} +RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} +SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} +AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} +INPUT_CURRENT_LIMIT_OPTIONS = { + "50ma": 0, + "100ma": 1, + "200ma": 2, + "300ma": 3, + "400ma": 4, + "500ma": 5, + "665ma": 6, + "1050ma": 7, +} +PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = { + "none": 0, + "hardware_reset": 1, + "ship_mode": 2, + "shutdown_mode": 3, +} +WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1} +WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1} +SYSTEM_REGULATION_OPTIONS = { + "battery_tracking": 0, + "4.4v": 1, + "4.5v": 2, + "4.6v": 3, + "4.7v": 4, + "4.8v": 5, + "4.9v": 6, + "pass_through": 7, +} +SYS_MODE_OPTIONS = { + "normal": 0, + "battery_only": 1, + "floating_off": 2, + "pulldown_off": 3, +} +TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3} +TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3} +TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1} +TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1} + + +def _on_voltage_step(volts: float) -> float: + millivolts = volts * 1000 + if abs(millivolts - round(millivolts / 10) * 10) > 1e-6: + raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV") + return volts + + +def _on_current_step(amps: float) -> float: + milliamps = amps * 1000 + step = 1 if milliamps < 36 else 10 + if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40: + raise cv.Invalid( + "fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA" + ) + return amps + + +# (config key, register, bit shift, validator or enum options, default). +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are +# driven at runtime, not from configuration. +FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( + ( + CONF_BATTERY_REGULATION_VOLTAGE, + REG_VBAT_CTRL, + 0, + cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step), + "4.2V", + ), + (CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True), + ( + CONF_FAST_CHARGE_CURRENT, + REG_ICHG_CTRL, + 0, + cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step), + "10mA", + ), + (CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False), + (CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False), + (CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"), + (CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"), + (CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"), + (CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"), + (CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"), + (CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True), + (CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True), + (CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False), + (CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True), + (CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"), + (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), + (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), + (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), + (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), + (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), + (CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"), + ( + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_SHIP_RST, + 3, + PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS, + "ship_mode", + ), + (CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"), + (CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"), + (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), + (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), + (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), + (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), + (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), + (CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False), + (CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False), + (CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"), + (CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"), + (CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False), + (CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True), + (CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False), + (CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False), +) + + +def _battery_regulation_code(volts: float) -> int: + """VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step.""" + return (round(volts * 1000) - 3500) // 10 + + +def _fast_charge_code(amps: float) -> int: + """ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA.""" + milliamps = round(amps * 1000) + if milliamps <= 35: + return milliamps - 5 + return milliamps // 10 + 27 + + +_ENCODERS: dict[str, Callable[[Any], int]] = { + CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code, + CONF_FAST_CHARGE_CURRENT: _fast_charge_code, + CONF_CHARGE_ENABLED: lambda enabled: not enabled, +} + + +def pack_registers(config: ConfigType) -> list[int]: + """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" + regs = [0] * REGISTER_COUNT + # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running + # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. + regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED + for key, register, shift, validator, _ in FIELDS: + value = config[key] + if isinstance(validator, dict): + value = validator[value] + elif (encode := _ENCODERS.get(key)) is not None: + value = encode(value) + regs[register - REG_VBAT_CTRL] |= int(value) << shift + return regs + + +def _field_schema(with_defaults: bool) -> cv.Schema: + schema = {} + for key, _, _, validator, default in FIELDS: + if isinstance(validator, dict): + validator = cv.enum(validator, lower=True) + optional = ( + cv.Optional(key, default=default) if with_defaults else cv.Optional(key) + ) + schema[optional] = validator + return cv.Schema(schema) + + +BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ25186Component), + cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean, + } + ) + .extend(BQ25186_CONFIG_SCHEMA) + .extend(i2c.i2c_device_schema(0x6A)) +) + +CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(BQ25186Component), + } + ).extend(_field_schema(with_defaults=False)) +) + + +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 config[CONF_CONFIGURE_ON_BOOT]: + cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) + + +@automation.register_action( + "bq25186.apply_configuration", + automation.LambdaAction, + CONFIGURE_ACTION_SCHEMA, + synchronous=True, +) +async def bq25186_configure_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + parent = await cg.get_variable(config[CONF_ID]) + + # Options the action leaves out fall back to the component's configuration + path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] + parent_config = CORE.config.get_config_for_path(path) + regs = pack_registers({**parent_config, **config}) + + text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) + lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + return automation.new_lambda_pvariable( + action_id, lambda_, automation.StatelessLambdaAction, template_arg + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp new file mode 100644 index 00000000000..e660ec63d14 --- /dev/null +++ b/esphome/components/bq25186/bq25186.cpp @@ -0,0 +1,79 @@ +#include "bq25186.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::bq25186 { + +ESPHOME_LOG_TAG(TAG, "bq25186"); + +// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, +// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, +}; + +bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGW(TAG, "Failed to write register 0x%02X", reg); + return false; + } + return true; +} + +bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t reg_value; + if (!this->read_byte(reg, ®_value)) { + ESP_LOGW(TAG, "Failed to read register 0x%02X", reg); + return false; + } + reg_value = (reg_value & ~mask) | (value & mask); + return this->write_register_(reg, reg_value); +} + +bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { + // Setup rejects an unknown chip; the action must not program it before that check or after it failed + if (!this->is_ready() || this->is_failed()) { + ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); + return false; + } + for (size_t i = 0; i < regs.size(); i++) { + const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); + const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + if (!ok) { + this->status_set_warning(LOG_STR("Configuration partly applied")); + return false; + } + } + this->status_clear_warning(); + return true; +} + +void BQ25186Component::setup() { + uint8_t mask_id; + if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Another charger at this address could lay its registers out differently, so do not write into it + const uint8_t device_id = mask_id & 0x0F; + if (device_id != 0x01) { + ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); + this->mark_failed(LOG_STR("Not a BQ25186")); + return; + } + + // A failed write only sets the warning status: the chip has identified itself, so a later + // apply_configuration may retry + if (this->configure_on_boot_) + this->apply_config(this->setup_regs_); +} + +void BQ25186Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ25186:"); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h new file mode 100644 index 00000000000..a6920871475 --- /dev/null +++ b/esphome/components/bq25186/bq25186.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq25186 { + +static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; +static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; +static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; +static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06; +static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07; +static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08; +static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09; +static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; +static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; +static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; +static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; + +// Register values for VBAT_CTRL..MASK_ID, packed by code generation +using BQ25186ConfigRegisters = std::array; + +class BQ25186Component : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_setup_registers(const BQ25186ConfigRegisters ®s) { + this->setup_regs_ = regs; + this->configure_on_boot_ = true; + } + bool apply_config(const BQ25186ConfigRegisters ®s); + + protected: + bool write_register_(uint8_t reg, uint8_t value); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + BQ25186ConfigRegisters setup_regs_{}; + bool configure_on_boot_{false}; +}; + +} // namespace esphome::bq25186 diff --git a/tests/component_tests/bq25186/__init__.py b/tests/component_tests/bq25186/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py new file mode 100644 index 00000000000..2ebf6087e7b --- /dev/null +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -0,0 +1,87 @@ +"""Tests for the bq25186 register packer.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.bq25186 import ( + BQ25186_CONFIG_SCHEMA, + CONF_BATTERY_REGULATION_VOLTAGE, + CONF_CHARGE_ENABLED, + CONF_FAST_CHARGE_CURRENT, + CONF_MASK_TS_INTERRUPT, + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_ICHG_CTRL, + REG_MASK_ID, + REG_SHIP_RST, + REG_VBAT_CTRL, + pack_registers, +) +from esphome.types import ConfigType + +# Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the +# watchdog disabled (0x87 instead of 0x84) and MASK_ID carries only its +# configurable bits 7:4 (0x40 instead of 0x41 with the device id). +DEFAULT_REGISTERS = [0x46, 0x05, 0x24, 0xC6, 0x87, 0x4D, 0x11, 0x40, 0x00, 0x40] + + +def _packed(reg: int, config: ConfigType | None = None) -> int: + regs = pack_registers(BQ25186_CONFIG_SCHEMA(config or {})) + return regs[reg - REG_VBAT_CTRL] + + +def test_defaults_match_power_on_values() -> None: + assert pack_registers(BQ25186_CONFIG_SCHEMA({})) == DEFAULT_REGISTERS + + +def test_battery_regulation_voltage_code() -> None: + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.1V"}) == 0x3C + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "3.5V"}) == 0x00 + assert _packed(REG_VBAT_CTRL, {CONF_BATTERY_REGULATION_VOLTAGE: "4.65V"}) == 0x73 + + +def test_fast_charge_current_code() -> None: + # 1 mA steps from 5 mA to 35 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "5mA"}) == 0x00 + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "20mA"}) == 0x0F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "35mA"}) == 0x1E + # 10 mA steps from 40 mA + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "40mA"}) == 0x1F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "200mA"}) == 0x2F + assert _packed(REG_ICHG_CTRL, {CONF_FAST_CHARGE_CURRENT: "1A"}) == 0x7F + + +def test_charge_disabled_sets_bit_7() -> None: + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: False}) == 0x85 + assert _packed(REG_ICHG_CTRL, {CONF_CHARGE_ENABLED: True}) == 0x05 + + +def test_masked_registers_only_carry_configurable_bits() -> None: + config: ConfigType = { + CONF_PUSH_BUTTON_LONG_PRESS_ACTION: "shutdown_mode", + CONF_MASK_TS_INTERRUPT: True, + } + # SHIP_RST bits 7:5 are reset triggers + ship_rst = _packed(REG_SHIP_RST, config) + assert ship_rst == 0x19 + assert ship_rst & ~0x1F == 0 + # MASK_ID bits 3:0 are the device id + mask_id = _packed(REG_MASK_ID, config) + assert mask_id == 0xC0 + assert mask_id & ~0xF0 == 0 + + +@pytest.mark.parametrize( + "key, value", + [ + (CONF_BATTERY_REGULATION_VOLTAGE, "4.105V"), + (CONF_FAST_CHARGE_CURRENT, "36mA"), + (CONF_FAST_CHARGE_CURRENT, "39mA"), + (CONF_FAST_CHARGE_CURRENT, "49mA"), + (CONF_FAST_CHARGE_CURRENT, "5.5mA"), + (CONF_FAST_CHARGE_CURRENT, "34.6mA"), + (CONF_BATTERY_REGULATION_VOLTAGE, "4.2005V"), + ], +) +def test_values_between_steps_are_rejected(key: str, value: str) -> None: + with pytest.raises(cv.Invalid): + BQ25186_CONFIG_SCHEMA({key: value}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml new file mode 100644 index 00000000000..9f4b117cd3d --- /dev/null +++ b/tests/components/bq25186/common.yaml @@ -0,0 +1,25 @@ +esphome: + on_boot: + priority: -100 + then: + # Just the defined configuration + - bq25186.apply_configuration: bq25186_charger + # Just the defined configuration with some overrides + - bq25186.apply_configuration: + id: bq25186_charger + battery_regulation_voltage: 4.1V + fast_charge_current: 200mA + +bq25186: + - id: bq25186_charger + configure_on_boot: false + fast_charge_current: 150mA + battery_regulation_voltage: 4.2V + input_current_limit: 500ma + system_regulation: 4.5v + charge_enabled: true + # Second charger with the boot-time write enabled + - id: bq25186_boot + address: 0x6B + configure_on_boot: true + fast_charge_current: 20mA diff --git a/tests/components/bq25186/test.esp32-idf.yaml b/tests/components/bq25186/test.esp32-idf.yaml new file mode 100644 index 00000000000..8854333e249 --- /dev/null +++ b/tests/components/bq25186/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq25186: !include common.yaml diff --git a/tests/components/bq25186/test.esp8266-ard.yaml b/tests/components/bq25186/test.esp8266-ard.yaml new file mode 100644 index 00000000000..88ac37a74fa --- /dev/null +++ b/tests/components/bq25186/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq25186: !include common.yaml From 1e13b7f9736112cfc127e07e9d714874eae22054 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 13 Oct 2026 00:56:28 +0200 Subject: [PATCH 015/111] [tinyusb] Add `tinyusb_keyboard` component (#16079) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/tinyusb/__init__.py | 2 +- .../components/tinyusb/tinyusb_component.cpp | 3 + .../components/tinyusb/tinyusb_component.h | 2 + .../components/tinyusb_keyboard/__init__.py | 110 ++++++++++++++++++ .../components/tinyusb_keyboard/keyboard.cpp | 90 ++++++++++++++ .../components/tinyusb_keyboard/keyboard.h | 66 +++++++++++ tests/components/tinyusb_keyboard/common.yaml | 43 +++++++ .../tinyusb_keyboard/test.esp32-s3-idf.yaml | 2 + 9 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 esphome/components/tinyusb_keyboard/__init__.py create mode 100644 esphome/components/tinyusb_keyboard/keyboard.cpp create mode 100644 esphome/components/tinyusb_keyboard/keyboard.h create mode 100644 tests/components/tinyusb_keyboard/common.yaml create mode 100644 tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d5866286fdb..d18ad6e6b4f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -605,6 +605,7 @@ esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 +esphome/components/tinyusb_keyboard/* @frederikbolding esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 586b69d603b..92a8e439d2a 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -34,7 +34,7 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # esp_tinyusb's driver install (descriptors_set fails with no class and no # user-provided full_speed_config), which trips the task watchdog before # loop() ever runs. -_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) +_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard") tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 14291fb3284..ba2fbded92e 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -26,6 +26,8 @@ void TinyUSB::setup() { } // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + // Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset. + const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config; this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; @@ -39,6 +41,7 @@ void TinyUSB::setup() { .device = &this->usb_descriptor_, .string = this->string_descriptor_, .string_count = SIZE, + .full_speed_config = full_speed_config, }; // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 030356bc03e..e9f7283b01d 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -49,6 +49,8 @@ class TinyUSB final : public Component { void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } + /// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID). + void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english diff --git a/esphome/components/tinyusb_keyboard/__init__.py b/esphome/components/tinyusb_keyboard/__init__.py new file mode 100644 index 00000000000..18b49a95bd3 --- /dev/null +++ b/esphome/components/tinyusb_keyboard/__init__.py @@ -0,0 +1,110 @@ +import string + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, +) +from esphome.components.tinyusb import TinyUSB +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@frederikbolding"] +# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces +CONFLICTS_WITH = ["usb_cdc_acm"] +DEPENDENCIES = ["tinyusb"] +DOMAIN = "tinyusb_keyboard" + +CONF_MODIFIERS = "modifiers" +CONF_TINYUSB_ID = "tinyusb_id" +CONF_USAGE = "usage" + +# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0 +HID_KEY_A = 0x04 +KEY_CODES = { + char: HID_KEY_A + index + for index, char in enumerate(string.ascii_lowercase + "1234567890") +} + +tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard") +TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TinyUSBKeyboard), + cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), +) + +_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255)) + + +def key_code(value: object) -> int: + """A single lowercase letter or digit, or a keyboard usage id. + + Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted + digit is read as that digit. + """ + if isinstance(value, int) and 0 <= value <= 9: + value = str(value) + return _KEY_CODE(value) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID]) + cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR)) + add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1) + + +KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)} + +automation.register_apply_action( + "tinyusb_keyboard.press", + automation.maybe_simple_id( + { + **KEYBOARD_ID_SCHEMA, + cv.Required(CONF_KEY): cv.templatable(key_code), + cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t), + } + ), + automation.ApplyCall( + "press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8)) + ), +) +automation.register_apply_action( + "tinyusb_keyboard.release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_keys()"), +) +automation.register_apply_action( + "tinyusb_keyboard.media_press", + automation.maybe_simple_id( + {**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)} + ), + automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)), +) +automation.register_apply_action( + "tinyusb_keyboard.media_release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_media()"), +) diff --git a/esphome/components/tinyusb_keyboard/keyboard.cpp b/esphome/components/tinyusb_keyboard/keyboard.cpp new file mode 100644 index 00000000000..6d2be7f7eba --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.cpp @@ -0,0 +1,90 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "keyboard.h" +#include "esphome/core/log.h" + +namespace esphome::tinyusb_keyboard { + +ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard"); + +void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); } + +void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) { + this->keycode_ = keycode; + this->modifiers_ = modifiers; + this->keyboard_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::set_consumer_(uint16_t usage) { + this->usage_ = usage; + this->consumer_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::flush_() { + if (!tud_mounted()) { + // No host is listening, and a key held from before it attached would be wrong, so drop the state + ESP_LOGV(TAG, "No host, dropping report"); + this->keyboard_pending_ = false; + this->consumer_pending_ = false; + this->wakeup_requested_ = false; + this->disable_loop(); + return; + } + if (tud_suspended()) { + // A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes. + // Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did + // must not be signalled on every pass. + if (!this->wakeup_requested_) { + this->wakeup_requested_ = true; + if (!tud_remote_wakeup()) { + ESP_LOGV(TAG, "Host did not allow remote wakeup"); + } + } + this->enable_loop(); + return; + } + this->wakeup_requested_ = false; + // tud_hid_*_report() returns false while the endpoint still holds the previous report, which is + // what happens for a press and a release in one action list. Keep retrying so the release gets out. + if (this->keyboard_pending_) { + uint8_t keycodes[6] = {this->keycode_}; + if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) { + this->keyboard_pending_ = false; + } + } + if (this->consumer_pending_ && !this->keyboard_pending_ && + tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) { + this->consumer_pending_ = false; + } + if (this->keyboard_pending_ || this->consumer_pending_) { + this->enable_loop(); + } else { + this->disable_loop(); + } +} + +void TinyUSBKeyboard::loop() { this->flush_(); } + +} // namespace esphome::tinyusb_keyboard + +// TinyUSB resolves these at link time when HID is enabled +extern "C" { +const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) { + return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR; +} + +uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + uint8_t * /*buffer*/, uint16_t /*reqlen*/) { + return 0; +} + +// Keyboard LED state from the host; nothing consumes it yet +void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {} +} + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/keyboard.h b/esphome/components/tinyusb_keyboard/keyboard.h new file mode 100644 index 00000000000..4c86ee599c2 --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.h @@ -0,0 +1,66 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "esphome/core/component.h" + +#include "tusb.h" +// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do +#include "class/hid/hid_device.h" + +namespace esphome::tinyusb_keyboard { + +static constexpr uint8_t REPORT_ID_KEYBOARD = 1; +static constexpr uint8_t REPORT_ID_CONSUMER = 2; +static constexpr uint8_t ENDPOINT_IN = 0x81; +static constexpr uint8_t POLL_INTERVAL_MS = 10; + +// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id. +// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's +// composite example does. +inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), +}; + +// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own +inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE, + POLL_INTERVAL_MS), +}; + +class TinyUSBKeyboard final : public Component { + public: + void setup() override { this->disable_loop(); } + void loop() override; + void dump_config() override; + + /// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released. + void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); } + void release_keys() { this->set_keyboard_(0, 0); } + /// Press one consumer control usage, for example 0xE9 for volume up. + void press_media(uint16_t usage) { this->set_consumer_(usage); } + void release_media() { this->set_consumer_(0); } + + protected: + void set_keyboard_(uint8_t keycode, uint8_t modifiers); + void set_consumer_(uint16_t usage); + /// Sends whatever changed; a report the endpoint cannot take yet is retried from loop() + void flush_(); + + // The wanted state of each report, resent until the endpoint accepts it so a release is never lost. + // Only the latest state is kept, so consecutive key actions need a short delay between them. + uint16_t usage_{0}; + uint8_t keycode_{0}; + uint8_t modifiers_{0}; + bool keyboard_pending_{false}; + bool consumer_pending_{false}; + bool wakeup_requested_{false}; +}; + +} // namespace esphome::tinyusb_keyboard + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/tests/components/tinyusb_keyboard/common.yaml b/tests/components/tinyusb_keyboard/common.yaml new file mode 100644 index 00000000000..8982d61b4a2 --- /dev/null +++ b/tests/components/tinyusb_keyboard/common.yaml @@ -0,0 +1,43 @@ +tinyusb: + +tinyusb_keyboard: + id: kb + +binary_sensor: + - platform: template + id: kb_letter + on_press: + - tinyusb_keyboard.press: + id: kb + key: a + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_shift_digit + on_press: + - tinyusb_keyboard.press: + id: kb + key: "1" + modifiers: 0x02 # Left Shift + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_raw_code + on_press: + - tinyusb_keyboard.press: + id: kb + key: 0x28 # Enter + modifiers: !lambda return 0; + on_release: + - tinyusb_keyboard.release: kb + + - platform: template + id: kb_volume_up + on_press: + - tinyusb_keyboard.media_press: + id: kb + usage: 0xE9 # Volume Up + on_release: + - tinyusb_keyboard.media_release: kb diff --git a/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..763cc106fcc --- /dev/null +++ b/tests/components/tinyusb_keyboard/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + tinyusb_keyboard: !include common.yaml From 150e32c4bedf3e86d9b4e40b6a98881356b306c3 Mon Sep 17 00:00:00 2001 From: Celsius Aray Date: Mon, 12 Oct 2026 18:56:50 -0400 Subject: [PATCH 016/111] [renogy_inverter_ble] Add Renogy inverter BLE sensor component (#16754) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../renogy_inverter_ble/__init__.py | 40 +++ .../renogy_inverter_ble.cpp | 248 ++++++++++++++++++ .../renogy_inverter_ble/renogy_inverter_ble.h | 59 +++++ .../components/renogy_inverter_ble/sensor.py | 99 +++++++ .../renogy_inverter_ble/common.yaml | 31 +++ .../renogy_inverter_ble/test.esp32-idf.yaml | 3 + 7 files changed, 481 insertions(+) create mode 100644 esphome/components/renogy_inverter_ble/__init__.py create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp create mode 100644 esphome/components/renogy_inverter_ble/renogy_inverter_ble.h create mode 100644 esphome/components/renogy_inverter_ble/sensor.py create mode 100644 tests/components/renogy_inverter_ble/common.yaml create mode 100644 tests/components/renogy_inverter_ble/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index d18ad6e6b4f..826c5e70038 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py new file mode 100644 index 00000000000..808f27a67a8 --- /dev/null +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import ble_client +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["ble_client"] +DOMAIN = "renogy_inverter_ble" +MULTI_CONF = True + +CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id" + +renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN) +RenogyInverterBle = renogy_inverter_ble_ns.class_( + "RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent +) + +RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RenogyInverterBle), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp new file mode 100644 index 00000000000..4cb43b5efbd --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp @@ -0,0 +1,248 @@ +#include "renogy_inverter_ble.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::renogy_inverter_ble { + +ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble"); + +static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0; +static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1; +static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0; +static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1; +static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4; + +static constexpr uint8_t MODBUS_DEVICE_ID = 0x20; +static constexpr uint8_t MODBUS_READ_HOLDING = 0x03; +static constexpr uint16_t REG_MAIN = 4000; +static constexpr uint16_t REG_MAIN_WORDS = 32; +static constexpr uint16_t REG_LOAD = 4408; +static constexpr uint16_t REG_LOAD_WORDS = 6; + +// Watchdog: returns the cycle to IDLE when the inverter stops answering +static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000; +// Gap the inverter needs between two consecutive register reads +static constexpr uint32_t LOAD_READ_DELAY_MS = 300; + +static constexpr uint32_t CYCLE_TIMEOUT_ID = 0; +static constexpr uint32_t LOAD_READ_ID = 1; + +static void publish(sensor::Sensor *s, float value) { + if (s != nullptr) { + s->publish_state(value); + } +} + +void RenogyInverterBle::dump_config() { + ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_); + LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_); + LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_); + LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_); + LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_); + LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Load current", this->load_current_sensor_); + LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_); + LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_); +} + +void RenogyInverterBle::abort_cycle_() { + this->cancel_timeout(CYCLE_TIMEOUT_ID); + this->cancel_timeout(LOAD_READ_ID); + this->frame_len_ = 0; + this->state_ = State::IDLE; +} + +void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) { + std::array req{MODBUS_DEVICE_ID, + MODBUS_READ_HOLDING, + static_cast(start_register >> 8), + static_cast(start_register), + static_cast(word_count >> 8), + static_cast(word_count)}; + const uint16_t crc = crc16(req.data(), 6); + req[6] = crc & 0xFF; // Modbus sends the CRC low byte first + req[7] = crc >> 8; + + this->frame_len_ = 0; + const esp_err_t status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_, + req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::start_cycle_() { + this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast(this->state_)); + this->abort_cycle_(); + }); + if (this->init_handle_ == 0) { + // No init characteristic discovered; try the main read directly + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + return; + } + this->state_ = State::INIT; + const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->init_handle_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "init read_char failed, status=%d", status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::update() { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "Not connected yet; skipping poll"); + return; + } + if (this->state_ != State::IDLE) { + ESP_LOGD(TAG, "Previous read cycle still in progress; skipping"); + return; + } + this->start_cycle_(); +} + +void RenogyInverterBle::on_frame_complete_(uint16_t len) { + // A Modbus frame with its CRC appended checks to zero + if (len < 5 || crc16(this->frame_.data(), len) != 0) { + ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast(this->state_), len); + this->abort_cycle_(); + return; + } + // Exception response: function code with the high bit set, third byte is the exception code + if ((this->frame_[1] & 0x80) != 0) { + ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast(this->state_)); + this->abort_cycle_(); + return; + } + // Only the reply to the outstanding read is accepted: same device, same function, the requested word count + const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD; + const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS; + if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING || + this->frame_[2] != 2 * expected_words) { + ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2], + static_cast(this->state_)); + this->abort_cycle_(); + return; + } + auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); }; + + if (this->state_ == State::MAIN) { + publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f); + publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f); + publish(this->ac_output_current_sensor_, reg16(3) * 0.01f); + publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f); + publish(this->battery_voltage_sensor_, reg16(5) * 0.1f); + publish(this->temperature_sensor_, reg16(6) * 0.1f); + publish(this->input_frequency_sensor_, reg16(9) * 0.01f); + this->frame_len_ = 0; + this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() { + this->state_ = State::LOAD; + this->read_register_(REG_LOAD, REG_LOAD_WORDS); + }); + return; + } + publish(this->load_current_sensor_, reg16(0) * 0.01f); + publish(this->load_active_power_sensor_, static_cast(reg16(1))); + publish(this->load_apparent_power_sensor_, static_cast(reg16(2))); + this->abort_cycle_(); +} + +void RenogyInverterBle::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + this->abort_cycle_(); + this->node_state = espbt::ClientState::IDLE; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID)); + auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID)); + auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID)); + if (notify_chr == nullptr || write_chr == nullptr) { + ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?"); + break; + } + this->notify_handle_ = notify_chr->handle; + this->write_handle_ = write_chr->handle; + this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0; + ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_, + this->init_handle_); + const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_); + if (status != ESP_OK) { + ESP_LOGW(TAG, "register_for_notify failed, status=%d", status); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + // Other nodes on the same client see this event too + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + ESP_LOGI(TAG, "Connected; will poll the inverter every update interval"); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (this->state_ != State::INIT || param->read.handle != this->init_handle_) { + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "init read failed, status=%d", param->read.status); + this->abort_cycle_(); + break; + } + // Init handshake done; the inverter now answers Modbus reads + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + break; + } + const uint16_t value_len = param->notify.value_len; + if (this->frame_len_ + value_len > MAX_FRAME) { + ESP_LOGW(TAG, "Frame overflow; resetting"); + this->abort_cycle_(); + break; + } + memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len); + this->frame_len_ += value_len; + if (this->frame_len_ < 3) { + break; + } + // An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is + // [id][func][byte_count][...data][crc][crc] + const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2; + if (this->frame_len_ >= expected_len) { + this->on_frame_complete_(expected_len); + } + break; + } + default: + break; + } +} + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h new file mode 100644 index 00000000000..41bcdddf4ba --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::renogy_inverter_ble { + +namespace espbt = esphome::esp32_ble_tracker; + +// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes +static constexpr size_t MAX_FRAME = 69; + +// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1 +// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03. +class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(ac_input_voltage) + SUB_SENSOR(ac_output_voltage) + SUB_SENSOR(ac_output_current) + SUB_SENSOR(ac_output_frequency) + SUB_SENSOR(input_frequency) + SUB_SENSOR(battery_voltage) + SUB_SENSOR(temperature) + SUB_SENSOR(load_current) + SUB_SENSOR(load_active_power) + SUB_SENSOR(load_apparent_power) + + public: + void dump_config() override; + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + + protected: + // Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE + enum class State : uint8_t { IDLE, INIT, MAIN, LOAD }; + + void start_cycle_(); + void read_register_(uint16_t start_register, uint16_t word_count); + void on_frame_complete_(uint16_t len); + void abort_cycle_(); + + State state_{State::IDLE}; + uint8_t frame_len_{0}; + uint16_t notify_handle_{0}; + uint16_t write_handle_{0}; + uint16_t init_handle_{0}; + // Reassembles the Modbus response, which arrives in MTU-sized notification chunks + std::array frame_; +}; + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/sensor.py b/esphome/components/renogy_inverter_ble/sensor.py new file mode 100644 index 00000000000..9b87cc6503b --- /dev/null +++ b/esphome/components/renogy_inverter_ble/sensor.py @@ -0,0 +1,99 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_VOLTAGE, + CONF_TEMPERATURE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_HERTZ, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) +from esphome.types import ConfigType + +from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA + +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["renogy_inverter_ble"] + +CONF_AC_INPUT_VOLTAGE = "ac_input_voltage" +CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage" +CONF_AC_OUTPUT_CURRENT = "ac_output_current" +CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency" +CONF_INPUT_FREQUENCY = "input_frequency" +CONF_LOAD_CURRENT = "load_current" +CONF_LOAD_ACTIVE_POWER = "load_active_power" +CONF_LOAD_APPARENT_POWER = "load_apparent_power" + +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend( + { + cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor) + await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor) + await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor) + await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor) + await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor) + await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor) + await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor) + await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor) diff --git a/tests/components/renogy_inverter_ble/common.yaml b/tests/components/renogy_inverter_ble/common.yaml new file mode 100644 index 00000000000..a4f6e8e16fc --- /dev/null +++ b/tests/components/renogy_inverter_ble/common.yaml @@ -0,0 +1,31 @@ +ble_client: + - mac_address: 14:9C:EF:01:02:03 + id: inverter_client + +renogy_inverter_ble: + - id: inv0 + ble_client_id: inverter_client + +sensor: + - platform: renogy_inverter_ble + renogy_inverter_ble_id: inv0 + ac_input_voltage: + name: AC input voltage + ac_output_voltage: + name: AC output voltage + ac_output_current: + name: AC output current + ac_output_frequency: + name: AC output frequency + input_frequency: + name: Input frequency + battery_voltage: + name: Battery voltage + temperature: + name: Temperature + load_current: + name: Load current + load_active_power: + name: Load active power + load_apparent_power: + name: Load apparent power diff --git a/tests/components/renogy_inverter_ble/test.esp32-idf.yaml b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml new file mode 100644 index 00000000000..5ef5be8856c --- /dev/null +++ b/tests/components/renogy_inverter_ble/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + renogy_inverter_ble: !include common.yaml From 54fdeb7b1b6c327a4b27bdb14aa1b61e6c93e396 Mon Sep 17 00:00:00 2001 From: Georgi Filipov Date: Tue, 13 Oct 2026 02:01:22 +0300 Subject: [PATCH 017/111] [samsung] Add climate IR samsung support (#8004) Co-authored-by: Georgi Filipov Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/samsung/__init__.py | 1 + esphome/components/samsung/climate.py | 16 ++ esphome/components/samsung/samsung.cpp | 235 ++++++++++++++++++ esphome/components/samsung/samsung.h | 31 +++ tests/components/samsung/common.yaml | 5 + tests/components/samsung/test.esp32-idf.yaml | 4 + .../components/samsung/test.esp8266-ard.yaml | 4 + 8 files changed, 297 insertions(+) create mode 100644 esphome/components/samsung/__init__.py create mode 100644 esphome/components/samsung/climate.py create mode 100644 esphome/components/samsung/samsung.cpp create mode 100644 esphome/components/samsung/samsung.h create mode 100644 tests/components/samsung/common.yaml create mode 100644 tests/components/samsung/test.esp32-idf.yaml create mode 100644 tests/components/samsung/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 826c5e70038..c74ae5616bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -492,6 +492,7 @@ esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti +esphome/components/samsung/* @jorofi esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core esphome/components/sdl/* @bdm310 @clydebarrow diff --git a/esphome/components/samsung/__init__.py b/esphome/components/samsung/__init__.py new file mode 100644 index 00000000000..fb22727b36a --- /dev/null +++ b/esphome/components/samsung/__init__.py @@ -0,0 +1 @@ +DOMAIN = "samsung" diff --git a/esphome/components/samsung/climate.py b/esphome/components/samsung/climate.py new file mode 100644 index 00000000000..ae59d17f96a --- /dev/null +++ b/esphome/components/samsung/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.types import ConfigType + +CODEOWNERS = ["@jorofi"] + +AUTO_LOAD = ["climate_ir"] + +samsung_ns = cg.esphome_ns.namespace("samsung") +SamsungClimate = samsung_ns.class_("SamsungClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SamsungClimate) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp new file mode 100644 index 00000000000..65f1cd10d6f --- /dev/null +++ b/esphome/components/samsung/samsung.cpp @@ -0,0 +1,235 @@ +#include "samsung.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::samsung { + +ESPHOME_LOG_TAG(TAG, "samsung.climate"); + +static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; +static constexpr uint32_t HEADER_MARK_US = 3000; +static constexpr uint32_t HEADER_SPACE_US = 9000; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t ONE_SPACE_US = 1500; +static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr uint32_t SECTION_SPACE_US = 2000; + +static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; + +// A powered-on unit at 24 °C, cool mode, fan auto, swing off (from IRremoteESP8266); checksums are refilled on send +static const uint8_t RESET_STATE[SAMSUNG_AC_STATE_LENGTH] PROGMEM = {0x02, 0x92, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x01, 0xE2, 0xFE, 0x71, 0x80, 0x11, 0xF0}; +// Middle section of a power on/off frame: an all-zero timer block +static const uint8_t TIMER_SECTION[SAMSUNG_AC_SECTION_LENGTH] PROGMEM = {0x01, 0xD2, 0x0F, 0x00, 0x00, 0x00, 0x00}; + +// Where each setting lives in the 14 byte state +struct Field { + uint8_t byte; + uint8_t shift; + uint8_t mask; +}; +static constexpr Field POWER_1{6, 4, 0b11}; +static constexpr Field SWING{9, 4, 0b111}; +static constexpr Field TEMP{11, 4, 0b1111}; +static constexpr Field FAN{12, 1, 0b111}; +static constexpr Field MODE{12, 4, 0b111}; +static constexpr Field POWER_2{13, 4, 0b11}; +static constexpr uint8_t POWER_ON = 0b11; + +static uint8_t get_field(const uint8_t *state, Field field) { return (state[field.byte] >> field.shift) & field.mask; } +static void set_field(uint8_t *state, Field field, uint8_t value) { + state[field.byte] = (state[field.byte] & ~(field.mask << field.shift)) | ((value & field.mask) << field.shift); +} +static bool is_powered_on(const uint8_t *state) { + return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON; +} + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +// The first entry of each table is the fallback in both directions +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2}, + {climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0}, + {climate::CLIMATE_FAN_LOW, 2}, + {climate::CLIMATE_FAN_MEDIUM, 4}, + {climate::CLIMATE_FAN_HIGH, 5}, +}; +static constexpr Code SWINGS[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0b111}, + {climate::CLIMATE_SWING_VERTICAL, 0b010}, + {climate::CLIMATE_SWING_HORIZONTAL, 0b011}, + {climate::CLIMATE_SWING_BOTH, 0b100}, +}; + +// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM. +static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) { + Code entry{}; + for (size_t i = 0; i < count; i++) { + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + progmem_memcpy(&entry, table, sizeof(entry)); + return entry; +} +template static uint8_t to_code(const Code (&table)[N], uint8_t value) { + return find_entry(table, N, value, false).code; +} +template static T from_code(const Code (&table)[N], uint8_t code) { + return static_cast(find_entry(table, N, code, true).value); +} + +// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself. +// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947 +static uint8_t section_checksum(const uint8_t *section) { + uint32_t ones = + __builtin_popcount(section[0]) + __builtin_popcount(section[1] & 0x0F) + __builtin_popcount(section[2] & 0xF0); + for (uint8_t i = 3; i < SAMSUNG_AC_SECTION_LENGTH; i++) { + ones += __builtin_popcount(section[i]); + } + return ~static_cast(ones); +} +static uint8_t stored_checksum(const uint8_t *section) { return (section[1] >> 4) | (section[2] << 4); } +static void store_checksum(uint8_t *section) { + const uint8_t sum = section_checksum(section); + section[1] = (section[1] & 0x0F) | (sum << 4); + section[2] = (section[2] & 0xF0) | (sum >> 4); +} + +SamsungClimate::SamsungClimate() + : climate_ir::ClimateIR( + SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}) { + progmem_memcpy(this->state_.data(), RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + // The unit's power state is unknown at boot, so the first "on" command sends a power frame + set_field(this->state_.data(), POWER_1, 0); + set_field(this->state_.data(), POWER_2, 0); +} + +void SamsungClimate::transmit_state() { + uint8_t *state = this->state_.data(); + const bool was_on = is_powered_on(state); + const bool on = this->mode != climate::CLIMATE_MODE_OFF; + + if (on) { + progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + set_field(state, MODE, to_code(MODES, this->mode)); + } // else keep the last mode so the power off frame still carries a valid state + + const uint8_t temp = clamp(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX); + set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN); + set_field(state, SWING, to_code(SWINGS, this->swing_mode)); + set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO))); + set_field(state, POWER_1, on ? POWER_ON : 0); + set_field(state, POWER_2, on ? POWER_ON : 0); + + if (on != was_on) { + // A power change is sent as an extended frame with the timer block between the two state sections + uint8_t timer[SAMSUNG_AC_SECTION_LENGTH]; + progmem_memcpy(timer, TIMER_SECTION, SAMSUNG_AC_SECTION_LENGTH); + const uint8_t *sections[] = {state, timer, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 3); + } else { + const uint8_t *sections[] = {state, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 2); + } +} + +void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t count) { + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Header(2) + (sections - 1) * separator(4) + bits(2 each) + trailing mark(1) + data->reserve(2 + (count - 1) * 4 + count * SAMSUNG_AC_SECTION_LENGTH * 8 * 2 + 1); + data->set_carrier_frequency(CARRIER_FREQUENCY_HZ); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + + for (uint8_t s = 0; s < count; s++) { + if (s != 0) { + data->item(BIT_MARK_US, SECTION_SPACE_US); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + } + uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; + memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); + store_checksum(section); + for (uint8_t byte : section) { + for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { + data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); + } + } + } + + data->mark(BIT_MARK_US); + transmit.perform(); +} + +bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { + if (!data.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) { + return false; + } + + // Decode into a local buffer so a bad frame leaves the stored state untouched + uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t length = EXTENDED_LENGTH; + for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { + if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + const bool have_separator = + data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); + if (!have_separator) { + // A third section is optional; a missing separator after the second one ends the frame + if (i >= SAMSUNG_AC_STATE_LENGTH) { + length = i; + break; + } + ESP_LOGW(TAG, "Missing section separator before byte %u", i); + return false; + } + } + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { + frame[i] |= 1 << bit; + } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { + ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); + return false; + } + } + } + if (!data.expect_mark(BIT_MARK_US)) { + ESP_LOGVV(TAG, "Footer fail"); + return false; + } + for (uint8_t offset = 0; offset < length; offset += SAMSUNG_AC_SECTION_LENGTH) { + if (stored_checksum(frame + offset) != section_checksum(frame + offset)) { + ESP_LOGW(TAG, "Checksum mismatch in section %u", offset / SAMSUNG_AC_SECTION_LENGTH); + return false; + } + } + + // In an extended frame the middle section is the timer block and the last one the second state section + uint8_t *state = this->state_.data(); + memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH); + memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH); + + this->swing_mode = from_code(SWINGS, get_field(state, SWING)); + this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN; + this->fan_mode = from_code(FANS, get_field(state, FAN)); + this->mode = + is_powered_on(state) ? from_code(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state))); + this->publish_state(); + return true; +} + +} // namespace esphome::samsung diff --git a/esphome/components/samsung/samsung.h b/esphome/components/samsung/samsung.h new file mode 100644 index 00000000000..608cffd0287 --- /dev/null +++ b/esphome/components/samsung/samsung.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate_ir/climate_ir.h" + +#include + +namespace esphome::samsung { + +static constexpr uint8_t SAMSUNG_AC_TEMP_MIN = 16; +static constexpr uint8_t SAMSUNG_AC_TEMP_MAX = 30; +// A frame is made of 7 byte sections; the normal state is two sections and a power change adds a timer section +static constexpr uint8_t SAMSUNG_AC_SECTION_LENGTH = 7; +static constexpr uint8_t SAMSUNG_AC_STATE_LENGTH = 2 * SAMSUNG_AC_SECTION_LENGTH; + +class SamsungClimate : public climate_ir::ClimateIR { + public: + SamsungClimate(); + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Send the given sections as one frame, each with its checksum filled in + void send_sections_(const uint8_t *const *sections, uint8_t count); + + // The last state sent or received; its power bits say whether the next frame has to switch power + std::array state_{}; +}; + +} // namespace esphome::samsung diff --git a/tests/components/samsung/common.yaml b/tests/components/samsung/common.yaml new file mode 100644 index 00000000000..534500ca3af --- /dev/null +++ b/tests/components/samsung/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: samsung + name: SAMSUNG Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/samsung/test.esp32-idf.yaml b/tests/components/samsung/test.esp32-idf.yaml new file mode 100644 index 00000000000..193f830e47e --- /dev/null +++ b/tests/components/samsung/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + samsung: !include common.yaml diff --git a/tests/components/samsung/test.esp8266-ard.yaml b/tests/components/samsung/test.esp8266-ard.yaml new file mode 100644 index 00000000000..451bf93b94e --- /dev/null +++ b/tests/components/samsung/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + samsung: !include common.yaml From 81c275777d9212622f64e295c99fcd5b578aa115 Mon Sep 17 00:00:00 2001 From: Dennis Gaida <2392217+DennisGaida@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:08:41 +0200 Subject: [PATCH 018/111] [bmi270] Add optional BMM150 magnetometer support via aux interface (#18436) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/bmi270/bmi270.cpp | 195 ++++++++++++++++-- esphome/components/bmi270/bmi270.h | 118 +++++++++-- esphome/components/bmi270/bmm150_aux.h | 123 +++++++++++ esphome/components/bmi270/motion.py | 17 ++ esphome/components/bmi270/sensor.py | 88 ++++++-- esphome/core/defines.h | 1 + tests/components/bmi270/common-plain.yaml | 11 + tests/components/bmi270/common.yaml | 16 ++ .../bmi270/test-plain.esp32-idf.yaml | 3 + tests/components/bmi270/test.esp32-idf.yaml | 4 +- 10 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 esphome/components/bmi270/bmm150_aux.h create mode 100644 tests/components/bmi270/common-plain.yaml create mode 100644 tests/components/bmi270/test-plain.esp32-idf.yaml diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index a1709c1fa56..37f718fb7eb 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -5,12 +5,16 @@ namespace esphome::bmi270 { +#ifdef USE_BMI270_BMM150 +using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions +#endif + ESPHOME_LOG_TAG(TAG, "bmi270"); #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const size_t MAX_I2C_BUFFER_SIZE = 32; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else -static const size_t MAX_I2C_BUFFER_SIZE = 256; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 256; #endif // Configuration blob upload @@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256; bool BMI270Component::load_config_file_() { // 1. Disable advanced power-save so the config port is accessible - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF)) return false; delay(1); // 2. Prepare config load: write 0x00 to INIT_CTRL to start - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START)) return false; // 3. Burst-write the config in pages @@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() { } // 4. Signal end of config load - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE)) return false; delay(20); // spec: wait ≥20 ms for init to complete @@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() { uint8_t status = 0; if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) return false; - if ((status & 0x0F) != 0x01) { + if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) { ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); return false; } @@ -84,7 +88,7 @@ void BMI270Component::setup() { ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) - if (!this->write_byte(0x7E, 0xB6)) { + if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { this->mark_failed(); return; } @@ -100,7 +104,8 @@ void BMI270Component::setup() { // 5. Configure accelerometer // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) - uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + uint8_t acc_conf = + (uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE; if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { this->mark_failed(); return; @@ -112,7 +117,8 @@ void BMI270Component::setup() { // 6. Configure gyroscope // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) - uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | + BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF; if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { this->mark_failed(); return; @@ -124,7 +130,7 @@ void BMI270Component::setup() { // 7. Enable accelerometer, gyroscope, and temperature sensor // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] - if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) { this->mark_failed(); return; } @@ -132,14 +138,133 @@ void BMI270Component::setup() { // 8. Re-enable advanced power save (optional; keeps current low between reads) // Disabled here for simplicity – leave in performance mode - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup this->mark_failed(); return; } + // 9. Optionally bring up whatever is wired to the aux interface. + // This is entirely additive: it is off by default, and failure only disables + // magnetometer reporting rather than the whole component. +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + this->magnetometer_ready_ = this->setup_magnetometer_(); + if (!this->magnetometer_ready_) { + ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it"); + } + } +#endif + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); } +#ifdef USE_BMI270_BMM150 +bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) { + // The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction + // duration depends on the attached device's own timing, so poll with a bounded + // timeout instead of guessing a fixed delay. + uint32_t start = millis(); + while (true) { + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_STATUS, &status)) + return false; + if ((status & BMI270_STATUS_AUX_BUSY) == 0) + return true; + if (millis() - start >= timeout_ms) + return false; + delay(1); + } +} + +bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value)) + return false; + if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg)) + return false; + return this->wait_for_aux_idle_(20); +} + +bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) { + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg)) + return false; + if (!this->wait_for_aux_idle_(20)) + return false; + return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN); +} + +bool BMI270Component::read_magnetometer_trim_() { + uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN]; + for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) { + if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN)) + return false; + } + this->mag_trim_ = bmm150_parse_trim(regs); + return true; +} + +bool BMI270Component::setup_magnetometer_() { + // Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary + // I2C master in manual mode, point it at the BMM150's address, power the BMM150 up + // and soft-reset it, verify its chip ID, read its factory trim values, put it in + // normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps + // AUX_X/Y/Z/R refreshed from the BMM150's data registers. + ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface..."); + + if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN)) + return false; + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1))) + return false; + + // Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it + // stayed powered across a restart. The chip ID is only readable once the power bit is set. + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) { + ESP_LOGW(TAG, "BMM150 power-on write timed out"); + return false; + } + delay(BMM150_START_UP_TIME_MS); + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) { + ESP_LOGW(TAG, "BMM150 soft-reset write timed out"); + return false; + } + delay(BMM150_SOFT_RESET_TIME_MS); + + uint8_t chip_id[BMI270_AUX_DATA_LEN]; + if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) { + ESP_LOGW(TAG, "BMM150 chip-ID read failed"); + return false; + } + if (chip_id[0] != BMM150_CHIP_ID_VALUE) { + ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE); + return false; + } + + if (!this->read_magnetometer_trim_()) { + ESP_LOGW(TAG, "BMM150 trim data read failed"); + return false; + } + + if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) { + ESP_LOGW(TAG, "BMM150 mode write timed out"); + return false; + } + + // Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's + // data registers (0x42) into its own AUX_X/Y/Z/R registers automatically. + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 | + BMI270_AUX_IF_CONF_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB)) + return false; + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN)) + return false; + + ESP_LOGD(TAG, "BMM150 magnetometer ready"); + return true; +} +#endif // USE_BMI270_BMM150 + void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); @@ -153,6 +278,12 @@ void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", + this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED")); + } +#endif MotionComponent::dump_config(); } @@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) - uint8_t raw_data[REG_READ_LEN]; - if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + // When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before + // the accel data, so one burst read from AUX_X_LSB fetches both. +#ifdef USE_BMI270_BMM150 + const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty(); + uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN]; + const uint8_t *mag_raw = buf; + const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf; + const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf)) + : this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#else + uint8_t buf[REG_READ_LEN]; + const uint8_t *raw_data = buf; + const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#endif + if (!read_ok) { ESP_LOGW(TAG, "Failed to read IMU data"); return false; } @@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) { data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; - if (this->temperature_callback_.empty()) - return true; - // Temperature: registers 0x22–0x23 - // Formula from datasheet: T[°C] = raw / 512 + 23 - static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); - float temperature = (raw_t / 512.0f) + 23.0f; - this->temperature_callback_.call(temperature); + if (!this->temperature_callback_.empty()) { + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + } + +#ifdef USE_BMI270_BMM150 + if (read_mag) { + const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw); + this->magnetometer_callback_.call(mag_data); + } +#endif return true; } diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 56d6a609522..a1fefdffe80 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -4,35 +4,81 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/defines.h" +#ifdef USE_BMI270_BMM150 +#include "bmm150_aux.h" +#endif #include namespace esphome::bmi270 { // Register map -static const uint8_t BMI270_REG_CHIP_ID = 0x00; -static const uint8_t BMI270_REG_ERR_REG = 0x02; -static const uint8_t BMI270_REG_STATUS = 0x03; -static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB -static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB -static const uint8_t BMI270_REG_TEMP_0 = 0x22; -static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) +static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00; +static constexpr uint8_t BMI270_REG_ERR_REG = 0x02; +static constexpr uint8_t BMI270_REG_STATUS = 0x03; +static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22; +static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) static constexpr uint8_t REG_READ_LEN = BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) -static const uint8_t BMI270_REG_PWR_CONF = 0x7C; -static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; -static const uint8_t BMI270_REG_INIT_CTRL = 0x59; -static const uint8_t BMI270_REG_INIT_DATA = 0x5E; -static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; -static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; -static const uint8_t BMI270_REG_ACC_CONF = 0x40; -static const uint8_t BMI270_REG_ACC_RANGE = 0x41; -static const uint8_t BMI270_REG_GYR_CONF = 0x42; -static const uint8_t BMI270_REG_GYR_RANGE = 0x43; +static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C; +static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59; +static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E; +static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40; +static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41; +static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42; +static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43; -static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; +static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Auxiliary (secondary I2C master) interface registers. +// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins +// instead of the main I2C bus - the BMM150 has no address of its own on that bus. +static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes) +static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B; +static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C; +static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D; +static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E; +static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F; +static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B; +static constexpr uint8_t BMI270_REG_CMD = 0x7E; + +// Register values and bitfields +static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6; +static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00; +static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01; +static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F; +static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01; +static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4 +static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4; +static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80; +static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40; +static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80; +static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04; +static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20; +static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01; +static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02; +static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04; +static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08; +static constexpr uint8_t BMI270_PWR_CTRL_IMU = + BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN; +static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80; +static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40; +static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3 +static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3 + +// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both. +static constexpr uint8_t BMI270_AUX_DATA_LEN = 8; +static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data"); // Accelerometer range options enum BMI270AccelRange : uint8_t { @@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t { BMI270_GYRO_ODR_3200 = 0x0D, }; -// ---Data class +#ifdef USE_BMI270_BMM150 +// What is wired to this instance's auxiliary (secondary I2C master) interface; another +// instance in the same build may have nothing attached. +enum BMI270AuxDevice : uint8_t { + BMI270_AUX_DEVICE_NONE = 0, + BMI270_AUX_DEVICE_BMM150 = 1, +}; +#endif // Main component class class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { @@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } +#ifdef USE_BMI270_BMM150 + void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; } + void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; } + template void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward(cb)); } +#endif protected: bool update_data(motion::MotionData &data) override; bool load_config_file_(); +#ifdef USE_BMI270_BMM150 + // Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only + // disables magnetometer reporting, it does not fail the whole component (accel/gyro still work). + bool setup_magnetometer_(); + // Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the + // attached device, so this is bounded by retries/timeout rather than a fixed delay. + bool wait_for_aux_idle_(uint32_t timeout_ms); + // Manual-mode aux read of 8 bytes starting at `reg` on the aux device. + bool read_aux_block_(uint8_t reg, uint8_t *buf); + // Manual-mode aux write of one byte to `reg` on the aux device. + bool write_aux_register_(uint8_t reg, uint8_t value); + // Reads the BMM150 factory trim values used to compensate its samples. + bool read_magnetometer_trim_(); +#endif // Config BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; - LazyCallbackManager temperature_callback_{}; +#ifdef USE_BMI270_BMM150 + LazyCallbackManager magnetometer_callback_{}; + bmm150::BMM150Trim mag_trim_{}; + BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE}; + uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS}; + bool magnetometer_ready_{false}; +#endif }; } // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h new file mode 100644 index 00000000000..747a0898fa8 --- /dev/null +++ b/esphome/components/bmi270/bmm150_aux.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#include "esphome/core/helpers.h" + +// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's +// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this +// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone +// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only +// reachable by having the BMI270 proxy register reads/writes through its aux interface. +namespace esphome::bmi270::bmm150 { + +static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10; +static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40; +static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42; +static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B; +static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C; +static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32; +static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up) +static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set +static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal) +static constexpr uint32_t BMM150_START_UP_TIME_MS = 3; +static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1; + +// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample. +static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D; +static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst +static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74 +static constexpr uint8_t BMM150_TRIM_X1 = 0x5D; +static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E; +static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first +static constexpr uint8_t BMM150_TRIM_X2 = 0x64; +static constexpr uint8_t BMM150_TRIM_Y2 = 0x65; +static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit +static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit +static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits +static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit +static constexpr uint8_t BMM150_TRIM_XY2 = 0x70; +static constexpr uint8_t BMM150_TRIM_XY1 = 0x71; +static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF; + +// Raw ADC values that mean "overflow" per the datasheet. +static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096; +static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384; + +// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow. +struct BMM150Data { + float x; + float y; + float z; +}; + +// Factory trim values (BMM150 datasheet, compensation section). +struct BMM150Trim { + int8_t x1, y1, x2, y2, xy2; + uint8_t xy1; + int16_t z2, z3, z4; + uint16_t z1, xyz1; +}; + +// A little-endian 16-bit trim word, addressed by its register +inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { + const uint8_t offset = reg - BMM150_REG_TRIM_START; + return encode_uint16(regs[offset + 1], regs[offset]); +} + +// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). +inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) { + BMM150Trim trim; + trim.x1 = static_cast(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]); + trim.y1 = static_cast(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]); + trim.z4 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z4)); + trim.x2 = static_cast(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]); + trim.y2 = static_cast(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]); + trim.z2 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z2)); + trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1); + trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK; + trim.z3 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z3)); + trim.xy2 = static_cast(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]); + trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START]; + return trim; +} + +// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) { + if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0) + return NAN; + float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f; + float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f); + float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f; + float comp3 = (float) trim_b + 160.0f; + float comp4 = (float) raw * ((comp2 + 256.0f) * comp3); + return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f; +} + +// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) { + if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0) + return NAN; + float comp0 = (float) raw - (float) trim.z4; + float comp1 = (float) rhall - (float) trim.xyz1; + float comp2 = (float) trim.z3 * comp1; + float comp3 = (float) trim.z1 * (float) rhall / 32768.0f; + float comp4 = (float) trim.z2 + comp3; + float comp5 = comp0 * 131072.0f - comp2; + return (comp5 / (comp4 * 4.0f)) / 16.0f; +} + +// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT. +inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) { + // X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2). + int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3)); + int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3)); + int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1)); + uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2)); + return BMM150Data{ + .x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall), + .y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall), + .z = bmm150_compensate_z(trim, z, rhall), + }; +} + +} // namespace esphome::bmi270::bmm150 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index ad36e592d14..97127fd2dd5 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns DEPENDENCIES = ["i2c"] +CONF_AUX_ADDRESS = "aux_address" +CONF_AUX_DEVICE = "aux_device" + # Enum proxies (must match the C++ enum values exactly) +BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") ACCEL_RANGE_OPTIONS = { "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, @@ -73,6 +77,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( GYRO_ODR_OPTIONS, upper=True ), + # Optional device wired to the BMI270's aux interface (not the main I2C bus). + # Defaults to NONE so existing configs/boards are unaffected. + cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of( + "NONE", "BMM150", upper=True + ), + # Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE. + cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address, } ) .extend(i2c.i2c_device_schema(0x68)) @@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) + # The aux interface code only compiles when some instance has a BMM150; the + # instance still records its own device, as another one may have none + if config[CONF_AUX_DEVICE] == "BMM150": + cg.add_define("USE_BMI270_BMM150") + cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150)) + cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 0e1b0604a3d..242b86614d8 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -6,37 +6,97 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, DEVICE_CLASS_TEMPERATURE, + ICON_MAGNET, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_MICROTESLA, ) from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType -from . import CONF_BMI270_ID, BMI270Component +from . import CONF_BMI270_ID, BMI270Component, bmi270_ns +from .motion import CONF_AUX_DEVICE AUTO_LOAD = ["bmi270"] -CONFIG_SCHEMA = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - icon=ICON_THERMOMETER, - accuracy_decimals=2, - state_class=STATE_CLASS_MEASUREMENT, - device_class=DEVICE_CLASS_TEMPERATURE, -).extend( +CONF_MAGNETIC_FIELD_X = "magnetic_field_x" +CONF_MAGNETIC_FIELD_Y = "magnetic_field_y" +CONF_MAGNETIC_FIELD_Z = "magnetic_field_z" + +BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data") + +_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z) + + +def _parent_schema() -> cv.Schema: + return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)}) + + +def _temperature_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, + ).extend(_parent_schema()) + + +def _magnetic_field_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROTESLA, + icon=ICON_MAGNET, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(_parent_schema()) + + +# `type` defaults to temperature so existing configs that omit it keep working unchanged. +CONFIG_SCHEMA = cv.typed_schema( { - cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), - cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), - } + CONF_TEMPERATURE: _temperature_sensor_schema(), + **{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS}, + }, + default_type=CONF_TEMPERATURE, ) +def _final_validate(config: ConfigType) -> None: + if config[CONF_TYPE] not in _MAGNETIC_FIELDS: + return + full_config = fv.full_config.get() + bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1] + bmi270_config = full_config.get_config_for_path(bmi270_path) + if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150": + raise cv.Invalid( + "This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'", + path=[CONF_TYPE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: + sensor_type = config[CONF_TYPE] var = await sensor.new_sensor(config) parent = await cg.get_variable(config[CONF_BMI270_ID]) + + if sensor_type == CONF_TEMPERATURE: + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) + return + + axis = sensor_type[-1:] data = MockObj("data") value_lambda = await cg.process_lambda( - var.publish_state(data), - [(cg.float_, str(data))], + var.publish_state(getattr(data, axis)), + [(BMM150Data.operator("const").operator("ref"), str(data))], ) - cg.add(parent.add_temperature_listener(value_lambda)) + cg.add(parent.add_magnetometer_listener(value_lambda)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a1b32b39f55..c645428c7c1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_BINARY_SENSOR_FILTER #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK +#define USE_BMI270_BMM150 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/components/bmi270/common-plain.yaml b/tests/components/bmi270/common-plain.yaml new file mode 100644 index 00000000000..13091521a83 --- /dev/null +++ b/tests/components/bmi270/common-plain.yaml @@ -0,0 +1,11 @@ +motion: + - platform: bmi270 + id: plain_imu + i2c_id: i2c_bus + address: 0x69 + +sensor: + - platform: bmi270 + bmi270_id: plain_imu + type: temperature + name: Plain IMU Temperature diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 41f30fd5c61..390def04a15 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -1,7 +1,21 @@ sensor: - platform: bmi270 + bmi270_id: bmi270_motion name: "BMI270 Temperature" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_x + name: "BMM150 Magnetic Field X" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_y + name: "BMM150 Magnetic Field Y" + - platform: bmi270 + bmi270_id: bmi270_motion + type: magnetic_field_z + name: "BMM150 Magnetic Field Z" + - platform: motion motion_id: bmi270_motion type: acceleration_x @@ -75,6 +89,8 @@ motion: # Gyroscope output data rate: 25HZ | 50HZ | 100HZ | 200HZ | # 400HZ | 800HZ | 1600HZ | 3200HZ gyroscope_odr: 200HZ + aux_device: BMM150 + aux_address: 0x10 axis_map: x: y y: x diff --git a/tests/components/bmi270/test-plain.esp32-idf.yaml b/tests/components/bmi270/test-plain.esp32-idf.yaml new file mode 100644 index 00000000000..aafcb7af3ab --- /dev/null +++ b/tests/components/bmi270/test-plain.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bmi270: !include common-plain.yaml diff --git a/tests/components/bmi270/test.esp32-idf.yaml b/tests/components/bmi270/test.esp32-idf.yaml index b47e39c3898..63ea3c5a1a1 100644 --- a/tests/components/bmi270/test.esp32-idf.yaml +++ b/tests/components/bmi270/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bmi270: !include common.yaml + bmi270_plain: !include common-plain.yaml From 73d5bb3c97534aec5105f3e7e9d546ae43fd0c40 Mon Sep 17 00:00:00 2001 From: naKruul <40398952+naKruul@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:09:40 +0200 Subject: [PATCH 019/111] [remko_ar715] Add Remko AR-715 IR climate platform (#17173) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/remko_ar715/__init__.py | 2 + esphome/components/remko_ar715/climate.py | 22 ++++ .../components/remko_ar715/remko_ar715.cpp | 93 +++++++++++++++ esphome/components/remko_ar715/remko_ar715.h | 37 ++++++ tests/components/remko_ar715/__init__.py | 7 ++ tests/components/remko_ar715/common.yaml | 3 + .../remko_ar715/remko_ar715_tests.cpp | 106 ++++++++++++++++++ .../remko_ar715/test.esp32-idf.yaml | 3 + .../remko_ar715/test.esp8266-ard.yaml | 3 + 10 files changed, 277 insertions(+) create mode 100644 esphome/components/remko_ar715/__init__.py create mode 100644 esphome/components/remko_ar715/climate.py create mode 100644 esphome/components/remko_ar715/remko_ar715.cpp create mode 100644 esphome/components/remko_ar715/remko_ar715.h create mode 100644 tests/components/remko_ar715/__init__.py create mode 100644 tests/components/remko_ar715/common.yaml create mode 100644 tests/components/remko_ar715/remko_ar715_tests.cpp create mode 100644 tests/components/remko_ar715/test.esp32-idf.yaml create mode 100644 tests/components/remko_ar715/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index c74ae5616bf..5e88b7d7e78 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/remko_ar715/* @naKruul esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core diff --git a/esphome/components/remko_ar715/__init__.py b/esphome/components/remko_ar715/__init__.py new file mode 100644 index 00000000000..18eb60df336 --- /dev/null +++ b/esphome/components/remko_ar715/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@naKruul"] +DOMAIN = "remko_ar715" diff --git a/esphome/components/remko_ar715/climate.py b/esphome/components/remko_ar715/climate.py new file mode 100644 index 00000000000..d640993a4cc --- /dev/null +++ b/esphome/components/remko_ar715/climate.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTS_HEAT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715") +RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend( + { + cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false, + cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false, + } +) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/remko_ar715/remko_ar715.cpp b/esphome/components/remko_ar715/remko_ar715.cpp new file mode 100644 index 00000000000..f6d5a2f62c9 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.cpp @@ -0,0 +1,93 @@ +#include "remko_ar715.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::remko_ar715 { + +ESPHOME_LOG_TAG(TAG, "remko_ar715.climate"); + +// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds +static constexpr uint16_t BIT_MARK = 511; +static constexpr uint16_t HDR_SPACE = 3492; +static constexpr uint16_t ONE_SPACE = 1540; +static constexpr uint16_t ZERO_SPACE = 548; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint8_t BITS = 52; + +static constexpr uint8_t POWER_ON = 0x3; +static constexpr uint8_t POWER_OFF = 0x0; +static constexpr uint8_t SWING_ON = 0x5; +static constexpr uint8_t SWING_OFF = 0x7; + +// Mode/fan byte: high nibble N3, low nibble N2 +static constexpr uint8_t MODE_FAN_AUTO = 0x80; +static constexpr uint8_t MODE_FAN_LOW = 0xA2; +static constexpr uint8_t MODE_FAN_MEDIUM = 0x91; +static constexpr uint8_t MODE_FAN_HIGH = 0x88; +static constexpr uint8_t MODE_DRY = 0xA4; +static constexpr uint8_t MODE_FAN_ONLY = 0xA3; + +static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_HIGH: + return MODE_FAN_HIGH; + case climate::CLIMATE_FAN_MEDIUM: + return MODE_FAN_MEDIUM; + case climate::CLIMATE_FAN_LOW: + return MODE_FAN_LOW; + default: + return MODE_FAN_AUTO; + } +} + +// 52-bit word, 13 nibbles MSB first: +// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing, +// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF +static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) { + const uint8_t temp_nibble = (temp - 16) & 0xF; + const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble; + const uint8_t checksum = (0xF - sum) & 0xF; + return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) | + (temp_nibble << 4) | checksum; +} + +static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) { + data->set_carrier_frequency(CARRIER_FREQUENCY); + data->reserve(2 + BITS * 2 + 2); + data->item(BIT_MARK, HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) + data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE); + // Stop bit and inter-frame gap + data->item(BIT_MARK, HDR_SPACE); +} + +void RemkoAr715Climate::transmit_state() { + const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); + const bool power = this->mode != climate::CLIMATE_MODE_OFF; + const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL; + + // DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles + uint8_t mode_fan; + switch (this->mode) { + case climate::CLIMATE_MODE_DRY: + mode_fan = MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + mode_fan = MODE_FAN_ONLY; + break; + default: + mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + break; + } + + const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp); + ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp, + ONOFF(swing), mode_fan, static_cast(code >> 32), static_cast(code)); + + auto transmit = this->transmitter_->transmit(); + send_frame(transmit.get_data(), code); + transmit.perform(); +} + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remko_ar715/remko_ar715.h b/esphome/components/remko_ar715/remko_ar715.h new file mode 100644 index 00000000000..463617bbc65 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::remko_ar715 { + +// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command. +// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer. +// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812 +// +// Supported devices: +// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote +// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI +// - TROTEC PAC 4600 +// - Novamatic CL 990 / CL 1590 +// - Rexair C15000N +// - freecom RCS-SD43UAI / RCS-SD43UWI + +static constexpr float TEMP_MIN = 16.0f; +static constexpr float TEMP_MAX = 30.0f; + +class RemkoAr715Climate final : public climate_ir::ClimateIR { + public: + RemkoAr715Climate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // temperature step + true, // supports dry + true, // supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + protected: + void transmit_state() override; +}; + +} // namespace esphome::remko_ar715 diff --git a/tests/components/remko_ar715/__init__.py b/tests/components/remko_ar715/__init__.py new file mode 100644 index 00000000000..ec1dafdf15c --- /dev/null +++ b/tests/components/remko_ar715/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/remko_ar715/common.yaml b/tests/components/remko_ar715/common.yaml new file mode 100644 index 00000000000..b284bc20c2a --- /dev/null +++ b/tests/components/remko_ar715/common.yaml @@ -0,0 +1,3 @@ +climate: + - platform: remko_ar715 + name: Test RemkoAr715 diff --git a/tests/components/remko_ar715/remko_ar715_tests.cpp b/tests/components/remko_ar715/remko_ar715_tests.cpp new file mode 100644 index 00000000000..bb1cece60fb --- /dev/null +++ b/tests/components/remko_ar715/remko_ar715_tests.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/remko_ar715/remko_ar715.h" + +namespace esphome::remko_ar715::testing { + +static constexpr int32_t BIT_MARK = 511; +static constexpr int32_t HDR_SPACE = 3492; +static constexpr int32_t ONE_SPACE = 1540; +static constexpr int32_t ZERO_SPACE = 548; +static constexpr int BITS = 52; + +/// The waveform the component must produce for a code: header, 52 bits MSB first, stop bit +static remote_base::RawTimings waveform(uint64_t code) { + remote_base::RawTimings raw; + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) { + raw.push_back(BIT_MARK); + raw.push_back((code >> i) & 1 ? -ONE_SPACE : -ZERO_SPACE); + } + raw.push_back(BIT_MARK); + raw.push_back(-HDR_SPACE); + return raw; +} + +/// Decodes a frame back into its code so a mismatch is readable +static uint64_t code_of(const remote_base::RawTimings &raw) { + uint64_t code = 0; + for (int bit = 0; bit < BITS; bit++) { + const size_t space = 3 + 2 * bit; + code = (code << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + return code; +} + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + RemkoAr715Climate climate; + + Fixture() { + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +static void expect_code(const remote_base::RawTimings &actual, uint64_t code) { + EXPECT_EQ(actual, waveform(code)) << std::hex << "actual 0x" << code_of(actual) << "\nexpected 0x" << code; +} + +// Codes captured from the AR-715 remote in IRremoteESP8266 issue 1812 +TEST(RemkoAr715, EncodesCapturedFrames) { + Fixture f; + + f.command(climate::CLIMATE_MODE_COOL, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x830000005880FULL); + + // Power off keeps the rest of the state in the frame + f.command(climate::CLIMATE_MODE_OFF, 16, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_VERTICAL); + expect_code(f.last_frame(), 0x8000000058802ULL); + + f.command(climate::CLIMATE_MODE_COOL, 25, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007919AULL); + + f.command(climate::CLIMATE_MODE_COOL, 20, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007914FULL); +} + +// Dry and fan-only replace the fan nibbles; the fan speed is not sent +TEST(RemkoAr715, DryAndFanOnlyIgnoreTheFanSpeed) { + Fixture f; + + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A469ULL); + + f.command(climate::CLIMATE_MODE_FAN_ONLY, 22, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF); + expect_code(f.last_frame(), 0x830000007A36AULL); +} + +} // namespace esphome::remko_ar715::testing diff --git a/tests/components/remko_ar715/test.esp32-idf.yaml b/tests/components/remko_ar715/test.esp32-idf.yaml new file mode 100644 index 00000000000..128f6d444a7 --- /dev/null +++ b/tests/components/remko_ar715/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remko_ar715: !include common.yaml diff --git a/tests/components/remko_ar715/test.esp8266-ard.yaml b/tests/components/remko_ar715/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bc4c8ddda13 --- /dev/null +++ b/tests/components/remko_ar715/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remko_ar715: !include common.yaml From 0c43d689d7852d4a75e471d932014ba351ecc92c Mon Sep 17 00:00:00 2001 From: pl4nkton Date: Tue, 13 Oct 2026 01:11:51 +0200 Subject: [PATCH 020/111] [sh1122] Add OLED display driver (#15295) Co-authored-by: Nicolas Reinecke Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/sh1122_base/__init__.py | 43 ++++++ .../components/sh1122_base/sh1122_base.cpp | 146 ++++++++++++++++++ esphome/components/sh1122_base/sh1122_base.h | 59 +++++++ esphome/components/sh1122_spi/__init__.py | 1 + esphome/components/sh1122_spi/display.py | 37 +++++ esphome/components/sh1122_spi/sh1122_spi.cpp | 42 +++++ esphome/components/sh1122_spi/sh1122_spi.h | 25 +++ tests/components/sh1122_spi/common.yaml | 19 +++ .../components/sh1122_spi/test.esp32-idf.yaml | 8 + .../sh1122_spi/test.esp8266-ard.yaml | 8 + .../sh1122_spi/test.rp2040-ard.yaml | 8 + 12 files changed, 398 insertions(+) create mode 100644 esphome/components/sh1122_base/__init__.py create mode 100644 esphome/components/sh1122_base/sh1122_base.cpp create mode 100644 esphome/components/sh1122_base/sh1122_base.h create mode 100644 esphome/components/sh1122_spi/__init__.py create mode 100644 esphome/components/sh1122_spi/display.py create mode 100644 esphome/components/sh1122_spi/sh1122_spi.cpp create mode 100644 esphome/components/sh1122_spi/sh1122_spi.h create mode 100644 tests/components/sh1122_spi/common.yaml create mode 100644 tests/components/sh1122_spi/test.esp32-idf.yaml create mode 100644 tests/components/sh1122_spi/test.esp8266-ard.yaml create mode 100644 tests/components/sh1122_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 5e88b7d7e78..b25611b3b66 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -522,6 +522,8 @@ esphome/components/sfa30/* @ghsensdev esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sh1122_base/* @pl4nkton +esphome/components/sh1122_spi/* @pl4nkton esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py new file mode 100644 index 00000000000..4f2b026a5ed --- /dev/null +++ b/esphome/components/sh1122_base/__init__.py @@ -0,0 +1,43 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@pl4nkton"] +DOMAIN = "sh1122_base" + +sh1122_base_ns = cg.esphome_ns.namespace("sh1122_base") +SH1122 = sh1122_base_ns.class_("SH1122", cg.PollingComponent, display.DisplayBuffer) +SH1122Model = sh1122_base_ns.enum("SH1122Model") + +MODELS = { + "SH1122_256X64": SH1122Model.SH1122_MODEL_256_64, +} + +SH1122_MODEL = cv.enum(MODELS, upper=True, space="_") + +SH1122_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.Optional(CONF_MODEL, default="SH1122_256X64"): SH1122_MODEL, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + } +).extend(cv.polling_component_schema("1s")) + + +async def setup_sh1122(var: MockObj, config: ConfigType) -> None: + await display.register_display(var, config) + + cg.add(var.set_model(config[CONF_MODEL])) + if (reset_pin := config.get(CONF_RESET_PIN)) is not None: + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/sh1122_base/sh1122_base.cpp b/esphome/components/sh1122_base/sh1122_base.cpp new file mode 100644 index 00000000000..72d5e44235f --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.cpp @@ -0,0 +1,146 @@ +#include "sh1122_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sh1122_base { + +ESPHOME_LOG_TAG(TAG, "sh1122"); + +static constexpr uint8_t SH1122_MAX_CONTRAST = 255; +static constexpr uint8_t SH1122_COLORMASK = 0x0f; +static constexpr uint8_t SH1122_COLORSHIFT = 4; +static constexpr uint8_t SH1122_PIXELSPERBYTE = 2; + +static constexpr uint8_t SH1122_SETLOWCOLUMNADDRESS = 0x00; +static constexpr uint8_t SH1122_SETHIGHCOLUMNADDRESS = 0x10; +static constexpr uint8_t SH1122_SETDISCHARGEVSLLEVEL = 0x30; +static constexpr uint8_t SH1122_SETDISPLAYSTARTLINE = 0x40; +static constexpr uint8_t SH1122_SETCONTRASTCURRENT = 0x81; +static constexpr uint8_t SH1122_SETSEGMENTREMAP = 0xA0; +static constexpr uint8_t SH1122_SETDISPLAYOFFON = 0xA4; +static constexpr uint8_t SH1122_SETNORMALDISPLAY = 0xA6; +static constexpr uint8_t SH1122_SETMULTIPLEXRATIO = 0xA8; +static constexpr uint8_t SH1122_DCDCSETTING = 0xAD; +static constexpr uint8_t SH1122_SETDISPLAYOFF = 0xAE; +static constexpr uint8_t SH1122_SETDISPLAYON = 0xAF; +static constexpr uint8_t SH1122_SETROWADDRESS = 0xB0; +static constexpr uint8_t SH1122_SETSCANDIRECTION = 0xC0; +static constexpr uint8_t SH1122_SETDISPLAYOFFSET = 0xD3; +static constexpr uint8_t SH1122_SETCLOCKDIVIDER = 0xD5; +static constexpr uint8_t SH1122_SETDISCHARGEPRECHARGEPERIOD = 0xD9; +static constexpr uint8_t SH1122_SETVCOMDESELECTLEVEL = 0xDB; +static constexpr uint8_t SH1122_SETVSEGMLEVEL = 0xDC; + +void SH1122::setup() { + this->init_internal_(this->get_buffer_length_()); + if (this->buffer_ == nullptr) { + this->mark_failed(); + return; + } + + this->turn_off(); + this->command2_(SH1122_SETCLOCKDIVIDER, 0x50); + this->command2_(SH1122_SETMULTIPLEXRATIO, 0x3F); + this->command2_(SH1122_SETDISPLAYOFFSET, 0x00); + this->command2_(SH1122_SETROWADDRESS, 0x00); + this->command_(SH1122_SETDISPLAYSTARTLINE | 32); // the panel's rows start half way down the 128 line RAM + this->command_(SH1122_SETDISCHARGEVSLLEVEL); + this->command2_(SH1122_DCDCSETTING, 0x81); + this->command_(SH1122_SETSEGMENTREMAP | 0x01); // column 0 on the right + this->command_(SH1122_SETSCANDIRECTION | 0x08); // rows scanned bottom up + this->command2_(SH1122_SETDISCHARGEPRECHARGEPERIOD, 0x28); + this->command2_(SH1122_SETVCOMDESELECTLEVEL, 0x35); + this->command2_(SH1122_SETVSEGMLEVEL, 0x35); + this->command_(SH1122_SETNORMALDISPLAY); + this->command_(SH1122_SETHIGHCOLUMNADDRESS); + this->command_(SH1122_SETLOWCOLUMNADDRESS); + this->command_(SH1122_SETDISPLAYOFFON); + this->set_brightness(this->brightness_); + this->display(); // the buffer starts cleared; push it so no power-on garbage shows + this->turn_on(); +} +void SH1122::display() { + // Return the RAM pointer to the top left corner in one transfer, then stream the frame + const uint8_t set_address[] = {SH1122_SETHIGHCOLUMNADDRESS, SH1122_SETLOWCOLUMNADDRESS, SH1122_SETROWADDRESS, 0x00}; + this->write_command(set_address, sizeof(set_address)); + this->write_display_data(); +} +void SH1122::update() { + this->do_update_(); + this->display(); +} +void SH1122::set_brightness(float brightness) { + this->brightness_ = clamp(brightness, 0.0F, 1.0F); + this->command2_(SH1122_SETCONTRASTCURRENT, int(SH1122_MAX_CONTRAST * (this->brightness_))); +} +void SH1122::turn_on() { + this->command_(SH1122_SETDISPLAYON); + this->is_on_ = true; +} +void SH1122::turn_off() { + this->command_(SH1122_SETDISPLAYOFF); + this->is_on_ = false; +} +int SH1122::get_height_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 64; + default: + return 0; + } +} +int SH1122::get_width_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 256; + default: + return 0; + } +} +size_t SH1122::get_buffer_length_() { + return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / SH1122_PIXELSPERBYTE; +} +void HOT SH1122::draw_absolute_pixel_internal(int x, int y, Color color) { + const int width = this->get_width_internal(); + if (x >= width || x < 0 || y >= this->get_height_internal() || y < 0) + return; + // Two pixels per byte, the left one in the high nibble + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color) & SH1122_COLORMASK; + const uint16_t pos = (x / SH1122_PIXELSPERBYTE) + (y * width / SH1122_PIXELSPERBYTE); + const uint8_t shift = (1u - (x % SH1122_PIXELSPERBYTE)) * SH1122_COLORSHIFT; + this->buffer_[pos] = (this->buffer_[pos] & (static_cast(~SH1122_COLORMASK) >> shift)) | (color4 << shift); +} +void SH1122::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color); + const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT); + memset(this->buffer_, fill, this->get_buffer_length_()); +} +void SH1122::init_reset_() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(1); + // Trigger Reset + this->reset_pin_->digital_write(false); + delay(10); + // Wake up + this->reset_pin_->digital_write(true); + } +} +const LogString *SH1122::model_str_() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return LOG_STR("SH1122 256x64"); + default: + return LOG_STR("Unknown"); + } +} + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_base/sh1122_base.h b/esphome/components/sh1122_base/sh1122_base.h new file mode 100644 index 00000000000..c3e4719d395 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/display/display_buffer.h" + +namespace esphome::sh1122_base { + +enum SH1122Model : uint8_t { + SH1122_MODEL_256_64 = 0, +}; + +class SH1122 : public display::DisplayBuffer { + public: + void setup() override; + + void display(); + + void update() override; + + void set_model(SH1122Model model) { this->model_ = model; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void init_brightness(float brightness) { this->brightness_ = brightness; } + void set_brightness(float brightness); + bool is_on() { return this->is_on_; } + void turn_on(); + void turn_off(); + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void fill(Color color) override; + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_GRAYSCALE; } + + protected: + /// Sends a command byte and its arguments; the SH1122 takes command arguments with D/C low as well + virtual void write_command(const uint8_t *bytes, size_t len) = 0; + virtual void write_display_data() = 0; + void command_(uint8_t value) { this->write_command(&value, 1); } + void command2_(uint8_t value, uint8_t data) { + const uint8_t bytes[2] = {value, data}; + this->write_command(bytes, 2); + } + void init_reset_(); + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + + int get_height_internal() override; + int get_width_internal() override; + size_t get_buffer_length_(); + const LogString *model_str_(); + + GPIOPin *reset_pin_{nullptr}; + float brightness_{1.0}; + SH1122Model model_{SH1122_MODEL_256_64}; + bool is_on_{false}; +}; + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_spi/__init__.py b/esphome/components/sh1122_spi/__init__.py new file mode 100644 index 00000000000..4ed70222673 --- /dev/null +++ b/esphome/components/sh1122_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sh1122_spi" diff --git a/esphome/components/sh1122_spi/display.py b/esphome/components/sh1122_spi/display.py new file mode 100644 index 00000000000..36acb9fd591 --- /dev/null +++ b/esphome/components/sh1122_spi/display.py @@ -0,0 +1,37 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import sh1122_base, spi +import esphome.config_validation as cv +from esphome.const import CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_PAGES +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@pl4nkton"] + +AUTO_LOAD = ["sh1122_base"] +DEPENDENCIES = ["spi"] + +sh1122_spi = cg.esphome_ns.namespace("sh1122_spi") +SPISH1122 = sh1122_spi.class_("SPISH1122", sh1122_base.SH1122, spi.SPIDevice) + +CONFIG_SCHEMA = cv.All( + sh1122_base.SH1122_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SPISH1122), + cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, + } + ).extend(spi.spi_device_schema(cs_pin_required=False)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + DOMAIN, require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) + var = cg.new_Pvariable(config[CONF_ID], dc) + await sh1122_base.setup_sh1122(var, config) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sh1122_spi/sh1122_spi.cpp b/esphome/components/sh1122_spi/sh1122_spi.cpp new file mode 100644 index 00000000000..2e925aea29d --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.cpp @@ -0,0 +1,42 @@ +#include "sh1122_spi.h" +#include "esphome/core/log.h" + +namespace esphome::sh1122_spi { + +ESPHOME_LOG_TAG(TAG, "sh1122_spi"); + +void SPISH1122::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + + this->init_reset_(); + delay(500); // NOLINT + SH1122::setup(); +} +void SPISH1122::dump_config() { + LOG_DISPLAY("", "SPI SH1122", this); + ESP_LOGCONFIG(TAG, + " Model: %s\n" + " Initial Brightness: %.2f", + LOG_STR_ARG(this->model_str_()), this->brightness_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} +// enable() and disable() drive the CS pin. The D/C setup time is a few tens of nanoseconds, so the +// GPIO write followed by the CS edge already satisfies it; no delay is needed. +void SPISH1122::write_command(const uint8_t *bytes, size_t len) { + this->dc_pin_->digital_write(false); + this->enable(); + this->write_array(bytes, len); + this->disable(); +} +void HOT SPISH1122::write_display_data() { + this->dc_pin_->digital_write(true); + this->enable(); + this->write_array(this->buffer_, this->get_buffer_length_()); + this->disable(); +} + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sh1122_spi/sh1122_spi.h b/esphome/components/sh1122_spi/sh1122_spi.h new file mode 100644 index 00000000000..f5da209fe27 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/sh1122_base/sh1122_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::sh1122_spi { + +class SPISH1122 final : public sh1122_base::SH1122, + public spi::SPIDevice { + public: + explicit SPISH1122(GPIOPin *dc_pin) : dc_pin_(dc_pin) {} + + void setup() override; + + void dump_config() override; + + protected: + void write_command(const uint8_t *bytes, size_t len) override; + void write_display_data() override; + + GPIOPin *dc_pin_; +}; + +} // namespace esphome::sh1122_spi diff --git a/tests/components/sh1122_spi/common.yaml b/tests/components/sh1122_spi/common.yaml new file mode 100644 index 00000000000..a48a1a5711c --- /dev/null +++ b/tests/components/sh1122_spi/common.yaml @@ -0,0 +1,19 @@ +display: + - platform: sh1122_spi + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${reset_pin} + brightness: 80% + pages: + - id: sh1122_spi_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: sh1122_spi_page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: sh1122_spi_page1 + to: sh1122_spi_page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/sh1122_spi/test.esp32-idf.yaml b/tests/components/sh1122_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..10efaac7f45 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + reset_pin: GPIO14 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.esp8266-ard.yaml b/tests/components/sh1122_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bc1105479d6 --- /dev/null +++ b/tests/components/sh1122_spi/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + dc_pin: GPIO15 + reset_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + sh1122_spi: !include common.yaml diff --git a/tests/components/sh1122_spi/test.rp2040-ard.yaml b/tests/components/sh1122_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..91ec731ca95 --- /dev/null +++ b/tests/components/sh1122_spi/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO20 + dc_pin: GPIO21 + reset_pin: GPIO22 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + sh1122_spi: !include common.yaml From 132e9dac8b52ae13aeeab07b205e2e026fbf00f2 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 01:13:11 +0200 Subject: [PATCH 021/111] [tmc2209] Add TMC2209 stepper driver (#20437) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 + esphome/components/tmc2209/__init__.py | 10 + esphome/components/tmc2209/stepper.py | 14 + esphome/components/tmc2209/tmc2209.cpp | 13 + esphome/components/tmc2209/tmc2209.h | 15 + esphome/components/tmc22xx/__init__.py | 211 ++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 372 ++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.h | 167 ++++++++ tests/components/tmc2209/common.yaml | 57 +++ tests/components/tmc2209/test.esp32-idf.yaml | 9 + .../components/tmc2209/test.esp8266-ard.yaml | 9 + tests/components/tmc2209/test.rp2040-ard.yaml | 9 + 12 files changed, 888 insertions(+) create mode 100644 esphome/components/tmc2209/__init__.py create mode 100644 esphome/components/tmc2209/stepper.py create mode 100644 esphome/components/tmc2209/tmc2209.cpp create mode 100644 esphome/components/tmc2209/tmc2209.h create mode 100644 esphome/components/tmc22xx/__init__.py create mode 100644 esphome/components/tmc22xx/tmc22xx.cpp create mode 100644 esphome/components/tmc22xx/tmc22xx.h create mode 100644 tests/components/tmc2209/common.yaml create mode 100644 tests/components/tmc2209/test.esp32-idf.yaml create mode 100644 tests/components/tmc2209/test.esp8266-ard.yaml create mode 100644 tests/components/tmc2209/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b25611b3b66..2c637b0d200 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -617,6 +617,8 @@ esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc esphome/components/tm1651/* @mrtoy-me +esphome/components/tmc2209/* @remcom +esphome/components/tmc22xx/* @remcom esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py new file mode 100644 index 00000000000..b3822f7582e --- /dev/null +++ b/esphome/components/tmc2209/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import tmc22xx + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc2209" + +tmc2209_ns = cg.esphome_ns.namespace("tmc2209") +TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) + +tmc22xx.register_actions("tmc2209", TMC2209Stepper) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py new file mode 100644 index 00000000000..5ef91dc591f --- /dev/null +++ b/esphome/components/tmc2209/stepper.py @@ -0,0 +1,14 @@ +from esphome.components import tmc22xx +from esphome.types import ConfigType + +from . import TMC2209Stepper + +AUTO_LOAD = ["tmc22xx"] +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate + + +async def to_code(config: ConfigType) -> None: + await tmc22xx.new_tmc22xx(config) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp new file mode 100644 index 00000000000..040b362a8d5 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -0,0 +1,13 @@ +#include "tmc2209.h" +#include "esphome/core/log.h" + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209"); + +void TMC2209Stepper::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209:"); + TMC22XXStepper::dump_config(); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h new file mode 100644 index 00000000000..2f880251e56 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/tmc22xx/tmc22xx.h" + +namespace esphome::tmc2209 { + +class TMC2209Stepper : public tmc22xx::TMC22XXStepper { + public: + void dump_config() override; + + protected: + uint8_t expected_version() const override { return 0x21; } +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py new file mode 100644 index 00000000000..ad7221f0b21 --- /dev/null +++ b/esphome/components/tmc22xx/__init__.py @@ -0,0 +1,211 @@ +"""Shared code for Trinamic stepper drivers configured over the single wire UART interface.""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import stepper, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DIR_PIN, + CONF_ID, + CONF_STEP_PIN, + CONF_UART_ID, +) +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc22xx" +DEPENDENCIES = ["uart"] + +CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" +CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" +CONF_ENN_PIN = "enn_pin" +CONF_HOLD_CURRENT = "hold_current" +CONF_IHOLD = "ihold" +CONF_IHOLDDELAY = "iholddelay" +CONF_INDEX_PIN = "index_pin" +CONF_INTERPOLATION = "interpolation" +CONF_INVERSE_DIRECTION = "inverse_direction" +CONF_IRUN = "irun" +CONF_MICROSTEPS = "microsteps" +CONF_OTTRIM = "ottrim" +CONF_RSENSE = "rsense" +CONF_RUN_CURRENT = "run_current" +CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TPOWERDOWN = "tpowerdown" +CONF_TPWM_THRESHOLD = "tpwm_threshold" +CONF_VSENSE = "vsense" + +tmc22xx_ns = cg.esphome_ns.namespace("tmc22xx") +TMC22XXStepper = tmc22xx_ns.class_( + "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice +) +StandstillMode = tmc22xx_ns.enum("StandstillMode") + +STANDSTILL_MODES = { + "normal": StandstillMode.STANDSTILL_MODE_NORMAL, + "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, + "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, + "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, +} + +validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) +validate_current = cv.All(cv.current, cv.positive_float) + + +def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: + """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" + return cv.All( + stepper.STEPPER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + cv.Optional(CONF_ADDRESS, default=0): cv.int_range(0, max_address), + cv.Optional(CONF_ENN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( + cv.frequency, cv.int_range(min=1) + ), + cv.Optional(CONF_RSENSE): cv.All( + cv.resistance, cv.float_range(min=0.0, min_included=False) + ), + cv.Optional(CONF_VSENSE): cv.boolean, + cv.Optional(CONF_OTTRIM): cv.int_range(0, 3), + cv.Optional(CONF_ANALOG_CURRENT_SCALE, default=False): cv.boolean, + cv.Optional(CONF_RUN_CURRENT): validate_current, + cv.Optional(CONF_HOLD_CURRENT): validate_current, + cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + cv.has_exactly_one_key(CONF_INDEX_PIN, CONF_STEP_PIN), + cv.has_none_or_all_keys(CONF_STEP_PIN, CONF_DIR_PIN), + _validate_analog_current_scale, + ) + + +def _validate_analog_current_scale(config: ConfigType) -> ConfigType: + if config[CONF_ANALOG_CURRENT_SCALE]: + for key in (CONF_RUN_CURRENT, CONF_HOLD_CURRENT): + if key in config: + raise cv.Invalid( + f"'{key}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', the " + f"current then depends on VREF; set '{CONF_IRUN}'/'{CONF_IHOLD}' " + "with the currents action instead", + path=[key], + ) + return config + + +def final_validate(config: ConfigType) -> ConfigType: + """Reject two drivers of the family with the same address on one UART bus.""" + for other in fv.full_config.get().get("stepper", []): + if other is config: + break + if ( + other.get(CONF_UART_ID) == config[CONF_UART_ID] + and other.get(CONF_ADDRESS) == config[CONF_ADDRESS] + ): + raise cv.Invalid( + f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' " + "on the same UART bus" + ) + return config + + +async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await stepper.register_stepper(var, config) + + cg.add(var.set_address(config[CONF_ADDRESS])) + cg.add(var.set_clock_frequency(config[CONF_CLOCK_FREQUENCY])) + cg.add(var.set_analog_current_scale(config[CONF_ANALOG_CURRENT_SCALE])) + cg.add(var.set_initial_microsteps(config[CONF_MICROSTEPS])) + for key, setter in ( + (CONF_ENN_PIN, var.set_enn_pin), + (CONF_STEP_PIN, var.set_step_pin), + (CONF_DIR_PIN, var.set_dir_pin), + (CONF_INDEX_PIN, var.set_index_pin), + ): + if (pin := config.get(key)) is not None: + cg.add(setter(await cg.gpio_pin_expression(pin))) + for key, setter in ( + (CONF_RSENSE, var.set_rsense), + (CONF_VSENSE, var.set_vsense), + (CONF_OTTRIM, var.set_ottrim), + (CONF_RUN_CURRENT, var.set_initial_run_current), + (CONF_HOLD_CURRENT, var.set_initial_hold_current), + ): + if (value := config.get(key)) is not None: + cg.add(setter(value)) + return var + + +def register_actions(domain: str, cls: MockObjClass) -> None: + """Register the actions shared by the family under `domain`, e.g. `tmc2209.enable`.""" + id_schema = {cv.GenerateID(): cv.use_id(cls)} + for name, enabled in (("enable", "true"), ("disable", "false")): + automation.register_apply_action( + f"{domain}.{name}", + automation.maybe_simple_id(id_schema), + automation.ApplyCall(f"set_enabled({enabled})"), + ) + + automation.register_apply_action( + f"{domain}.configure", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_INVERSE_DIRECTION): cv.templatable(cv.boolean), + cv.Optional(CONF_MICROSTEPS): cv.templatable(validate_microsteps), + cv.Optional(CONF_INTERPOLATION): cv.templatable(cv.boolean), + cv.Optional(CONF_ENABLE_SPREADCYCLE): cv.templatable(cv.boolean), + cv.Optional(CONF_TPWM_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField( + CONF_INVERSE_DIRECTION, "set_inverse_direction", cg.bool_ + ), + automation.ApplyField(CONF_MICROSTEPS, "set_microsteps", cg.uint16), + automation.ApplyField(CONF_INTERPOLATION, "set_interpolation", cg.bool_), + automation.ApplyField(CONF_ENABLE_SPREADCYCLE, "set_spreadcycle", cg.bool_), + automation.ApplyField(CONF_TPWM_THRESHOLD, "set_tpwm_threshold", cg.uint32), + ) + + automation.register_apply_action( + f"{domain}.currents", + cv.Schema( + { + **id_schema, + cv.Exclusive(CONF_RUN_CURRENT, "run"): cv.templatable(validate_current), + cv.Exclusive(CONF_IRUN, "run"): cv.templatable(cv.int_range(0, 31)), + cv.Exclusive(CONF_HOLD_CURRENT, "hold"): cv.templatable( + validate_current + ), + cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), + cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( + cv.enum(STANDSTILL_MODES, lower=True) + ), + } + ), + automation.ApplyField(CONF_RUN_CURRENT, "set_run_current", cg.float_), + automation.ApplyField(CONF_IRUN, "set_irun", cg.uint8), + automation.ApplyField(CONF_HOLD_CURRENT, "set_hold_current", cg.float_), + automation.ApplyField(CONF_IHOLD, "set_ihold", cg.uint8), + automation.ApplyField(CONF_IHOLDDELAY, "set_iholddelay", cg.uint8), + automation.ApplyField(CONF_TPOWERDOWN, "set_tpowerdown", cg.uint8), + automation.ApplyField( + CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode + ), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp new file mode 100644 index 00000000000..1628572d406 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -0,0 +1,372 @@ +#include "tmc22xx.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::tmc22xx { + +ESPHOME_LOG_TAG(TAG, "tmc22xx"); + +static constexpr uint8_t SYNC_BYTE = 0x05; +static constexpr uint8_t MASTER_ADDRESS = 0xFF; +static constexpr uint8_t WRITE_BIT = 0x80; +static constexpr size_t READ_REQUEST_SIZE = 4; +static constexpr size_t DATAGRAM_SIZE = 8; +static constexpr uint8_t DEFAULT_TOFF = 3; +static constexpr float SENSE_RESISTOR_OFFSET = 0.02f; // Ohm, added to RSENSE in the datasheet current formula +static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resistance when using internal sensing +// INDEX pulses are counted once per loop, so near the target the speed is limited to cover the remaining +// distance in no less than this time. A slow loop pass then cannot run far past the target. +static constexpr float APPROACH_TIME_S = 0.02f; +// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer +static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; + +// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first +static uint8_t datagram_crc(const uint8_t *data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + const uint8_t reflected = reverse_bits(data[i]); + crc = crc8(&reflected, 1, crc, 0x07, true); + } + return crc; +} + +void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } + +void TMC22XXStepper::setup() { + if (this->enn_pin_ != nullptr) { + this->enn_pin_->setup(); + this->enn_pin_->digital_write(true); + } + + auto version = this->read_field(VERSION); + if (!version.has_value()) { + ESP_LOGE(TAG, "No response from driver at address %u", this->address_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->version_ = *version; + if (this->version_ != this->expected_version()) { + ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); + } + + // Bring write-only registers in line with their cached values and clear the reset flag + this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); + this->write_register(REG_TPOWERDOWN, this->tpowerdown_); + this->write_register(REG_TPWMTHRS, this->tpwmthrs_); + this->write_register(REG_VACTUAL, 0); + this->write_register(REG_GSTAT, 0x07); + + this->write_field(PDN_DISABLE, true); + this->write_field(MSTEP_REG_SELECT, true); + this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); + this->write_field(I_SCALE_ANALOG, this->analog_current_scale_); + if (this->vsense_.has_value()) + this->write_field(VSENSE, *this->vsense_); + if (this->ottrim_.has_value()) + this->write_field(OTTRIM, *this->ottrim_); + this->vsense_active_ = this->read_field(VSENSE).value_or(0); + + // TOFF=0 disables the outputs, keep the current value so it can be restored on enable + auto toff = this->read_field(TOFF); + if (toff.has_value() && *toff != 0) + this->toff_ = *toff; + + this->set_microsteps(this->initial_microsteps_); + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + // INDEX toggles on every step of the internal pulse generator that VACTUAL drives + this->write_field(INDEX_OTPW, false); + this->write_field(INDEX_STEP, true); + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + // Step on both edges, so every step pin toggle is one step + this->write_field(DEDGE, true); + this->write_field(MULTISTEP_FILT, false); + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + this->set_enabled(true); +} + +void TMC22XXStepper::dump_config() { + ESP_LOGCONFIG(TAG, + " Address: %u\n" + " IC version: 0x%02X", + this->address_, this->version_); + // Register reads on a failed driver would only wait for the UART timeout + if (!this->is_failed()) { + uint8_t irun = this->read_field(IRUN).value_or(0); + uint8_t ihold = this->read_field(IHOLD).value_or(0); + ESP_LOGCONFIG(TAG, " Microsteps: %u", this->get_microsteps()); + if (this->analog_current_scale_) { + ESP_LOGCONFIG(TAG, + " Run current scale: %u\n" + " Hold current scale: %u\n" + " Current scaled by VREF", + irun, ihold); + } else { + ESP_LOGCONFIG(TAG, + " Run current: %.2f A\n" + " Hold current: %.2f A\n" + " Max current: %.2f A", + this->scale_to_current(irun), this->scale_to_current(ihold), this->scale_to_current(31)); + } + } + LOG_PIN(" ENN Pin: ", this->enn_pin_); + LOG_PIN(" STEP Pin: ", this->step_pin_); + LOG_PIN(" DIR Pin: ", this->dir_pin_); + LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_STEPPER(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void TMC22XXStepper::loop() { + if (this->index_pin_ != nullptr) + this->add_index_pulses_(); + if (this->has_reached_target()) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + if (!this->enabled_) + this->set_enabled(true); + } + + if (this->index_pin_ != nullptr) { + this->loop_serial_(); + } else { + this->loop_step_dir_(); + } +} + +void TMC22XXStepper::add_index_pulses_() { + InterruptLock lock; + this->current_position += this->index_store_.pulses; + this->index_store_.pulses = 0; +} + +void TMC22XXStepper::loop_serial_() { + int32_t remaining = this->target_position - this->current_position; + int8_t direction = (remaining > 0) - (remaining < 0); + // Ran past the target, restart the ramp from standstill in the other direction + if (direction != 0 && direction == -this->direction_) + this->current_speed_ = 0.0f; + this->calculate_speed_(micros()); + this->current_speed_ = std::min(this->current_speed_, static_cast(std::abs(remaining)) / APPROACH_TIME_S); + + int32_t vactual = direction * this->speed_to_vactual_(this->current_speed_); + int32_t last = this->get_vactual_(); + if (vactual == last) + return; + if (vactual != 0 && (vactual > 0) == (last > 0) && std::abs(vactual - last) < std::abs(last) / VACTUAL_CHANGE_DIVISOR) + return; + // Pulses that arrive while VACTUAL changes direction are counted in the new direction + this->index_store_.direction = direction; + this->direction_ = direction; + this->write_field(VACTUAL, static_cast(vactual)); +} + +void TMC22XXStepper::loop_step_dir_() { + int32_t direction = this->should_step_(); + if (direction == 0) + return; + if (direction != this->direction_) { + this->dir_pin_->digital_write(direction > 0); + this->direction_ = direction; + delayMicroseconds(1); + } + this->step_state_ = !this->step_state_; + this->step_pin_->digital_write(this->step_state_); +} + +void TMC22XXStepper::on_shutdown() { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); +} + +void TMC22XXStepper::set_enabled(bool enabled) { + if (!enabled) { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); + if (this->index_pin_ != nullptr) { + // Count the pulses up to the stop, so the position is current before the target is set + this->add_index_pulses_(); + this->index_store_.direction = 0; + this->direction_ = 0; + } + // Stop at the current position, so the motor does not run off again when re-enabled + this->target_position = this->current_position; + } + if (this->enn_pin_ != nullptr) { + this->enn_pin_->digital_write(!enabled); + } else { + this->write_field(TOFF, enabled ? this->toff_ : 0); + } + this->enabled_ = enabled; +} + +void TMC22XXStepper::set_microsteps(uint16_t microsteps) { + for (uint8_t mres = 0; mres <= 8; mres++) { + if ((256u >> mres) == microsteps) { + this->write_field(MRES, mres); + return; + } + } + ESP_LOGW(TAG, "Invalid microsteps %u", microsteps); +} + +uint16_t TMC22XXStepper::get_microsteps() { + auto mres = this->read_field(MRES); + if (!mres.has_value() || *mres > 8) + return 0; + return 256u >> *mres; +} + +void TMC22XXStepper::set_run_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IRUN, *scale); +} + +void TMC22XXStepper::set_hold_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IHOLD, *scale); +} + +float TMC22XXStepper::full_scale_current_() { + return (this->vsense_active_ ? 0.180f : 0.325f) / + ((std::isnan(this->rsense_) ? INTERNAL_RSENSE_OHM : this->rsense_) + SENSE_RESISTOR_OFFSET); +} + +float TMC22XXStepper::scale_to_current(uint8_t scale) { + return (std::min(scale, 31) + 1) / 32.0f * this->full_scale_current_() / std::numbers::sqrt2_v; +} + +optional TMC22XXStepper::current_to_scale_(float current) { + if (this->analog_current_scale_) { + ESP_LOGW(TAG, "Current in A is not supported with analog current scale, use irun/ihold"); + return {}; + } + float scale = 32.0f * std::numbers::sqrt2_v * current / this->full_scale_current_() - 1.0f; + if (scale > 31.0f) { + ESP_LOGW(TAG, "%.2f A is above the %.2f A limit, using the limit", current, this->scale_to_current(31)); + return 31; + } + return static_cast(std::max(0.0f, std::round(scale))); +} + +int32_t TMC22XXStepper::speed_to_vactual_(float speed) const { + // VACTUAL is in microsteps per 2^24 clock cycles + return static_cast(std::lround(speed * 16777216.0f / this->clock_frequency_)); +} + +uint32_t *TMC22XXStepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_IHOLD_IRUN: + return &this->ihold_irun_; + case REG_TPOWERDOWN: + return &this->tpowerdown_; + case REG_TPWMTHRS: + return &this->tpwmthrs_; + case REG_VACTUAL: + return &this->vactual_reg_; + default: + return nullptr; + } +} + +bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { + uint8_t datagram[DATAGRAM_SIZE] = {SYNC_BYTE, + this->address_, + static_cast(reg | WRITE_BIT), + static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value), + 0}; + datagram[7] = datagram_crc(datagram, 7); + + while (this->available()) + this->read(); + this->write_array(datagram, DATAGRAM_SIZE); + this->flush(); + // On the single wire bus every byte sent is also received, drop that echo + uint8_t echo[DATAGRAM_SIZE]; + if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->status_clear_warning(); + + if (uint32_t *shadow = this->shadow_register(reg)) + *shadow = value; + return true; +} + +optional TMC22XXStepper::read_register(uint8_t reg) { + if (uint32_t *shadow = this->shadow_register(reg)) + return *shadow; + + uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; + request[3] = datagram_crc(request, 3); + + while (this->available()) + this->read(); + this->write_array(request, READ_REQUEST_SIZE); + this->flush(); + uint8_t reply[DATAGRAM_SIZE]; + if (!this->read_array(reply, READ_REQUEST_SIZE) || !this->read_array(reply, DATAGRAM_SIZE)) { + ESP_LOGV(TAG, "No reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + this->status_clear_warning(); + return encode_uint32(reply[3], reply[4], reply[5], reply[6]); +} + +bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { + // Skip the UART timeout when the driver never answered during setup + if (this->is_failed()) + return false; + auto data = this->read_register(field.reg); + if (!data.has_value()) + return false; + uint32_t mask = field.mask(); + return this->write_register(field.reg, (*data & ~mask) | ((value << field.shift) & mask)); +} + +optional TMC22XXStepper::read_field(const RegisterField &field) { + auto data = this->read_register(field.reg); + if (!data.has_value()) + return {}; + return extract_field(*data, field); +} + +uint32_t TMC22XXStepper::extract_field(uint32_t data, const RegisterField &field) { + uint32_t value = (data & field.mask()) >> field.shift; + if (field.is_signed && field.width < 32) { + uint32_t sign_bit = 1u << (field.width - 1); + value = (value ^ sign_bit) - sign_bit; + } + return value; +} + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h new file mode 100644 index 00000000000..8b0c92d3520 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "esphome/components/stepper/stepper.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tmc22xx { + +/// A bit field inside one register. +struct RegisterField { + uint8_t reg; + uint8_t shift; + uint8_t width; + bool is_signed; + + constexpr uint32_t mask() const { return ((width >= 32) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift; } +}; + +// Registers shared by the TMC22xx family (TMC2202, TMC2208, TMC2209, TMC2224, TMC2225, TMC2226) +static constexpr uint8_t REG_GCONF = 0x00; +static constexpr uint8_t REG_GSTAT = 0x01; +static constexpr uint8_t REG_IOIN = 0x06; +static constexpr uint8_t REG_FACTORY_CONF = 0x07; +static constexpr uint8_t REG_IHOLD_IRUN = 0x10; +static constexpr uint8_t REG_TPOWERDOWN = 0x11; +static constexpr uint8_t REG_TPWMTHRS = 0x13; +static constexpr uint8_t REG_VACTUAL = 0x22; +static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_PWMCONF = 0x70; + +static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; +static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; +static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; +static constexpr RegisterField SHAFT{REG_GCONF, 3, 1, false}; +static constexpr RegisterField INDEX_OTPW{REG_GCONF, 4, 1, false}; +static constexpr RegisterField INDEX_STEP{REG_GCONF, 5, 1, false}; +static constexpr RegisterField PDN_DISABLE{REG_GCONF, 6, 1, false}; +static constexpr RegisterField MSTEP_REG_SELECT{REG_GCONF, 7, 1, false}; +static constexpr RegisterField MULTISTEP_FILT{REG_GCONF, 8, 1, false}; +static constexpr RegisterField VERSION{REG_IOIN, 24, 8, false}; +static constexpr RegisterField OTTRIM{REG_FACTORY_CONF, 8, 2, false}; +static constexpr RegisterField IHOLD{REG_IHOLD_IRUN, 0, 5, false}; +static constexpr RegisterField IRUN{REG_IHOLD_IRUN, 8, 5, false}; +static constexpr RegisterField IHOLDDELAY{REG_IHOLD_IRUN, 16, 4, false}; +static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; +static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; +static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; +static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +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 FREEWHEEL{REG_PWMCONF, 20, 2, false}; + +enum StandstillMode : uint8_t { + STANDSTILL_MODE_NORMAL = 0, + STANDSTILL_MODE_FREEWHEELING = 1, + STANDSTILL_MODE_COIL_SHORT_LS = 2, + STANDSTILL_MODE_COIL_SHORT_HS = 3, +}; + +/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. +struct IndexPulseStore { + volatile int32_t pulses{0}; + volatile int8_t direction{0}; + static void gpio_intr(IndexPulseStore *arg); +}; + +/// Base class for Trinamic stepper drivers that are configured over the single wire UART interface. +class TMC22XXStepper : public stepper::Stepper, public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_address(uint8_t address) { this->address_ = address; } + void set_enn_pin(GPIOPin *pin) { this->enn_pin_ = pin; } + void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } + void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } + void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } + void set_rsense(float rsense) { this->rsense_ = rsense; } + void set_vsense(bool vsense) { this->vsense_ = vsense; } + void set_ottrim(uint8_t ottrim) { this->ottrim_ = ottrim; } + void set_analog_current_scale(bool enable) { this->analog_current_scale_ = enable; } + void set_initial_run_current(float current) { this->initial_run_current_ = current; } + void set_initial_hold_current(float current) { this->initial_hold_current_ = current; } + void set_initial_microsteps(uint16_t microsteps) { this->initial_microsteps_ = microsteps; } + + /// 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_; } + + void set_microsteps(uint16_t microsteps); + uint16_t get_microsteps(); + void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } + void set_spreadcycle(bool enable) { this->write_field(EN_SPREADCYCLE, enable); } + void set_inverse_direction(bool inverse) { this->write_field(SHAFT, inverse); } + void set_tpwm_threshold(uint32_t threshold) { this->write_field(TPWMTHRS, threshold); } + + /// Set the motor run or hold current in A RMS. + /// Not supported with analog current scale, use set_irun() and set_ihold() instead. + void set_run_current(float current); + void set_hold_current(float current); + void set_irun(uint8_t irun) { this->write_field(IRUN, irun); } + void set_ihold(uint8_t ihold) { this->write_field(IHOLD, ihold); } + void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } + void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } + void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + /// Convert a current scale (0-31) to A RMS, without analog current scale. + float scale_to_current(uint8_t scale); + + bool write_register(uint8_t reg, uint32_t value); + optional read_register(uint8_t reg); + bool write_field(const RegisterField &field, uint32_t value); + optional read_field(const RegisterField &field); + static uint32_t extract_field(uint32_t data, const RegisterField &field); + + protected: + /// IC version reported in IOIN by the supported chip. + virtual uint8_t expected_version() const = 0; + /// Return the cached value of a write-only register, or nullptr when the register is readable. + virtual uint32_t *shadow_register(uint8_t reg); + optional current_to_scale_(float current); + float full_scale_current_(); + int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } + int32_t speed_to_vactual_(float speed) const; + void add_index_pulses_(); + void loop_serial_(); + void loop_step_dir_(); + + GPIOPin *enn_pin_{nullptr}; + GPIOPin *step_pin_{nullptr}; + GPIOPin *dir_pin_{nullptr}; + InternalGPIOPin *index_pin_{nullptr}; + uint32_t clock_frequency_{12000000}; + // Write-only registers shared by all family members, with their power-on values + uint32_t ihold_irun_{0x00071703}; + uint32_t tpowerdown_{0x14}; + uint32_t tpwmthrs_{0}; + uint32_t vactual_reg_{0}; + // NaN while not configured + float rsense_{NAN}; + float initial_run_current_{NAN}; + float initial_hold_current_{NAN}; + IndexPulseStore index_store_{}; + uint16_t initial_microsteps_{16}; + optional vsense_{}; + optional ottrim_{}; + uint8_t address_{0}; + uint8_t toff_{3}; + uint8_t version_{0}; + int8_t direction_{0}; + bool analog_current_scale_{false}; + bool enabled_{false}; + bool vsense_active_{false}; + bool step_state_{false}; + HighFrequencyLoopRequester high_freq_; +}; + +} // namespace esphome::tmc22xx diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml new file mode 100644 index 00000000000..ff10c79edcf --- /dev/null +++ b/tests/components/tmc2209/common.yaml @@ -0,0 +1,57 @@ +stepper: + - platform: tmc2209 + id: tmc2209_step_dir + uart_id: uart_bus + address: 0 + step_pin: ${step_pin} + dir_pin: ${dir_pin} + enn_pin: ${enn_pin} + rsense: 110 mOhm + vsense: false + ottrim: 1 + run_current: 800mA + hold_current: 400mA + microsteps: 16 + max_speed: 2000 steps/s + acceleration: 1000 steps/s^2 + deceleration: 1000 steps/s^2 + + - platform: tmc2209 + id: tmc2209_serial + uart_id: uart_bus + address: 1 + index_pin: ${index_pin} + clock_frequency: 12MHz + analog_current_scale: true + max_speed: 1000 steps/s + +esphome: + on_boot: + then: + - tmc2209.configure: + id: tmc2209_step_dir + inverse_direction: true + microsteps: 8 + interpolation: true + enable_spreadcycle: false + tpwm_threshold: 0 + - tmc2209.configure: + id: tmc2209_serial + microsteps: !lambda "return 32;" + - tmc2209.currents: + id: tmc2209_step_dir + run_current: 1A + hold_current: 0.5A + iholddelay: 4 + tpowerdown: 20 + standstill_mode: freewheeling + - tmc2209.currents: + id: tmc2209_serial + irun: 20 + ihold: !lambda "return 5;" + - tmc2209.enable: tmc2209_serial + - stepper.set_target: + id: tmc2209_serial + target: 1000 + - tmc2209.disable: + id: tmc2209_step_dir diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml new file mode 100644 index 00000000000..5634c186860 --- /dev/null +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3e4ea33f7a5 --- /dev/null +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO12 + dir_pin: GPIO13 + enn_pin: GPIO14 + index_pin: GPIO15 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + tmc2209: !include common.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml new file mode 100644 index 00000000000..722f8f201fc --- /dev/null +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -0,0 +1,9 @@ +substitutions: + step_pin: GPIO2 + dir_pin: GPIO3 + enn_pin: GPIO6 + index_pin: GPIO7 + +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml + tmc2209: !include common.yaml From fa519cea645745d3e08de08d8233c4bfbf6627da Mon Sep 17 00:00:00 2001 From: Ilya Esin Date: Mon, 12 Oct 2026 23:14:31 +0000 Subject: [PATCH 022/111] [apds9930] Add ambient light and proximity sensor (#13125) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/apds9930/__init__.py | 2 + esphome/components/apds9930/apds9930.cpp | 140 ++++++++++++++++++ esphome/components/apds9930/apds9930.h | 30 ++++ esphome/components/apds9930/sensor.py | 70 +++++++++ tests/components/apds9930/common.yaml | 13 ++ tests/components/apds9930/test.esp32-idf.yaml | 3 + .../components/apds9930/test.esp8266-ard.yaml | 3 + .../components/apds9930/test.rp2040-ard.yaml | 3 + 9 files changed, 265 insertions(+) create mode 100644 esphome/components/apds9930/__init__.py create mode 100644 esphome/components/apds9930/apds9930.cpp create mode 100644 esphome/components/apds9930/apds9930.h create mode 100644 esphome/components/apds9930/sensor.py create mode 100644 tests/components/apds9930/common.yaml create mode 100644 tests/components/apds9930/test.esp32-idf.yaml create mode 100644 tests/components/apds9930/test.esp8266-ard.yaml create mode 100644 tests/components/apds9930/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c637b0d200..99a12f839f3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apds9306/* @aodrenah +esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex esphome/components/as5600/* @ammmze diff --git a/esphome/components/apds9930/__init__.py b/esphome/components/apds9930/__init__.py new file mode 100644 index 00000000000..0e6330bd88a --- /dev/null +++ b/esphome/components/apds9930/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@iyesin"] +DOMAIN = "apds9930" diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp new file mode 100644 index 00000000000..6debc0d9c7b --- /dev/null +++ b/esphome/components/apds9930/apds9930.cpp @@ -0,0 +1,140 @@ +#include "apds9930.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::apds9930 { + +ESPHOME_LOG_TAG(TAG, "apds9930"); + +// Register addresses +static constexpr uint8_t APDS9930_ENABLE = 0x00; +static constexpr uint8_t APDS9930_ATIME = 0x01; +static constexpr uint8_t APDS9930_PTIME = 0x02; +static constexpr uint8_t APDS9930_PPULSE = 0x0E; +static constexpr uint8_t APDS9930_CONTROL = 0x0F; +static constexpr uint8_t APDS9930_ID = 0x12; +static constexpr uint8_t APDS9930_STATUS = 0x13; + +// Command byte: repeated byte and auto-increment protocols +static constexpr uint8_t APDS9930_CMD = 0x80; +static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0; + +static constexpr uint8_t APDS9930_CHIP_ID = 0x39; + +// ENABLE register bits +static constexpr uint8_t APDS9930_PON = 0x01; +static constexpr uint8_t APDS9930_AEN = 0x02; +static constexpr uint8_t APDS9930_PEN = 0x04; + +// STATUS register bits +static constexpr uint8_t APDS9930_AVALID = 0x01; +static constexpr uint8_t APDS9930_PVALID = 0x02; + +// CONTROL register fields +static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6; +static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4; +static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2; +static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0; +static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode + +static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms +static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms +static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses + +// Lux calculation coefficients +static constexpr float APDS9930_DF = 52.0f; +static constexpr float APDS9930_GA = 0.49f; +static constexpr float APDS9930_ALS_B = 1.862f; +static constexpr float APDS9930_ALS_C = 0.746f; +static constexpr float APDS9930_ALS_D = 1.291f; +static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE); + +static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120}; +static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8}; +static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step + +// Lux per count at 1x gain and the fixed integration time +static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS; +// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels +static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); + +bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } + +void APDS9930Component::setup() { + uint8_t id; + if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { + this->mark_failed(LOG_STR("Communication failed")); + return; + } + if (id != APDS9930_CHIP_ID) { + ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + + const uint8_t control = + (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | + (this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT; + + uint8_t enable = APDS9930_PON; + if (this->illuminance_sensor_ != nullptr) + enable |= APDS9930_AEN; + if (this->proximity_sensor_ != nullptr) + enable |= APDS9930_PEN; + + if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) || + !this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) || + !this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) || + !this->write_reg_(APDS9930_ENABLE, enable)) { + this->mark_failed(LOG_STR("Configuration failed")); + } +} + +void APDS9930Component::dump_config() { + ESP_LOGCONFIG(TAG, + "APDS9930:\n" + " LED Drive: %.1f mA\n" + " Proximity Gain: %ux\n" + " Ambient Light Gain: %ux", + LED_CURRENT_MAX_MA / (1 << this->led_drive_), + progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]), + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); +} + +void APDS9930Component::update() { + // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H + uint8_t raw[7]; + if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { + this->status_set_warning(LOG_STR("Reading data failed")); + return; + } + this->status_clear_warning(); + const uint8_t status = raw[0]; + + if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { + const uint16_t ch0 = encode_uint16(raw[2], raw[1]); + const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { + // A saturated channel would give a lux value that is too low, not too high + ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); + this->illuminance_sensor_->publish_state(NAN); + } else { + const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f}); + this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X / + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + } + } + + if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { + this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + } +} + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/apds9930.h b/esphome/components/apds9930/apds9930.h new file mode 100644 index 00000000000..cc7f77d3d02 --- /dev/null +++ b/esphome/components/apds9930/apds9930.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::apds9930 { + +class APDS9930Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(illuminance) + SUB_SENSOR(proximity) + + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_led_drive(uint8_t level) { this->led_drive_ = level; } + void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; } + void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; } + + protected: + bool write_reg_(uint8_t reg, uint8_t value); + + uint8_t led_drive_{0}; + uint8_t proximity_gain_{0}; + uint8_t ambient_gain_{0}; +}; + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py new file mode 100644 index 00000000000..2b169271c68 --- /dev/null +++ b/esphome/components/apds9930/sensor.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + UNIT_LUX, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain" +CONF_LED_DRIVE = "led_drive" +CONF_PROXIMITY = "proximity" +CONF_PROXIMITY_GAIN = "proximity_gain" + +DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3} +PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3} +AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3} + +apds9930_ns = cg.esphome_ns.namespace("apds9930") +APDS9930Component = apds9930_ns.class_( + "APDS9930Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APDS9930Component), + cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum( + DRIVE_LEVELS, lower=True + ), + cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum( + PROXIMITY_LEVELS, lower=True + ), + cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum( + AMBIENT_LEVELS, lower=True + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROXIMITY): sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x39)) +) + + +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) + cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) + cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) + cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_PROXIMITY, var.set_proximity_sensor) diff --git a/tests/components/apds9930/common.yaml b/tests/components/apds9930/common.yaml new file mode 100644 index 00000000000..2cb9547e5cf --- /dev/null +++ b/tests/components/apds9930/common.yaml @@ -0,0 +1,13 @@ +sensor: + - platform: apds9930 + i2c_id: i2c_bus + id: apds9930_sensor + address: 0x39 + update_interval: 30s + led_drive: 25ma + proximity_gain: 4x + ambient_light_gain: 16x + illuminance: + name: APDS9930 Illuminance + proximity: + name: APDS9930 Proximity diff --git a/tests/components/apds9930/test.esp32-idf.yaml b/tests/components/apds9930/test.esp32-idf.yaml new file mode 100644 index 00000000000..8226f36aa74 --- /dev/null +++ b/tests/components/apds9930/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.esp8266-ard.yaml b/tests/components/apds9930/test.esp8266-ard.yaml new file mode 100644 index 00000000000..913a4adeeaa --- /dev/null +++ b/tests/components/apds9930/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + apds9930: !include common.yaml diff --git a/tests/components/apds9930/test.rp2040-ard.yaml b/tests/components/apds9930/test.rp2040-ard.yaml new file mode 100644 index 00000000000..be77d931dfc --- /dev/null +++ b/tests/components/apds9930/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + apds9930: !include common.yaml From b21354d6d9bd33803db17b0ffef48a141e1b19f4 Mon Sep 17 00:00:00 2001 From: Romain Luyten Date: Tue, 13 Oct 2026 01:22:34 +0200 Subject: [PATCH 023/111] [mistral_ir] Add climate component for Mistral MHFE1126A IR AC units (#17812) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/mistral_ir/__init__.py | 1 + esphome/components/mistral_ir/climate.py | 16 + esphome/components/mistral_ir/mistral_ir.cpp | 135 +++++++ esphome/components/mistral_ir/mistral_ir.h | 38 ++ tests/components/mistral_ir/__init__.py | 14 + tests/components/mistral_ir/benchmark.yaml | 4 + tests/components/mistral_ir/common.yaml | 4 + .../mistral_ir/mistral_ir_tests.cpp | 330 ++++++++++++++++++ .../components/mistral_ir/test.esp32-idf.yaml | 3 + .../mistral_ir/test.esp8266-ard.yaml | 3 + 11 files changed, 549 insertions(+) create mode 100644 esphome/components/mistral_ir/__init__.py create mode 100644 esphome/components/mistral_ir/climate.py create mode 100644 esphome/components/mistral_ir/mistral_ir.cpp create mode 100644 esphome/components/mistral_ir/mistral_ir.h create mode 100644 tests/components/mistral_ir/__init__.py create mode 100644 tests/components/mistral_ir/benchmark.yaml create mode 100644 tests/components/mistral_ir/common.yaml create mode 100644 tests/components/mistral_ir/mistral_ir_tests.cpp create mode 100644 tests/components/mistral_ir/test.esp32-idf.yaml create mode 100644 tests/components/mistral_ir/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 99a12f839f3..f7875eb1407 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -379,6 +379,7 @@ esphome/components/midea_ir/* @dudanov esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow +esphome/components/mistral_ir/* @lerhum esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt diff --git a/esphome/components/mistral_ir/__init__.py b/esphome/components/mistral_ir/__init__.py new file mode 100644 index 00000000000..deadd224d54 --- /dev/null +++ b/esphome/components/mistral_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mistral_ir" diff --git a/esphome/components/mistral_ir/climate.py b/esphome/components/mistral_ir/climate.py new file mode 100644 index 00000000000..cc152ca85d3 --- /dev/null +++ b/esphome/components/mistral_ir/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@lerhum"] + +mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir") +MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/mistral_ir/mistral_ir.cpp b/esphome/components/mistral_ir/mistral_ir.cpp new file mode 100644 index 00000000000..2eafcb348af --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.cpp @@ -0,0 +1,135 @@ +#include "mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::mistral_ir { + +ESPHOME_LOG_TAG(TAG, "mistral_ir.climate"); + +// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state +static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE8, 0x00, 0x60, 0x00}; +static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10}; +static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8}; + +constexpr uint8_t MISTRAL_POWER_OFF = 0x00; +constexpr uint8_t MISTRAL_POWER_ON = 0x20; +constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F; +constexpr uint8_t MISTRAL_MODE_AUTO = 0x10; + +// The tables live in flash, so entries are copied out before use +struct ModeCode { + climate::ClimateMode mode; + uint8_t code; +}; +static const ModeCode MODES[] PROGMEM = { + {climate::CLIMATE_MODE_COOL, 0xC0}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x80}, + {climate::CLIMATE_MODE_HEAT, 0x60}, + {climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO}, +}; + +struct FanCode { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; +}; +static const FanCode FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0xA4, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x44, 0x40}, + {climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0}, + {climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0}, +}; + +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +template static optional find_entry(const T (&table)[N], Pred pred) { + for (const T &slot : table) { + T entry = load_entry(slot); + if (pred(entry)) + return entry; + } + return {}; +} + +static uint8_t compute_checksum(const uint8_t *frame) { + uint8_t sum = 0; + for (const uint8_t &idx : CHECKSUM_BYTES) + sum += reverse_bits(frame[progmem_read_byte(&idx)]); + return reverse_bits(sum); +} + +static bool is_valid_frame(const uint8_t *frame) { + for (const uint8_t &slot : FIXED_BYTES) { + const uint8_t idx = progmem_read_byte(&slot); + if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx])) + return false; + } + return frame[11] == compute_checksum(frame); +} + +void MistralIR::transmit_state() { + const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; }); + const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO); + const FanCode fan = + find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0])); + const uint8_t temp = lroundf(clamp(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX)); + + uint8_t frame[MISTRAL_FRAME_SIZE]; + progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame)); + frame[1] = fan.byte1; + frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON; + frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO; + frame[5] = reverse_bits(static_cast(MISTRAL_TEMP_OFFSET - temp)); + frame[6] = fan.byte6; + frame[11] = compute_checksum(frame); + + auto transmit = this->transmitter_->transmit(); + remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame)); + transmit.perform(); +} + +bool MistralIR::on_receive(remote_base::RemoteReceiveData data) { + auto aeha = remote_base::AEHAProtocol().decode(data); + if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE) + return false; + + const uint8_t *frame = aeha->data.data(); + if (!is_valid_frame(frame)) + return false; + + if (frame[3] == MISTRAL_POWER_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + if (frame[3] != MISTRAL_POWER_ON) + return false; + + // Decode everything before touching the state so an unknown code leaves it untouched + const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; }); + const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; }); + if (!mode.has_value() || !fan.has_value()) + return false; + + this->mode = mode->mode; + this->target_temperature = + clamp(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX); + this->fan_mode = fan->fan; + + ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast(this->mode), + this->target_temperature, static_cast(fan->fan)); + this->publish_state(); + return true; +} + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mistral_ir/mistral_ir.h b/esphome/components/mistral_ir/mistral_ir.h new file mode 100644 index 00000000000..172af1d6a25 --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::mistral_ir { + +// Temperature range, Celsius +constexpr uint8_t MISTRAL_TEMP_MIN = 16; +constexpr uint8_t MISTRAL_TEMP_MAX = 30; + +constexpr uint16_t MISTRAL_ADDRESS = 0x322C; +constexpr size_t MISTRAL_FRAME_SIZE = 12; + +/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit + from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address + 0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded + directly here rather than through the generic AEHA helper. + + Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx + byte[1] / byte[6] -- fan speed + byte[3] -- power: 0x20 on, 0x00 off + byte[4] -- mode + byte[5] -- target temperature (bit-reversed offset from 0x9F) + byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8 +*/ +class MistralIR : public climate_ir::ClimateIR { + public: + MistralIR() + : climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace esphome::mistral_ir diff --git a/tests/components/mistral_ir/__init__.py b/tests/components/mistral_ir/__init__.py new file mode 100644 index 00000000000..c74d97e1d16 --- /dev/null +++ b/tests/components/mistral_ir/__init__.py @@ -0,0 +1,14 @@ +from esphome.components import remote_base +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The platform's to_code never runs here; without this request remote_base drops aeha_protocol.cpp + remote_base.request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/mistral_ir/benchmark.yaml b/tests/components/mistral_ir/benchmark.yaml new file mode 100644 index 00000000000..ef3f9fb05d1 --- /dev/null +++ b/tests/components/mistral_ir/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The mistral_ir, climate_ir and remote_transmitter sources come in through +# the dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/mistral_ir/common.yaml b/tests/components/mistral_ir/common.yaml new file mode 100644 index 00000000000..43a2225bcba --- /dev/null +++ b/tests/components/mistral_ir/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mistral_ir + name: Mistral IR + transmitter_id: xmitr diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp new file mode 100644 index 00000000000..37db86d10a1 --- /dev/null +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -0,0 +1,330 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/mistral_ir/mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +namespace esphome::mistral_ir::testing { + +using Frame = std::vector; + +static constexpr uint8_t POWER_OFF = 0x00; +static constexpr uint8_t POWER_ON = 0x20; +static constexpr uint8_t MODE_COOL = 0xC0; +static constexpr uint8_t MODE_DRY = 0x40; +static constexpr uint8_t MODE_FAN_ONLY = 0x80; +static constexpr uint8_t MODE_HEAT = 0x60; +static constexpr uint8_t MODE_AUTO = 0x10; +static constexpr uint8_t FAN_AUTO_BYTE1 = 0xA4; +static constexpr uint8_t FAN_AUTO_BYTE6 = 0x00; +static constexpr uint8_t FAN_LOW_BYTE1 = 0x44; +static constexpr uint8_t FAN_LOW_BYTE6 = 0x40; +static constexpr uint8_t FAN_MEDIUM_BYTE1 = 0x24; +static constexpr uint8_t FAN_MEDIUM_BYTE6 = 0xC0; +static constexpr uint8_t FAN_HIGH_BYTE1 = 0xA4; +static constexpr uint8_t FAN_HIGH_BYTE6 = 0xA0; +static constexpr uint16_t WRONG_ADDRESS = 0x322D; +static constexpr uint32_t TOLERANCE_PERCENT = 25; + +static constexpr climate::ClimateMode ALL_MODES[] = { + climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, +}; +static constexpr climate::ClimateFanMode ALL_FANS[] = { + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, +}; + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +static uint8_t checksum(const Frame &frame) { + uint8_t sum = 0; + for (size_t idx : {1, 3, 4, 5, 6, 8}) + sum += reverse_bits(frame[idx]); + return reverse_bits(sum); +} + +/// A received frame with a valid checksum, which callers may overwrite +static Frame make_frame(uint8_t byte1, uint8_t power, uint8_t mode, uint8_t temperature, uint8_t byte6) { + Frame frame{0x56, byte1, 0x08, power, mode, reverse_bits(static_cast(0x9F - temperature)), + byte6, 0x00, 0xE8, 0x00, 0x60, 0x00}; + frame[11] = checksum(frame); + return frame; +} + +static remote_base::RawTimings encode(uint16_t address, const Frame &frame) { + remote_base::RemoteTransmitData data; + remote_base::AEHAProtocol().encode(&data, address, frame.data(), frame.size()); + return data.get_data(); +} + +struct Fixture { + CapturingTransmitter transmitter; + MistralIR climate; + + Fixture() { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_mistral_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.perform(); + } + + /// The 12 data bytes of the last transmission, decoded by the AEHA protocol + Frame last_frame() const { + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return {}; + remote_base::RemoteReceiveData data(this->transmitter.frames.back(), TOLERANCE_PERCENT, + remote_base::TOLERANCE_MODE_PERCENTAGE); + const auto aeha = remote_base::AEHAProtocol().decode(data); + EXPECT_TRUE(aeha.has_value()); + if (!aeha.has_value()) + return {}; + EXPECT_EQ(aeha->address, MISTRAL_ADDRESS); + EXPECT_EQ(aeha->data.size(), MISTRAL_FRAME_SIZE); + return aeha->data; + } + + bool receive(const remote_base::RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + + bool receive(const Frame &frame, uint16_t address = MISTRAL_ADDRESS) { return this->receive(encode(address, frame)); } + + void expect_state(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan) const { + EXPECT_EQ(this->climate.mode, mode); + EXPECT_FLOAT_EQ(this->climate.target_temperature, temperature); + EXPECT_EQ(this->climate.fan_mode, fan); + } +}; + +TEST(MistralIR, EncodesEachMode) { + struct Case { + climate::ClimateMode mode; + uint8_t code; + uint8_t checksum; + }; + // 22 C: 0x9F - 22 = 0x89, reversed 0x91. Checksum sums the reversed bytes 1, 3, 4, 5, 6 and 8: + // fan AUTO 0xA4 -> 0x25, power on 0x20 -> 0x04, mode, temperature 0x89, byte 6 0x00, 0xE8 -> 0x17 + const Case cases[] = { + // COOL 0xC0 -> 0x03: 0x25 + 0x04 + 0x03 + 0x89 + 0x00 + 0x17 = 0xCC, reversed 0x33 + {climate::CLIMATE_MODE_COOL, MODE_COOL, 0x33}, + // DRY 0x40 -> 0x02: sum 0xCB, reversed 0xD3 + {climate::CLIMATE_MODE_DRY, MODE_DRY, 0xD3}, + // FAN_ONLY 0x80 -> 0x01: sum 0xCA, reversed 0x53 + {climate::CLIMATE_MODE_FAN_ONLY, MODE_FAN_ONLY, 0x53}, + // HEAT 0x60 -> 0x06: sum 0xCF, reversed 0xF3 + {climate::CLIMATE_MODE_HEAT, MODE_HEAT, 0xF3}, + // HEAT_COOL 0x10 -> 0x08: sum 0xD1, reversed 0x8B + {climate::CLIMATE_MODE_HEAT_COOL, MODE_AUTO, 0x8B}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.mode)); + f.command(c.mode, 22.0f, climate::CLIMATE_FAN_AUTO); + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_ON, c.code, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, c.checksum}; + EXPECT_EQ(f.last_frame(), expected); + } +} + +TEST(MistralIR, EncodesEachFanMode) { + struct Case { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; + uint8_t checksum; + }; + // COOL at 24 C: 0x9F - 24 = 0x87, reversed 0xE1. Fixed part of the checksum sum: + // power on 0x04 + COOL 0x03 + temperature 0x87 + 0xE8 -> 0x17 = 0xA5, plus reversed bytes 1 and 6 + const Case cases[] = { + // AUTO 0xA4 -> 0x25, 0x00 -> 0x00: 0xA5 + 0x25 + 0x00 = 0xCA, reversed 0x53 + {climate::CLIMATE_FAN_AUTO, FAN_AUTO_BYTE1, FAN_AUTO_BYTE6, 0x53}, + // LOW 0x44 -> 0x22, 0x40 -> 0x02: 0xA5 + 0x22 + 0x02 = 0xC9, reversed 0x93 + {climate::CLIMATE_FAN_LOW, FAN_LOW_BYTE1, FAN_LOW_BYTE6, 0x93}, + // MEDIUM 0x24 -> 0x24, 0xC0 -> 0x03: 0xA5 + 0x24 + 0x03 = 0xCC, reversed 0x33 + {climate::CLIMATE_FAN_MEDIUM, FAN_MEDIUM_BYTE1, FAN_MEDIUM_BYTE6, 0x33}, + // HIGH 0xA4 -> 0x25, 0xA0 -> 0x05: 0xA5 + 0x25 + 0x05 = 0xCF, reversed 0xF3 + {climate::CLIMATE_FAN_HIGH, FAN_HIGH_BYTE1, FAN_HIGH_BYTE6, 0xF3}, + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.fan)); + f.command(climate::CLIMATE_MODE_COOL, 24.0f, c.fan); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[1], c.byte1); + EXPECT_EQ(frame[4], MODE_COOL); + EXPECT_EQ(frame[5], 0xE1); + EXPECT_EQ(frame[6], c.byte6); + EXPECT_EQ(frame[11], c.checksum); + } +} + +TEST(MistralIR, EncodesOff) { + Fixture f; + f.command(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_AUTO); + // OFF has no mode code, so byte 4 falls back to auto 0x10 -> 0x08 with power 0x00: + // 0x25 + 0x00 + 0x08 + 0x89 + 0x00 + 0x17 = 0xCD, reversed 0xB3 + const Frame expected{0x56, FAN_AUTO_BYTE1, 0x08, POWER_OFF, MODE_AUTO, 0x91, + FAN_AUTO_BYTE6, 0x00, 0xE8, 0x00, 0x60, 0xB3}; + EXPECT_EQ(f.last_frame(), expected); +} + +TEST(MistralIR, EncodesTemperatureRoundedAndClamped) { + struct Case { + float requested; + uint8_t byte5; + }; + const Case cases[] = { + {16.0f, 0xF1}, // 0x9F - 16 = 0x8F, reversed 0xF1 + {22.0f, 0x91}, // 0x9F - 22 = 0x89, reversed 0x91 + {30.0f, 0x81}, // 0x9F - 30 = 0x81, reversed 0x81 + {15.6f, 0xF1}, // rounds to 16 + {30.4f, 0x81}, // rounds to 30 + {10.0f, 0xF1}, // clamps to 16 + {35.0f, 0x81}, // clamps to 30 + }; + Fixture f; + for (const Case &c : cases) { + SCOPED_TRACE(c.requested); + f.command(climate::CLIMATE_MODE_COOL, c.requested, climate::CLIMATE_FAN_AUTO); + const Frame frame = f.last_frame(); + ASSERT_EQ(frame.size(), MISTRAL_FRAME_SIZE); + EXPECT_EQ(frame[5], c.byte5); + EXPECT_EQ(frame[11], checksum(frame)); + } +} + +TEST(MistralIR, RoundTripsEveryModeAndFan) { + for (climate::ClimateMode mode : ALL_MODES) { + for (climate::ClimateFanMode fan : ALL_FANS) { + for (float temperature : {16.0f, 23.0f, 30.0f}) { + SCOPED_TRACE(static_cast(mode) * 1000 + static_cast(fan) * 100 + static_cast(temperature)); + Fixture sender; + sender.command(mode, temperature, fan); + EXPECT_EQ(sender.last_frame().size(), MISTRAL_FRAME_SIZE); + Fixture receiver; + EXPECT_TRUE(receiver.receive(sender.transmitter.frames.back())); + receiver.expect_state(mode, temperature, fan); + } + } + } +} + +TEST(MistralIR, ReceivesHeatFrame) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_HEAT, 25.0f, climate::CLIMATE_FAN_LOW); +} + +TEST(MistralIR, ReceivedPowerOffKeepsTemperatureAndFan) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + EXPECT_TRUE(f.receive(make_frame(FAN_AUTO_BYTE1, POWER_OFF, MODE_AUTO, 28, FAN_AUTO_BYTE6))); + f.expect_state(climate::CLIMATE_MODE_OFF, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsBadChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[11] ^= 0x01; + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsChangedFixedByte) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (size_t idx : {0, 2, 7, 8, 9, 10}) { + SCOPED_TRACE(idx); + Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + frame[idx] ^= 0x01; + // Byte 8 is part of the checksum, so keep it valid to test the fixed-byte check alone + frame[11] = checksum(frame); + EXPECT_FALSE(f.receive(frame)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsUnknownPowerModeAndFan) { + struct Case { + const char *name; + uint8_t byte1; + uint8_t power; + uint8_t mode; + uint8_t byte6; + }; + const Case cases[] = { + {"power 0x10", FAN_LOW_BYTE1, 0x10, MODE_HEAT, FAN_LOW_BYTE6}, + {"mode 0x20", FAN_LOW_BYTE1, POWER_ON, 0x20, FAN_LOW_BYTE6}, + {"fan 0x44/0x00", FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, FAN_AUTO_BYTE6}, + {"fan 0x00/0x40", 0x00, POWER_ON, MODE_HEAT, FAN_LOW_BYTE6}, + }; + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + for (const Case &c : cases) { + SCOPED_TRACE(c.name); + EXPECT_FALSE(f.receive(make_frame(c.byte1, c.power, c.mode, 25, c.byte6))); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + } +} + +TEST(MistralIR, RejectsWrongAddress) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + EXPECT_FALSE(f.receive(frame, WRONG_ADDRESS)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +TEST(MistralIR, RejectsWrongFrameLength) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); + const Frame frame = make_frame(FAN_LOW_BYTE1, POWER_ON, MODE_HEAT, 25, FAN_LOW_BYTE6); + Frame shorter(frame.begin(), frame.begin() + 11); + EXPECT_FALSE(f.receive(shorter)); + Frame longer = frame; + longer.push_back(0x00); + EXPECT_FALSE(f.receive(longer)); + f.expect_state(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_FAN_HIGH); +} + +} // namespace esphome::mistral_ir::testing diff --git a/tests/components/mistral_ir/test.esp32-idf.yaml b/tests/components/mistral_ir/test.esp32-idf.yaml new file mode 100644 index 00000000000..4f0186d9eb8 --- /dev/null +++ b/tests/components/mistral_ir/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + mistral_ir: !include common.yaml diff --git a/tests/components/mistral_ir/test.esp8266-ard.yaml b/tests/components/mistral_ir/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3dddb492665 --- /dev/null +++ b/tests/components/mistral_ir/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + mistral_ir: !include common.yaml From 6c86a8903f1718a00e804d214cba783603f27a02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:28:17 -1000 Subject: [PATCH 024/111] [core] Report incomplete YAML discovery and record remote package sources (#20312) --- esphome/bundle.py | 12 +++ esphome/components/packages/__init__.py | 40 +++++++- esphome/yaml_util.py | 96 ++++++++++++++----- .../component_tests/packages/test_packages.py | 45 +++++++++ tests/unit_tests/test_bundle.py | 9 +- tests/unit_tests/test_yaml_util.py | 62 ++++++++++-- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/esphome/bundle.py b/esphome/bundle.py index 45d9a74b49a..f499e0de1bc 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -418,6 +418,18 @@ class ConfigBundleCreator: must ship every candidate so the remote build can pick any one. """ discovered = yaml_util.discover_user_yaml_files(self._config_path) + if discovered.load_errors: + _LOGGER.warning( + "Bundle may be incomplete; could not load all configuration files: %s", + "; ".join(discovered.load_errors), + ) + if discovered.unresolved: + _LOGGER.warning( + "Bundle may be incomplete; %d !include path(s) use " + "substitutions and cannot be captured: %s", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) self._secrets_paths.update(discovered.secrets) # A !secret inside a file this re-parse does not reach (for example # a git-fetched package the builder re-fetches) still resolves diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 9498d4b4da7..577385b2174 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,6 @@ from collections import UserDict from collections.abc import Callable +from dataclasses import dataclass, field from functools import reduce import logging from pathlib import Path @@ -35,11 +36,45 @@ from esphome.const import ( CONF_VARS, __version__ as ESPHOME_VERSION, ) -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) DOMAIN = "packages" + + +@dataclass(frozen=True) +class RemotePackageSource: + """A remote source a package was fetched from while processing the config.""" + + url: str + ref: str | None + + +@dataclass +class PackagesData: + """Per-run package state, keyed under DOMAIN in CORE.data.""" + + # Insertion-ordered and de-duplicated + remote_sources: dict[RemotePackageSource, None] = field(default_factory=dict) + + +def _get_data() -> PackagesData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PackagesData() + return CORE.data[DOMAIN] + + +def get_remote_package_sources() -> tuple[RemotePackageSource, ...]: + """Remote sources fetched for this config, in fetch order. + + store_yaml uses them to tell remote parts of the config from local files. + """ + if (data := CORE.data.get(DOMAIN)) is None: + return () + return tuple(data.remote_sources) + + # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -193,6 +228,9 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) + _get_data().remote_sources[ + RemotePackageSource(config[CONF_URL], config.get(CONF_REF)) + ] = None files: list[dict[str, Any]] = [] # ``repo_root`` is the directory containing ``.git`` and must be passed diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 5882c133f6a..f02819a3edb 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -12,7 +12,7 @@ import math import os from pathlib import Path import re -from typing import Any +from typing import Any, NamedTuple import uuid import yaml @@ -410,9 +410,11 @@ def _load_include_candidates( include: IncludeFile, *, warn_on_unresolved: bool, + warn_on_load_error: bool, seen: set[int], expanded_paths: set[Path], keepalive: list[Any], + result: ForceLoadResult, ) -> None: """Load every filesystem candidate for an unresolved ``IncludeFile``.""" from voluptuous import Invalid @@ -425,6 +427,7 @@ def _load_include_candidates( include.file, include.parent_file, ) + result.unresolved.append(str(include.file)) return _LOGGER.debug( "Expanding !include %s (referenced from %s) to %d candidate file(s)", @@ -439,27 +442,27 @@ def _load_include_candidates( try: loaded = include.with_file(candidate.as_posix()).load() except (EsphomeError, Invalid) as err: - # Unlike an unresolved pattern (expected during the discovery - # re-parse), a matched on-disk candidate that fails to load is a - # genuine user error; warn in every mode. The file itself is - # still tracked (the load listener fires before parsing), only - # its nested includes go undiscovered. - _LOGGER.warning( + # A real user error. The file stays tracked (the listener fires + # before parsing); only its nested includes are missed. + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load candidate %s for !include %s: %s", candidate, include.file, err, ) + result.errors.append(f"{candidate}: {err}") continue # The throwaway IncludeFile is this tree's only owner; keep the tree # alive so ids recorded in ``seen`` stay unique for the traversal. keepalive.append(loaded) - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=seen, _expanded_paths=expanded_paths, _keepalive=keepalive, + _result=result, ) @@ -473,14 +476,19 @@ def find_secret_references(text: str) -> set[str]: return {match.group(1) for match in _SECRET_REFERENCE_RE.finditer(text)} +class ForceLoadResult(NamedTuple): + """Templated includes that matched no file, and includes that failed to load. + + Either being non-empty means the walk was incomplete. + """ + + unresolved: list[str] + errors: list[str] + + def force_load_include_files( - obj: Any, - *, - warn_on_unresolved: bool = True, - _seen: set[int] | None = None, - _expanded_paths: set[Path] | None = None, - _keepalive: list[Any] | None = None, -) -> None: + obj: Any, *, warn_on_unresolved: bool = True, warn_on_load_error: bool = True +) -> ForceLoadResult: """Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree. Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved @@ -495,8 +503,30 @@ def force_load_include_files( expression could select. By default a warning is logged when no candidate exists; pass ``warn_on_unresolved=False`` (used by discovery paths that run on a fresh re-parse where substitutions haven't been applied yet) to - demote it to a debug log. + demote it to a debug log. Callers that report ``errors`` themselves pass + ``warn_on_load_error=False`` so load failures are not logged twice. """ + result = ForceLoadResult([], []) + _force_load_include_files( + obj, + warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, + _result=result, + ) + return result + + +def _force_load_include_files( + obj: Any, + *, + warn_on_unresolved: bool = True, + warn_on_load_error: bool = True, + _seen: set[int] | None = None, + _expanded_paths: set[Path] | None = None, + _keepalive: list[Any] | None = None, + _result: ForceLoadResult, +) -> None: + """Walk for :func:`force_load_include_files`, adding to ``result``.""" from voluptuous import Invalid if _seen is None: @@ -519,51 +549,60 @@ def force_load_include_files( _load_include_candidates( obj, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, seen=_seen, expanded_paths=_expanded_paths, keepalive=_keepalive, + result=_result, ) return try: loaded = obj.load() except (EsphomeError, Invalid) as err: - _LOGGER.warning( + (_LOGGER.warning if warn_on_load_error else _LOGGER.debug)( "Failed to load !include %s (referenced from %s): %s", obj.file, obj.parent_file, err, ) + _result.errors.append(f"{obj.file}: {err}") return - force_load_include_files( + _force_load_include_files( loaded, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, dict): if id(obj) in _seen: return _seen.add(id(obj)) for value in obj.values(): - force_load_include_files( + _force_load_include_files( value, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) elif isinstance(obj, (list, tuple)): if id(obj) in _seen: return _seen.add(id(obj)) for item in obj: - force_load_include_files( + _force_load_include_files( item, warn_on_unresolved=warn_on_unresolved, + warn_on_load_error=warn_on_load_error, _seen=_seen, _expanded_paths=_expanded_paths, _keepalive=_keepalive, + _result=_result, ) @@ -575,11 +614,14 @@ class DiscoveredYamlFiles: were re-parsing the user's config; ``secrets`` is the subset whose *un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so a ``secrets.yaml`` symlinked to a differently-named target is still - flagged as secrets). + flagged as secrets). ``files`` is incomplete when ``unresolved`` (templated + include paths) or ``load_errors`` is non-empty. """ files: list[Path] = field(default_factory=list) secrets: set[Path] = field(default_factory=set) + unresolved: list[str] = field(default_factory=list) + load_errors: list[str] = field(default_factory=list) def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: @@ -609,9 +651,13 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: try: try: data = load_yaml(config_path) - except EsphomeError: - return DiscoveredYamlFiles(list(loaded), secrets) - force_load_include_files(data, warn_on_unresolved=False) + except EsphomeError as err: + return DiscoveredYamlFiles( + list(loaded), secrets, load_errors=[f"{config_path}: {err}"] + ) + unresolved, load_errors = force_load_include_files( + data, warn_on_unresolved=False, warn_on_load_error=False + ) finally: _load_listeners.remove(_capture_secret) @@ -622,7 +668,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: if path not in seen: seen.add(path) unique.append(path) - return DiscoveredYamlFiles(unique, secrets) + return DiscoveredYamlFiles(unique, secrets, unresolved, load_errors) def _add_data_ref(fn): diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 418cab5ea15..fd7e9f12786 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -10,9 +10,11 @@ import pytest from esphome import bundle from esphome.components.packages import ( CONFIG_SCHEMA, + RemotePackageSource, _substitute_package_definition, _walk_packages, do_packages_pass, + get_remote_package_sources, merge_packages, resolve_packages, ) @@ -614,6 +616,49 @@ def test_package_remove_by_missing_id() -> None: assert actual == expected +def test_get_remote_package_sources_empty_without_remote_packages() -> None: + """A config without remote packages records no sources.""" + assert get_remote_package_sources() == () + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_get_remote_package_sources_records_each_fetch_once( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """Every fetched remote source is recorded exactly once.""" + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + mock_load_yaml.side_effect = lambda *args, **kwargs: OrderedDict() + + config = { + CONF_PACKAGES: { + "first": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: [TEST_YAML_FILENAME], + }, + "again": { + CONF_URL: "https://github.com/esphome/repo-a", + CONF_REF: "main", + CONF_FILES: ["other.yaml"], + }, + "second": { + CONF_URL: "https://github.com/esphome/repo-b", + CONF_FILES: [TEST_YAML_FILENAME], + }, + } + } + packages_pass(config) + + # packages are processed last to first, so repo-b is fetched before repo-a + assert get_remote_package_sources() == ( + RemotePackageSource("https://github.com/esphome/repo-b", None), + RemotePackageSource("https://github.com/esphome/repo-a", "main"), + ) + + @patch("esphome.yaml_util.load_yaml") @patch("pathlib.Path.is_file") @patch("esphome.git.clone_or_update") diff --git a/tests/unit_tests/test_bundle.py b/tests/unit_tests/test_bundle.py index 6a086767840..fe4bf197157 100644 --- a/tests/unit_tests/test_bundle.py +++ b/tests/unit_tests/test_bundle.py @@ -1363,7 +1363,8 @@ def test_discover_files_candidate_outside_config_dir_skipped( def test_discover_files_nested_include_load_failure( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A nested !include pointing at a missing file is logged and skipped.""" + """A nested !include pointing at a missing file is reported in the bundle + summary, not a second time by yaml_util, and skipped.""" config_dir = _setup_config_dir(tmp_path) (config_dir / "test.yaml").write_text( "esphome:\n name: test\nwifi: !include missing.yaml\n" @@ -1374,10 +1375,12 @@ def test_discover_files_nested_include_load_failure( paths = [f.path for f in files] assert "test.yaml" in paths + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any( - "failed to load !include" in r.message.lower() and "missing.yaml" in r.message - for r in caplog.records + m.startswith("Bundle may be incomplete") and "missing.yaml" in m + for m in warnings ) + assert not any("failed to load !include" in m.lower() for m in warnings) def test_force_load_skips_duplicate_include_file() -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 24a431b0c05..c30b03467f1 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1280,22 +1280,52 @@ def test_force_load_include_files_unresolved_log_level( assert matching == [expect_level] +def test_force_load_include_files_returns_unresolved_paths( + patch_include_file: None, +) -> None: + """Includes with substitution-templated paths are reported back to the + caller; resolvable ones are not.""" + templated = _StubInclude("${var}.yaml", unresolved=True) + plain = _StubInclude("ok.yaml") + result = force_load_include_files({"a": templated, "b": plain}) + assert result.unresolved == [str(templated.file)] + assert result.errors == [] + assert plain.load_calls == 1 + + def test_force_load_include_files_warns_on_load_failure( patch_include_file: None, caplog: pytest.LogCaptureFixture, ) -> None: - """An `EsphomeError` raised by `load()` is caught and logged, not propagated.""" + """An `EsphomeError` raised by `load()` is caught, logged, and reported to + the caller — not propagated.""" stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) with caplog.at_level("WARNING", logger="esphome.yaml_util"): - force_load_include_files({"k": stub}) + result = force_load_include_files({"k": stub}) assert any( "Failed to load !include" in r.message and "missing.yaml" in r.message for r in caplog.records ) + assert result.errors == [f"{stub.file}: boom"] + assert result.unresolved == [] + + +def test_force_load_include_files_load_failure_quiet_when_caller_reports( + patch_include_file: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """With warn_on_load_error=False the failure is still returned but only + logged at debug, so a caller that reports it does not duplicate it.""" + stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom")) + with caplog.at_level("DEBUG", logger="esphome.yaml_util"): + result = force_load_include_files({"k": stub}, warn_on_load_error=False) + assert result.errors == [f"{stub.file}: boom"] + failures = [r for r in caplog.records if "Failed to load !include" in r.message] + assert [r.levelname for r in failures] == ["DEBUG"] def test_discovered_yaml_files_holds_files_and_secrets() -> None: - """`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory.""" + """`DiscoveredYamlFiles` is a small data carrier.""" files = [Path("/tmp/a.yaml")] secrets = {Path("/tmp/a.yaml")} discovered = DiscoveredYamlFiles(files, secrets) @@ -1358,11 +1388,24 @@ def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None: assert target.resolve() in discovered.secrets -def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None: - """A YAML parse failure returns whatever was tracked so far without raising.""" +def test_discover_user_yaml_files_reports_parse_errors(tmp_path: Path) -> None: + """A YAML parse failure is surfaced in `.load_errors` (not raised), so + consumers can tell the file set is incomplete.""" entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n") discovered = discover_user_yaml_files(entry) assert isinstance(discovered, DiscoveredYamlFiles) + assert len(discovered.load_errors) == 1 + assert "entry.yaml" in discovered.load_errors[0] + + +def test_discover_user_yaml_files_reports_unresolved_includes( + tmp_path: Path, +) -> None: + """A substitution-templated `!include` path is surfaced in `.unresolved`.""" + entry = _write_entry_including(tmp_path, "${board}.yaml") + discovered = discover_user_yaml_files(entry) + assert len(discovered.unresolved) == 1 + assert "${board}.yaml" in discovered.unresolved[0] def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None: @@ -1589,9 +1632,9 @@ def test_discover_user_yaml_files_many_candidates_keep_nested_includes( def test_discover_user_yaml_files_bad_candidate_still_tracked( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """A matched candidate that fails to parse warns even during discovery, - stays tracked (the load listener fires before parsing), and doesn't block - other candidates.""" + """A matched candidate that fails to parse is reported in ``load_errors`` + (logged only at debug, the caller reports it), stays tracked (the load + listener fires before parsing), and doesn't block other candidates.""" _write(tmp_path, "keys/good.yaml", "api:\n") _write(tmp_path, "keys/bad.yaml", "esphome: [unterminated\n") with caplog.at_level("DEBUG", logger="esphome.yaml_util"): @@ -1604,7 +1647,8 @@ def test_discover_user_yaml_files_bad_candidate_still_tracked( matching = [ r.levelname for r in caplog.records if "Failed to load candidate" in r.message ] - assert matching == ["WARNING"] + assert matching == ["DEBUG"] + assert any("bad.yaml" in e for e in discovered.load_errors) def test_discover_user_yaml_files_tolerates_templated_top_level_include( From cce435a31ed5172660aa61343d957cbab545393c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:34:44 -1000 Subject: [PATCH 025/111] [gpio] Keep each interlock group in one shared flash table (#20238) --- esphome/components/gpio/switch/__init__.py | 18 ++++++---- .../components/gpio/switch/gpio_switch.cpp | 4 --- esphome/components/gpio/switch/gpio_switch.h | 7 ++-- .../component_tests/gpio/test_gpio_switch.py | 33 +++++++++++++++++++ .../gpio/test_gpio_switch_interlock.yaml | 23 +++++++++++++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/gpio/test_gpio_switch.py create mode 100644 tests/component_tests/gpio/test_gpio_switch_interlock.yaml diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 766cdc4afb3..481b8d01dad 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - if CONF_INTERLOCK in config: + if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") - interlock = [] - for it in config[CONF_INTERLOCK]: - lock = await cg.get_variable(it) - interlock.append(lock) - cg.add(var.set_interlock(interlock)) + locks = [await cg.get_variable(it) for it in interlock] + # The group's table lists this switch too, so every member shares one table. + group = sorted({str(s): s for s in (var, *locks)}.values(), key=str) + if len(group) > 1: + table = cg.shared_progmem_array( + "gpio_interlock", + switch.Switch.operator("ptr"), + cg.ArrayInitializer(*group), + constexpr=False, + ) + cg.add(var.set_interlock(table, len(group))) cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index 450a97eb190..68086d94660 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -67,8 +67,4 @@ void GPIOSwitch::write_state(bool state) { this->publish_state(state); } -#ifdef USE_GPIO_SWITCH_INTERLOCK -void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } -#endif - } // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index e7323e6e937..64e4f95f16b 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component { void setup() override; void dump_config() override; #ifdef USE_GPIO_SWITCH_INTERLOCK - void set_interlock(const std::initializer_list &interlock); + /// Codegen only: the table must outlive this switch; it may list this switch, which is skipped. + void set_interlock(Switch *const *interlock, size_t count) { + this->interlock_ = ConstVector(interlock, count); + } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } #endif @@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK - FixedVector interlock_; + ConstVector interlock_; uint32_t interlock_wait_time_{0}; #endif }; diff --git a/tests/component_tests/gpio/test_gpio_switch.py b/tests/component_tests/gpio/test_gpio_switch.py new file mode 100644 index 00000000000..e62f11fd090 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch.py @@ -0,0 +1,33 @@ +"""Tests for the GPIO switch component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_gpio_switch_interlock_group_shares_one_table( + generate_main: Callable[[str | Path], str], +) -> None: + """Every member of an interlock group points at the same flash table.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + tables = re.findall(r"(gpio_interlock\w*)\[\] PROGMEM = \{([^}]*)\};", main_cpp) + assert tables == [("gpio_interlock", "sw_a, sw_b, sw_c")] + for sw in ("sw_a", "sw_b", "sw_c"): + assert f"{sw}->set_interlock(gpio_interlock, 3);" in main_cpp + + +def test_gpio_switch_interlock_with_only_itself_has_no_table( + generate_main: Callable[[str | Path], str], +) -> None: + """A switch interlocked only with itself gets no table and no set_interlock.""" + main_cpp = generate_main( + "tests/component_tests/gpio/test_gpio_switch_interlock.yaml" + ) + + assert "sw_self->set_interlock(" not in main_cpp + assert "sw_self->set_interlock_wait_time(0);" in main_cpp diff --git a/tests/component_tests/gpio/test_gpio_switch_interlock.yaml b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml new file mode 100644 index 00000000000..a3da64388f9 --- /dev/null +++ b/tests/component_tests/gpio/test_gpio_switch_interlock.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp32: + board: esp32dev + +switch: + - platform: gpio + pin: 12 + id: sw_a + interlock: [sw_b, sw_c] + - platform: gpio + pin: 13 + id: sw_b + interlock: [sw_a, sw_c] + - platform: gpio + pin: 14 + id: sw_c + interlock: [sw_a, sw_b] + - platform: gpio + pin: 15 + id: sw_self + interlock: [sw_self] From 72ca565a0e4e2a676748b202caef348332d88a14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:03 -1000 Subject: [PATCH 026/111] [core] Resume if and while from the end of their branch instead of an embedded continuation action (#20283) --- esphome/core/automation.h | 61 ++- esphome/core/base_automation.h | 100 ++--- tests/components/api/common-base.yaml | 8 +- .../core/test_action_branch_owner.cpp | 403 ++++++++++++++++++ .../fixtures/branch_resume_async.yaml | 28 ++ .../fixtures/continuation_actions.yaml | 14 +- tests/integration/test_branch_resume_async.py | 14 + .../integration/test_continuation_actions.py | 9 +- 8 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 tests/components/core/test_action_branch_owner.cpp diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 35c2a17c247..bbb7f08fb8e 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -576,20 +576,22 @@ template class Action { /// the following actions in the chain. int num_running_total() { int total = this->num_running_; - if (this->next_ != nullptr) - total += this->next_->num_running_total(); + if (Action *next = this->chain_next_()) + total += next->num_running_total(); return total; } protected: friend ActionList; - template friend class ContinuationAction; + /// For a branch owner (if, while), play() runs when the branch finishes. virtual void play(const Ts &...x) = 0; void play_next_(const Ts &...x) { if (this->num_running_ > 0) { this->num_running_--; - if (this->next_ != nullptr) { + if (this->next_is_owner_()) { + this->owner_()->play(x...); + } else if (this->next_ != nullptr) { this->next_->play_complex(x...); } } @@ -603,15 +605,24 @@ template class Action { virtual void stop() {} void stop_next_() { - if (this->next_ != nullptr) { - this->next_->stop_complex(); - } + if (Action *next = this->chain_next_()) + next->stop_complex(); } bool is_running_next_() { - if (this->next_ == nullptr) - return false; - return this->next_->is_running(); + Action *next = this->chain_next_(); + return next != nullptr && next->is_running(); + } + + // A branch's last action points at its owner with the low bit set. Only Action objects are + // tagged, never function pointers, which use that bit on ARM Thumb (#9283). + static constexpr uintptr_t OWNER_TAG = 1; + bool next_is_owner_() const { return reinterpret_cast(this->next_) & OWNER_TAG; } + /// Only valid when next_is_owner_(). + Action *owner_() const { return reinterpret_cast(reinterpret_cast(this->next_) - OWNER_TAG); } + Action *chain_next_() const { return this->next_is_owner_() ? nullptr : this->next_; } + static Action *tag_owner(Action *owner) { + return reinterpret_cast(reinterpret_cast(owner) + OWNER_TAG); } Action *next_{nullptr}; @@ -621,25 +632,30 @@ template class Action { int num_running_{0}; }; +// The vtable pointer gives every instantiation this alignment. +static_assert(alignof(Action<>) >= 2, "the owner tag needs a free low bit in Action object pointers"); + template class ActionList { public: void add_action(Action *action) { // Walk to end of chain - action lists are short and only built during setup() - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; - *tail = action; + *this->tail_() = action; } void add_actions(const std::initializer_list *> &actions) { // Find tail once, then append all actions in a single pass - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; + Action **tail = this->tail_(); for (auto *action : actions) { *tail = action; tail = &action->next_; } } + /// Resume `owner` via play() when the list finishes; call once, after adding actions. + void set_owner(Action *owner) { + // Nothing to tag in an empty list + if (this->actions_ == nullptr) + return; + *this->tail_() = Action::tag_owner(owner); + } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { @@ -672,6 +688,17 @@ template class ActionList { template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } + // Lists with an owner are complete; never append after set_owner() + Action **tail_() { + Action **tail = &this->actions_; + while (*tail != nullptr) { + // Not checked in release builds: only automation.py builds these lists, appending each action and + // then finishing the branch once, so appending after set_owner() would be a codegen bug. + ESPHOME_DEBUG_ASSERT(!(*tail)->next_is_owner_()); + tail = &(*tail)->next_; + } + return tail; + } Action *actions_{nullptr}; }; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 09cde1261d0..c59e27f43ab 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -278,70 +278,33 @@ template class ApplyCondition final : public Condition< bool check(const Ts &...x) override { return Fn(x...); } }; -/// Simple continuation action that calls play_next_ on a parent action. -/// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class ContinuationAction : public Action { - public: - explicit ContinuationAction(Action *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->play_next_(x...); } - - protected: - Action *parent_; -}; - -// Forward declaration for WhileLoopContinuation -template class WhileAction; - -/// Loop continuation for WhileAction that checks condition and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class WhileLoopContinuation : public Action { - public: - explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} - - void play(const Ts &...x) override; - - protected: - WhileAction *parent_; -}; - -// Wraps a ContinuationAction when Enabled, empty otherwise. -// Lets IfAction elide the else continuation when HasElse is false. -template struct OptionalContinuation { - ContinuationAction action; - explicit OptionalContinuation(Action *parent) : action(parent) {} -}; -template struct OptionalContinuation { - explicit OptionalContinuation(Action * /*parent*/) {} -}; - template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} // Precondition: build each branch either with one add_then/add_else call, or with // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. - // Appending the inline continuation twice forms a self-loop in the next_ chain. + // set_owner() closes the list; appending after it is a codegen bug. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->then_continuation_); + this->then_.set_owner(this); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(&this->else_continuation_.action); + this->else_.set_owner(this); } // Codegen appends branch actions one at a time, then finishes each branch once; a list literal // would be a constant array in .rodata, which is RAM on ESP8266. void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->then_continuation_); } + void finish_then() { this->then_.set_owner(this); } void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } - void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void finish_else() requires(HasElse) { this->else_.set_owner(this); } - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; + // The condition may stop this automation (e.g. script.stop in a lambda) if (this->condition_->check(x...)) { if (!this->then_.empty() && this->num_running_ > 0) { this->then_.play(x...); @@ -356,8 +319,8 @@ template class IfAction : public Action { this->play_next_(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ - } + // Branch-end resume hook + void play(const Ts &...x) override { this->play_next_(x...); } void stop() override { this->then_.stop(); @@ -369,10 +332,8 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; - ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; - [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { @@ -383,14 +344,12 @@ template class WhileAction : public Action { // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->loop_continuation_); + this->then_.set_owner(this); } void add_then_action(Action *action) { this->then_.add_action(action); } - void finish_then() { this->then_.add_action(&this->loop_continuation_); } + void finish_then() { this->then_.set_owner(this); } - friend class WhileLoopContinuation; - - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; // Initial condition check if (!this->condition_->check(x...)) { @@ -400,12 +359,25 @@ template class WhileAction : public Action { return; } - if (this->num_running_ > 0) { - this->then_.play(x...); + // The condition may have stopped this automation + if (this->num_running_ == 0) + return; + // An empty body cannot resume the loop + if (this->then_.empty()) { + this->play_next_(x...); + return; } + this->then_.play(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ + // Branch-end resume hook: loop again or continue + void play(const Ts &...x) override { + // The condition may stop this automation + if (this->num_running_ > 0 && this->condition_->check(x...) && this->num_running_ > 0) { + this->then_.play(x...); + } else { + this->play_next_(x...); + } } void stop() override { this->then_.stop(); } @@ -413,25 +385,13 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - WhileLoopContinuation loop_continuation_{this}; }; -// Implementation of WhileLoopContinuation::play -template void WhileLoopContinuation::play(const Ts &...x) { - if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { - // play again - this->parent_->then_.play(x...); - } else { - // condition false, play next - this->parent_->play_next_(x...); - } -} - // Forward declaration for RepeatLoopContinuation template class RepeatAction; /// Loop continuation for RepeatAction that increments iteration and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +/// Repeat keeps a continuation: its body takes an extra uint32_t, so it cannot be tagged. template class RepeatLoopContinuation : public Action { public: explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} @@ -447,7 +407,7 @@ template class RepeatAction : public Action { TEMPLATABLE_VALUE(uint32_t, count) // Precondition: use either add_then or add_then_action plus one finish_then, never both - // (see IfAction::add_then). + // (see IfAction::add_then); appending the continuation twice forms a self-loop. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 1513d537c21..7f6cb83f16c 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,7 +181,7 @@ api: room: !lambda 'return room;' ratio: !lambda 'return level / 100.0f;' - homeassistant.tag_scanned: !lambda 'return room;' - # Test ContinuationAction (IfAction with then/else branches) + # Test IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -199,7 +199,7 @@ api: format: "Condition false, value: %ld" args: ['(long) value'] - logger.log: "After if/else" - # Test nested IfAction (multiple ContinuationAction instances) + # Test nested IfAction - action: test_nested_if variables: outer: bool @@ -219,7 +219,7 @@ api: else: - logger.log: "Outer false" - logger.log: "After nested if" - # Test WhileLoopContinuation (WhileAction) + # Test WhileAction - action: test_while_action variables: max_count: int @@ -234,7 +234,7 @@ api: args: ['id(api_continuation_test_counter)'] - lambda: 'id(api_continuation_test_counter)++;' - logger.log: "After while loop" - # Test RepeatLoopContinuation (RepeatAction) + # Test RepeatAction - action: test_repeat_action variables: count: int diff --git a/tests/components/core/test_action_branch_owner.cpp b/tests/components/core/test_action_branch_owner.cpp new file mode 100644 index 00000000000..126f932686d --- /dev/null +++ b/tests/components/core/test_action_branch_owner.cpp @@ -0,0 +1,403 @@ +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::core::testing { + +using Log = std::vector; + +// Records its id when it runs, then continues the chain +class RecordAction : public Action<> { + public: + RecordAction(Log *log, int id) : log_(log), id_(id) {} + void play() override { this->log_->push_back(this->id_); } + Action<> *next() const { return this->next_; } + + protected: + Log *log_; + int id_; +}; + +// Holds the chain until finish() is called, like a delay +class DeferredAction : public Action<> { + public: + explicit DeferredAction(Log *log, int id) : log_(log), id_(id) {} + void play_complex() override { + this->num_running_++; + this->log_->push_back(this->id_); + this->pending_ = true; + } + void play() override {} + void stop() override { this->pending_ = false; } + void finish() { + if (this->pending_) { + this->pending_ = false; + this->play_next_(); + } + } + bool pending() const { return this->pending_; } + + protected: + Log *log_; + int id_; + bool pending_{false}; +}; + +class FlagCondition : public Condition<> { + public: + bool check() override { return this->value; } + bool value{true}; +}; + +// Stops the given list during check(), like script.stop in a lambda condition +class StoppingCondition : public Condition<> { + public: + bool check() override { + if (this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; +}; + +// Stops the given list on the Nth check and keeps returning true +class StopOnCheckCondition : public Condition<> { + public: + explicit StopOnCheckCondition(int stop_on) : stop_on_(stop_on) {} + bool check() override { + if (++this->checks_ == this->stop_on_ && this->list != nullptr) + this->list->stop(); + return true; + } + ActionList<> *list{nullptr}; + + protected: + int stop_on_; + int checks_{0}; +}; + +// Turns its condition off after a number of checks +class CountdownCondition : public Condition<> { + public: + explicit CountdownCondition(int passes) : passes_(passes) {} + bool check() override { return this->passes_-- > 0; } + + protected: + int passes_; +}; + +// Reads an action's next_ through Action's own tag helpers +class TaggedOwner : public RecordAction { + public: + using RecordAction::RecordAction; + static bool is_tagged(const TaggedOwner *a) { return a->next_is_owner_(); } + static Action<> *owner_of(const TaggedOwner *a) { return a->owner_(); } +}; + +class InspectList : public ActionList<> { + public: + Action<> *head() const { return this->actions_; } +}; + +TEST(ActionBranchOwner, SetOwnerTagsOnlyTheLastAction) { + Log log; + TaggedOwner a(&log, 1), b(&log, 2), c(&log, 3); + TaggedOwner owner(&log, 99); + InspectList list; + list.add_actions({&a, &b}); + list.add_action(&c); // appending before set_owner keeps order + list.set_owner(&owner); + EXPECT_EQ(list.head(), &a); + EXPECT_EQ(a.next(), &b); + EXPECT_EQ(b.next(), &c); + EXPECT_FALSE(TaggedOwner::is_tagged(&a)); + EXPECT_FALSE(TaggedOwner::is_tagged(&b)); + EXPECT_TRUE(TaggedOwner::is_tagged(&c)); + EXPECT_EQ(TaggedOwner::owner_of(&c), &owner); +} + +TEST(ActionBranchOwner, EmptyListStaysEmpty) { + Log log; + TaggedOwner owner(&log, 99); + InspectList list; + list.set_owner(&owner); + EXPECT_EQ(list.head(), nullptr); + EXPECT_TRUE(list.empty()); +} + +// ESPHOME_DEBUG_ASSERT is only live because script/cpp_unit_test.py builds with -DESPHOME_DEBUG +TEST(ActionBranchOwnerDeathTest, AppendAfterSetOwnerAsserts) { + Log log; + RecordAction a(&log, 1), late(&log, 2); + TaggedOwner owner(&log, 99); + ActionList<> list; + list.add_action(&a); + list.set_owner(&owner); + EXPECT_DEATH(list.add_action(&late), ""); +} + +TEST(ActionBranchOwner, IfThenResumesAfterTheIfOnce) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then({&t1, &t2}); + if_action.add_else({&e1}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4})); + log.clear(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{3, 4})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// The per-item calls automation.py emits +TEST(ActionBranchOwner, PerItemIfAndWhileResumeAfterTheirBranch) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction t1(&log, 1), t2(&log, 2), e1(&log, 3), after(&log, 4); + if_action.add_then_action(&t1); + if_action.add_then_action(&t2); + if_action.finish_then(); + if_action.add_else_action(&e1); + if_action.finish_else(); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2, 4, 3, 4})); + + log.clear(); + CountdownCondition loop_cond(2); + WhileAction<> loop(&loop_cond); + DeferredAction wait(&log, 5); + RecordAction after2(&log, 6); + loop.add_then_action(&wait); + loop.finish_then(); + ActionList<> top2; + top2.add_actions({&loop, &after2}); + top2.play(); + wait.finish(); + wait.finish(); + EXPECT_EQ(log, (Log{5, 5, 6})); + EXPECT_FALSE(top2.is_running()); +} + +// then: [] generates finish_then() with no actions +TEST(ActionBranchOwner, PerItemEmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + if_action.finish_then(); + if_action.finish_else(); + WhileAction<> loop(&cond); + loop.finish_then(); + RecordAction after(&log, 1), after2(&log, 2); + ActionList<> top, top2; + top.add_actions({&if_action, &after}); + top2.add_actions({&loop, &after2}); + top.play(); + cond.value = false; + top.play(); + cond.value = true; + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, ConditionThatStopsTheRunStartsNoBranch) { + Log log; + StoppingCondition cond; + IfAction if_action(&cond); + WhileAction<> loop(&cond); + RecordAction t1(&log, 1), e1(&log, 2), body(&log, 3), after(&log, 4); + if_action.add_then({&t1}); + if_action.add_else({&e1}); + loop.add_then({&body}); + ActionList<> if_list, while_list; + if_list.add_actions({&if_action, &after}); + while_list.add_actions({&loop, &after}); + + cond.list = &if_list; + if_list.play(); + cond.list = &while_list; + while_list.play(); + EXPECT_TRUE(log.empty()); + EXPECT_FALSE(if_list.is_running()); + EXPECT_FALSE(while_list.is_running()); +} + +TEST(ActionBranchOwner, WhileConditionStoppingOnALaterPassEndsTheLoop) { + Log log; + StopOnCheckCondition cond(2); + WhileAction<> loop(&cond); + RecordAction body(&log, 1), after(&log, 2); + loop.add_then({&body}); + ActionList<> list; + list.add_actions({&loop, &after}); + cond.list = &list; + + list.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_FALSE(list.is_running()); +} + +TEST(ActionBranchOwner, IfResumesAfterDeferredLastAction) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + EXPECT_TRUE(top.is_running()); + // num_running() counts the top-level chain only: the if, not the action inside its branch + EXPECT_EQ(top.num_running(), 1); + wait.finish(); + EXPECT_EQ(log, (Log{1, 2})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +// A deferred action that can hold two runs at once, completed in order +class QueueDeferredAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->pending_++; + } + void play() override {} + void finish_one() { + if (this->pending_ > 0) { + this->pending_--; + this->play_next_(); + } + } + int running() const { return this->num_running_; } + + protected: + int pending_{0}; +}; + +TEST(ActionBranchOwner, OverlappingRunsResumeOncePerRun) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + QueueDeferredAction wait; + RecordAction after(&log, 7); + if_action.add_then({&wait}); + ActionList<> top; + top.add_actions({&if_action, &after}); + + top.play(); + top.play(); + EXPECT_EQ(wait.running(), 2); // the tagged last action holds both runs + wait.finish_one(); + EXPECT_EQ(log, (Log{7})); + EXPECT_TRUE(top.is_running()); + wait.finish_one(); + EXPECT_EQ(log, (Log{7, 7})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(wait.running(), 0); +} + +TEST(ActionBranchOwner, EmptyBranchesFinishImmediately) { + Log log; + FlagCondition cond; + IfAction if_action(&cond); + RecordAction after(&log, 1); + if_action.add_then({}); + if_action.add_else({}); + ActionList<> top; + top.add_actions({&if_action, &after}); + top.play(); + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 1})); + + FlagCondition always; + WhileAction<> loop(&always); + loop.add_then({}); + ActionList<> top2; + RecordAction after2(&log, 2); + top2.add_actions({&loop, &after2}); + top2.play(); + EXPECT_EQ(log, (Log{1, 1, 2})); + EXPECT_FALSE(top2.is_running()); +} + +TEST(ActionBranchOwner, WhileRechecksEachPassAndExits) { + Log log; + CountdownCondition cond(3); + WhileAction<> loop(&cond); + RecordAction body(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&body}); + ActionList<> top; + top.add_actions({&loop, &after}); + top.play(); + EXPECT_EQ(log, (Log{1, 1, 1, 2})); + EXPECT_FALSE(top.is_running()); +} + +TEST(ActionBranchOwner, StopMidBranchClearsRunning) { + Log log; + FlagCondition cond; + WhileAction<> loop(&cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 2); + loop.add_then({&wait}); + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_TRUE(top.is_running()); + top.stop(); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); + wait.finish(); // already stopped: nothing pending, nothing resumes + EXPECT_EQ(log, (Log{1})); + + // A fresh run works after the stop + cond.value = false; + top.play(); + EXPECT_EQ(log, (Log{1, 2})); +} + +TEST(ActionBranchOwner, NestedIfInsideWhileResumesEachOwner) { + Log log; + CountdownCondition loop_cond(2); + FlagCondition if_cond; + WhileAction<> loop(&loop_cond); + IfAction inner(&if_cond); + DeferredAction wait(&log, 1); + RecordAction after(&log, 9); + inner.add_then({&wait}); + loop.add_then({&inner}); // the if is the last action of the loop body + ActionList<> top; + top.add_actions({&loop, &after}); + + top.play(); + EXPECT_EQ(log, (Log{1})); + wait.finish(); // resumes the if, then the while, which loops again + EXPECT_EQ(log, (Log{1, 1})); + wait.finish(); // second pass ends; the while condition is now false + EXPECT_EQ(log, (Log{1, 1, 9})); + EXPECT_FALSE(top.is_running()); + EXPECT_EQ(top.num_running(), 0); +} + +} // namespace esphome::core::testing diff --git a/tests/integration/fixtures/branch_resume_async.yaml b/tests/integration/fixtures/branch_resume_async.yaml index fb093238095..76654ddaa9e 100644 --- a/tests/integration/fixtures/branch_resume_async.yaml +++ b/tests/integration/fixtures/branch_resume_async.yaml @@ -72,6 +72,26 @@ script: format: "parallel-after-if-%d" args: ['run'] + - id: stop_in_later_while_check + then: + - lambda: 'id(counter) = 0;' + - while: + condition: + lambda: |- + if (++id(counter) == 2) + id(stop_in_later_while_check).stop(); + return true; + then: + - lambda: 'ESP_LOGI("test", "later-check-body-%d", id(counter));' + - logger.log: "after-later-check-should-not-run" + - id: empty_while + then: + - while: + condition: + lambda: 'return true;' + then: [] + - logger.log: "after-empty-while" + - id: nested_if_last_in_while then: - lambda: 'id(counter) = 0;' @@ -206,6 +226,14 @@ api: ESP_LOGI("test", "stop-running=%d", id(stop_in_branch).is_running() ? 1 : 0); - delay: 600ms - logger.log: "stop-check-done" + - action: run_stop_in_later_while_check + then: + - script.execute: stop_in_later_while_check + - delay: 200ms + - logger.log: "later-check-done" + - action: run_empty_while + then: + - script.execute: empty_while - action: run_nested_if_last_in_while then: - script.execute: nested_if_last_in_while diff --git a/tests/integration/fixtures/continuation_actions.yaml b/tests/integration/fixtures/continuation_actions.yaml index bdfe149cb7b..d48569f0f4b 100644 --- a/tests/integration/fixtures/continuation_actions.yaml +++ b/tests/integration/fixtures/continuation_actions.yaml @@ -5,7 +5,7 @@ host: api: actions: - # Test 1: IfAction with ContinuationAction (then/else branches) + # Test 1: IfAction with then/else branches - action: test_if_action variables: condition: bool @@ -27,7 +27,7 @@ api: args: ['value'] - logger.log: "if completed" - # Test 2: Nested IfAction (multiple ContinuationAction instances) + # Test 2: Nested IfAction - action: test_nested_if variables: outer: bool @@ -51,7 +51,7 @@ api: - logger.log: "nested-outer-false" - logger.log: "nested if completed" - # Test 3: WhileAction with WhileLoopContinuation + # Test 3: WhileAction - action: test_while_action variables: max_count: int @@ -74,7 +74,7 @@ api: value: !lambda 'return id(continuation_test_counter) + 1;' - logger.log: "while completed" - # Test 4: RepeatAction with RepeatLoopContinuation + # Test 4: RepeatAction - action: test_repeat_action variables: count: int @@ -90,7 +90,7 @@ api: args: ['iteration'] - logger.log: "repeat completed" - # Test 5: Combined continuations (if + while + repeat) + # Test 5: Combined if + while + repeat - action: test_combined variables: do_loop: bool @@ -123,7 +123,7 @@ api: - logger.log: "combined-skipped" - logger.log: "combined completed" - # Test 6: Rapid triggers to verify memory efficiency + # Test 6: Rapid triggers - action: test_rapid_if then: - logger.log: "=== Rapid if test start ===" @@ -153,7 +153,7 @@ globals: restore_value: false initial_value: '0' -# Sensor to test rapid automation triggers with if/else (ContinuationAction) +# Sensor to test rapid automation triggers with if/else sensor: - platform: template id: rapid_sensor diff --git a/tests/integration/test_branch_resume_async.py b/tests/integration/test_branch_resume_async.py index 78064cbe305..f6fb5dbd457 100644 --- a/tests/integration/test_branch_resume_async.py +++ b/tests/integration/test_branch_resume_async.py @@ -73,6 +73,20 @@ async def test_branch_resume_async( assert count("stopped-branch-should-not-run") == 0 assert count("stopped-after-if-should-not-run") == 0 + # A while condition that stops the script on a later pass must not start the body again + done = wait_for("later-check-done") + await client.execute_service(service["run_stop_in_later_while_check"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("later-check-body-1") == 1 + assert count("later-check-body-2") == 0 + assert count("after-later-check-should-not-run") == 0 + + # An empty loop body cannot resume the loop, so the while finishes at once + done = wait_for("after-empty-while") + await client.execute_service(service["run_empty_while"], {}) + await asyncio.wait_for(done, timeout=2.0) + assert count("after-empty-while") == 1 + done = wait_for("after-nested-while") await client.execute_service(service["run_nested_if_last_in_while"], {}) await asyncio.wait_for(done, timeout=2.0) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index e6020c711a1..1c6e8891a96 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -1,4 +1,4 @@ -"""Test continuation actions (ContinuationAction, WhileLoopContinuation, RepeatLoopContinuation).""" +"""Test that if, while and repeat resume the actions after them once their branch finishes.""" from __future__ import annotations @@ -17,10 +17,7 @@ async def test_continuation_actions( api_client_connected: APIClientConnectedFactory, ) -> None: """ - Test that continuation actions work correctly for if/while/repeat. - - These continuation classes replace LambdaAction with simple parent pointers, - saving 32-36 bytes per instance and eliminating std::function overhead. + Test that if/while/repeat resume the actions after them once their branch finishes. """ loop = asyncio.get_running_loop() @@ -218,7 +215,7 @@ async def test_continuation_actions( ) assert test_results["combined_complete"], "Combined did not complete" - # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) + # Test 6: Rapid triggers through an if/else test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" await client.execute_service(test_service, {}) From 99a4ae1f518454b7d95da31e14b6a8d2470b2615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:35:27 -1000 Subject: [PATCH 027/111] [ota] Add old_key so an upload can retry with the previous key (#19398) --- esphome/__main__.py | 17 +++ esphome/components/esphome/ota/__init__.py | 35 +++-- esphome/espota2.py | 96 +++++++++---- tests/component_tests/ota/test_esphome_ota.py | 75 +++++++++- tests/components/ota/old_key.yaml | 12 ++ .../ota/validate-old_key.esp8266-ard.yaml | 2 + tests/unit_tests/test_espota2.py | 17 +++ tests/unit_tests/test_espota2_noise.py | 95 ++++++++++++- tests/unit_tests/test_main.py | 129 +++++++++++------- 9 files changed, 384 insertions(+), 94 deletions(-) create mode 100644 tests/components/ota/old_key.yaml create mode 100644 tests/components/ota/validate-old_key.esp8266-ard.yaml diff --git a/esphome/__main__.py b/esphome/__main__.py index 0c164893df2..2b094e67a14 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1376,6 +1376,7 @@ def _upload_via_native_api( # Fail closed: an encryption block whose key did not resolve must never # fall back to a plaintext upload noise_psk = None + old_noise_psk = None plaintext_fallback = False allow_plaintext_upload = False if ota_key := getattr(args, "ota_key", None): @@ -1396,6 +1397,8 @@ def _upload_via_native_api( # Ensure the key is a string, as required by the underlying OTA implementation. # It arrives here as a SensitiveStr which aioesphomeapi rejects. noise_psk = str(noise_psk) + if old_key := encryption_conf.get(espota2.CONF_OLD_KEY): + old_noise_psk = str(old_key) elif api_key := static_encryption_key(config.get(CONF_API) or {}): # Remove before 2027.3.0: the api key is tried, falling back to plaintext noise_psk = str(api_key) @@ -1445,9 +1448,22 @@ def _upload_via_native_api( noise_psk, plaintext_fallback=plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + old_noise_psk=old_noise_psk, ) +def _old_key_notice(config: ConfigType) -> None: + """After `run`, whose image was just built from this config; a plain + `upload` may send a stale image that still carries the previous key.""" + from esphome import espota2 + + for item in config.get(CONF_OTA, []): + if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( + item.get(CONF_ENCRYPTION) or {} + ).get(espota2.CONF_OLD_KEY): + _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) + + def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: @@ -1892,6 +1908,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") + _old_key_notice(config) else: _LOGGER.warning("Failed to upload to %s", devices) return exit_code diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index dc4fc5b47ed..7a0d03df529 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -5,6 +5,7 @@ from esphome.components.noise import ( ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, + validate_encryption_key, ) from esphome.components.ota import ( BASE_OTA_SCHEMA, @@ -32,7 +33,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv from esphome.types import ConfigType @@ -99,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None: # Encryption blocks conflict only when both pin a key; a bare # `encryption:` (a package/device split) is compatible with a # keyed one, and merge_config yields the keyed result - merged_key = ( - merged_ota_esphome_configs_by_port[conf_port] - .get(CONF_ENCRYPTION, {}) - .get(CONF_KEY) + merged_enc = merged_ota_esphome_configs_by_port[conf_port].get( + CONF_ENCRYPTION, {} ) - other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) - if merged_key and other_key and merged_key != other_key: - raise cv.Invalid( - f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" - ) + other_enc = ota_conf.get(CONF_ENCRYPTION, {}) + for option in (CONF_KEY, CONF_OLD_KEY): + merged_key = merged_enc.get(option) + other_key = other_enc.get(option) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} " + f"{option} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited. + a runtime provisioned api key cannot be inherited, and an old_key equal + to the resolved key is a leftover from a finished rotation. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -229,12 +233,19 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key + if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]: + raise cv.Invalid( + f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' " + "once the device runs the new key" + ) # Uploader side options live only on the ota block; the api block keeps the -# shared schema +# shared schema. The firmware is built with `key` alone, `old_key` never +# reaches the device _ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( { + cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key), cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, } ) diff --git a/esphome/espota2.py b/esphome/espota2.py index dbdf3233660..f775afe7f66 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -215,14 +215,14 @@ class OTANetworkError(OTAError): """Network-level OTA failure (timeout, reset, closed connection); retrying may succeed.""" -# Remove before 2027.3.0 -class OTAEncryptionFallback(OTAError): - """The encrypted attempt failed and the caller may retry in plaintext.""" +class OTAKeyRejected(OTAError): + """The Noise handshake failed on the key; another key may succeed.""" -# Uploader side option under `ota: encryption:`; the ota component imports the -# name so the upload path never loads the component module +# Uploader side options under `ota: encryption:`; the ota component imports +# the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +CONF_OLD_KEY = "old_key" ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -249,20 +249,43 @@ PLAINTEXT_FALLBACK_NOTICE = ( ) -# Remove before 2027.3.0 -class _EncryptionAttempt: - """The key an upload tries and whether it may fall back to plaintext; - a rejected handshake falls back at once, a transport fault only on repeat.""" +OLD_KEY_REMOVE_NOTICE = ( + "Once the device has booted this firmware it runs the current key; remove " + f"'{CONF_OLD_KEY}' from 'ota: encryption:'" +) - def __init__(self, noise_psk: str | None, plaintext_fallback: bool) -> None: + +class _EncryptionAttempt: + """The key an upload tries, the previous key to retry with once, and + (until 2027.3.0) whether it may fall back to plaintext.""" + + def __init__( + self, noise_psk: str | None, plaintext_fallback: bool, old_noise_psk: str | None + ) -> None: self.noise_psk = noise_psk + self.old_noise_psk = old_noise_psk + self.tried_old_key = False self.plaintext_fallback = plaintext_fallback self.handshake_faults = 0 + def retry_with_old_key(self) -> bool: + """Swap in the previous key once; the device may not run the new one yet.""" + if not self.old_noise_psk: + return False + _LOGGER.warning( + "Device rejected the OTA encryption key; retrying with '%s'", CONF_OLD_KEY + ) + self.noise_psk = self.old_noise_psk + self.old_noise_psk = None + self.tried_old_key = True + return True + + # Remove before 2027.3.0 def handshake_fault_falls_back(self) -> bool: self.handshake_faults += 1 return self.plaintext_fallback and self.handshake_faults >= 2 + # Remove before 2027.3.0 def downgrade(self, reason: str) -> None: _LOGGER.warning( "%s. Retrying in plaintext; a device that requires encryption " @@ -459,18 +482,20 @@ class NoiseSocketWrapper: if payload[0] != NOISE_HANDSHAKE_OK: reason = payload[1:].decode("utf-8", "replace") if reason == NOISE_MAC_FAILURE_REASON: - raise OTAError( + raise OTAKeyRejected( "Device rejected the handshake; is the OTA encryption key correct?" ) raise OTAError(f"Device rejected the noise handshake: {reason}") try: self._handshake.read_message(payload[1:]) - except (ValueError, self._invalid_tag) as err: - # InvalidTag is a wrong key; ValueError covers a device sending an - # invalid curve point, which cryptography rejects during the DH - raise OTAError( + except self._invalid_tag as err: + raise OTAKeyRejected( "Noise handshake failed; is the OTA encryption key correct?" ) from err + except ValueError as err: + # An invalid curve point, rejected by cryptography during the DH; + # the device is broken, not the key + raise OTAError(f"Noise handshake failed: {err}") from err self._encrypt, self._decrypt = self._handshake.get_ciphers() def sendall(self, data: bytes) -> None: @@ -652,11 +677,6 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - except OTAError as err: - # Remove before 2027.3.0 - if plaintext_fallback: - raise OTAEncryptionFallback(str(err)) from err - raise _LOGGER.info("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) @@ -874,6 +894,7 @@ def run_ota_impl_( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -912,11 +933,12 @@ def run_ota_impl_( total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS last_error = "" reached_device = False + reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 - encryption = _EncryptionAttempt(noise_psk, plaintext_fallback) + encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] - if reached_device or attempt >= len(res): + if not reconnect and (reached_device or attempt >= len(res)): _LOGGER.info( "Retrying in %.0f seconds (attempt %d of %d)...", UPLOAD_RETRY_DELAY, @@ -925,6 +947,7 @@ def run_ota_impl_( ) time.sleep(UPLOAD_RETRY_DELAY) reached_device = False + reconnect = False _LOGGER.info("Connecting to %s port %s...", sa[0], sa[1]) sock = socket.socket(af, socktype) sock.settimeout(20.0) @@ -951,11 +974,22 @@ def run_ota_impl_( encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, ) - except OTAEncryptionFallback as err: - # Same address and attempt budget: not a network retry + except OTAKeyRejected as err: + # The device sent a reject and closed on its own, so reconnect + # at once, on the same address and attempt budget last_error = str(err) - encryption.downgrade(last_error) - continue + reconnect = True + if encryption.retry_with_old_key(): + continue + # Remove before 2027.3.0 + if encryption.plaintext_fallback: + encryption.downgrade(last_error) + continue + if encryption.tried_old_key: + raise OTAKeyRejected( + f"The device rejected both 'key' and '{CONF_OLD_KEY}'" + ) from err + raise except OTAHandshakeNetworkError as err: last_error = str(err) if encryption.handshake_fault_falls_back(): @@ -976,7 +1010,13 @@ def run_ota_impl_( _LOGGER.error(str(err)) return 1, None - # Successfully uploaded to sa[0] + if encryption.tried_old_key: + # Kept next to the result: a device that should already run the + # current key but answered to the previous one is worth a look + _LOGGER.warning( + "The device accepted '%s'; it was still running the previous key", + CONF_OLD_KEY, + ) return 0, sa[0] _LOGGER.error("Upload failed after %d attempts: %s", total_attempts, last_error) @@ -992,6 +1032,7 @@ def run_ota( noise_psk: str | None = None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + old_noise_psk: str | None = None, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -1003,6 +1044,7 @@ def run_ota( noise_psk, plaintext_fallback, allow_plaintext_upload, + old_noise_psk, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index 77db1499231..39920ff4363 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -30,7 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID -from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD, CONF_OLD_KEY import esphome.final_validate as fv @@ -215,6 +215,77 @@ def test_encryption_schema_allow_plaintext_upload() -> None: encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) +def test_encryption_old_key_schema() -> None: + """old_key is validated like key and is an ota only option.""" + conf = _encryption_schema({CONF_KEY: API_KEY, CONF_OLD_KEY: OTHER_KEY}) + assert conf[CONF_OLD_KEY] == OTHER_KEY + with pytest.raises(cv.Invalid, match="base64"): + _encryption_schema({CONF_OLD_KEY: "short"}) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_OLD_KEY: OTHER_KEY}) + + +def test_encryption_old_key_keeps_inherited_key() -> None: + """A bare block with old_key inherits the api key as the current one.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}) + ], + } + token = fv.full_config.set(full_conf) + try: + ota_esphome_final_validate({}) + updated = fv.full_config.get() + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY + assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_OLD_KEY] == OTHER_KEY + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_mismatch_between_merged_configs_rejected() -> None: + """Same-port blocks that pin different old keys raise like differing keys.""" + full_conf = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}}, + CONF_OTA: [ + _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_OLD_KEY: OTHER_KEY}}), + _make_ota_config( + port=3232, + **{ + CONF_ENCRYPTION: { + CONF_OLD_KEY: "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=" + } + }, + ), + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="old_key is inconsistent"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_encryption_old_key_equal_to_key_rejected() -> None: + """An old_key that matches key is a leftover from a finished rotation.""" + full_conf = { + CONF_API: {}, + CONF_OTA: [ + _make_ota_config( + port=3232, + **{CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_OLD_KEY: API_KEY}}, + ) + ], + } + token = fv.full_config.set(full_conf) + try: + with pytest.raises(cv.Invalid, match="'old_key' is the same as 'key'"): + ota_esphome_final_validate({}) + finally: + fv.full_config.reset(token) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -225,7 +296,7 @@ def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: } token = fv.full_config.set(full_conf) try: - with pytest.raises(cv.Invalid, match="encryption is inconsistent"): + with pytest.raises(cv.Invalid, match="encryption key is inconsistent"): ota_esphome_final_validate({}) finally: fv.full_config.reset(token) diff --git a/tests/components/ota/old_key.yaml b/tests/components/ota/old_key.yaml new file mode 100644 index 00000000000..8074ca06f6f --- /dev/null +++ b/tests/components/ota/old_key.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + encryption: + old_key: "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" diff --git a/tests/components/ota/validate-old_key.esp8266-ard.yaml b/tests/components/ota/validate-old_key.esp8266-ard.yaml new file mode 100644 index 00000000000..f58187f4556 --- /dev/null +++ b/tests/components/ota/validate-old_key.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include old_key.yaml diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f4c5935059c..6fa6c616b67 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -799,6 +799,23 @@ def test_run_ota_impl_connection_failed( mock_sleep.assert_called_with(espota2.UPLOAD_RETRY_DELAY) +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_old_key_reconnect_never_waits( + mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock +) -> None: + """The reconnect with old_key skips the retry delay even once the + address list has been cycled by an earlier connect failure.""" + mock_socket.connect.side_effect = [OSError("timed out"), None, None] + mock_perform_ota.side_effect = [espota2.OTAKeyRejected("rejected"), None] + + result_code, _ = espota2.run_ota_impl_( + "test.local", 3232, None, str(firmware_file), noise_psk="a", old_noise_psk="b" + ) + + assert result_code == 0 + assert mock_sleep.call_count == 1 # only the connect failure waited + + @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_connect_retry_succeeds( mock_socket: Mock, firmware_file: Path, mock_perform_ota: Mock, mock_sleep: Mock diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 60f6b28e076..ca50641309b 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -70,10 +70,12 @@ class FakeEncryptedDevice(threading.Thread): prologue_features_override: int | None = None, connections: int = 1, drop_handshakes: int = 0, + reject_reason: str | None = None, ) -> None: super().__init__(daemon=True) self.connections = connections self.drop_handshakes = drop_handshakes # hang up mid-handshake this many times + self.reject_reason = reject_reason # refuse every handshake with this reason self.psk = psk self.version = version self.offer_noise = offer_noise @@ -160,6 +162,9 @@ class FakeEncryptedDevice(threading.Thread): if self.drop_handshakes > 0: self.drop_handshakes -= 1 return # a transport fault: the socket closes with no reply + if self.reject_reason is not None: + _send_frame(sock, b"\x01" + self.reject_reason.encode()) + return try: proto.read_message(msg1[1:]) except InvalidTag: @@ -244,8 +249,9 @@ def _run_ota( tmp_path: Path, noise_psk: str, plaintext_fallback: bool = True, + old_noise_psk: str | None = None, ) -> int: - """Drive the retry loop, which is where the plaintext fallback reconnects.""" + """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" path.write_bytes(firmware) device.start() @@ -256,10 +262,62 @@ def _run_ota( path, noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + old_noise_psk=old_noise_psk, ) return rc +THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() + + +@pytest.mark.parametrize( + ("device_psk", "old_noise_psk", "expected_rc", "retried"), + [ + # The device still runs the previous key: one reconnect with old_key + (OTHER_PSK, OTHER_PSK, 0, True), + # The device already runs the new key: old_key is never presented + (PSK, OTHER_PSK, 0, False), + # Neither key matches: the retry is spent, then it fails closed + (THIRD_PSK, OTHER_PSK, 1, True), + # No old_key configured: a rejected key is a device error + (OTHER_PSK, None, 1, False), + ], + ids=["old_key_accepted", "key_accepted", "both_rejected", "no_old_key"], +) +def test_old_key_retry( + caplog: pytest.LogCaptureFixture, + tmp_path: Path, + device_psk: str, + old_noise_psk: str | None, + expected_rc: int, + retried: bool, +) -> None: + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice(psk=device_psk, connections=2 if retried else 1) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + firmware, + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=old_noise_psk, + ) + device.join_and_check() + assert rc == expected_rc + assert (device.received == firmware) is (expected_rc == 0) + assert ( + any("retrying with 'old_key'" in r.message for r in caplog.records) is retried + ) + assert any("accepted 'old_key'" in r.message for r in caplog.records) is ( + retried and expected_rc == 0 + ) + assert not any("plaintext" in r.message for r in caplog.records) + if expected_rc == 1 and old_noise_psk is not None: + assert any("rejected both" in r.message for r in caplog.records) + + def test_encrypted_upload_success() -> None: """A full encrypted v2 upload spanning several 8192-byte blocks.""" pytest.importorskip("aioesphomeapi.noise") @@ -495,21 +553,21 @@ def test_handshake_reject_with_other_reason() -> None: def test_handshake_garbage_second_message() -> None: - """A valid-looking point with a garbage MAC fails cleanly.""" + """A valid-looking point with a garbage MAC is a key failure.""" wrapper = _wrapper(_frame(b"\x00" + bytes(range(48)))) with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" + espota2.OTAKeyRejected, match="handshake failed; is the OTA encryption key" ): wrapper.do_handshake() def test_handshake_invalid_curve_point() -> None: - """An all-zero x25519 point is rejected as a clean error, not a crash.""" + """An all-zero x25519 point is a clean error, not a crash, and not a + key failure: it must not spend the old_key retry.""" wrapper = _wrapper(_frame(b"\x00" + bytes(48))) - with pytest.raises( - espota2.OTAError, match="handshake failed; is the OTA encryption key" - ): + with pytest.raises(espota2.OTAError, match="handshake failed: ") as info: wrapper.do_handshake() + assert not isinstance(info.value, espota2.OTAKeyRejected) def test_recv_closed_at_frame_boundary_returns_empty() -> None: @@ -585,6 +643,29 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: wrapper._decrypt.decrypt.assert_called_once() +@pytest.mark.parametrize("plaintext_fallback", [False, True]) +def test_non_key_reject_reason_is_a_device_error( + caplog: pytest.LogCaptureFixture, tmp_path: Path, plaintext_fallback: bool +) -> None: + """Only a key failure spends the old_key retry or, until 2027.3.0, the + plaintext fallback; another reject reason fails at once.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(reject_reason="Busy") + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=plaintext_fallback, + old_noise_psk=OTHER_PSK, + ) + device.join_and_check() + assert rc == 1 + assert not any("retrying with 'old_key'" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 3b10c9cb3c2..801eda4496a 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1558,6 +1558,14 @@ class MockArgs: env_ota_key: str | None = None +# The keyword half of every run_ota call; a test overrides the one it is about +RUN_OTA_DEFAULT_KWARGS = { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + "old_noise_psk": None, +} + + def test_upload_program_serial_esp32( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -2120,32 +2128,39 @@ def test_upload_program_ota_success( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) +@pytest.mark.parametrize( + "old_key", + [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="], + ids=["key", "old_key"], +) def test_upload_program_ota_encryption_key( mock_run_ota: Mock, mock_get_port_type: Mock, tmp_path: Path, + old_key: str | None, + caplog: pytest.LogCaptureFixture, ) -> None: - """The resolved encryption key is passed through to run_ota.""" + """The resolved encryption key, and the previous one when set, are passed + through to run_ota.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) mock_get_port_type.return_value = "NETWORK" mock_run_ota.return_value = (0, "192.168.1.100") key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + encryption = {CONF_KEY: key} + if old_key is not None: + encryption["old_key"] = old_key config = { CONF_OTA: [ - { - CONF_PLATFORM: CONF_ESPHOME, - CONF_PORT: 3232, - CONF_ENCRYPTION: {CONF_KEY: key}, - } + {CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: encryption} ] } - exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) + with caplog.at_level(logging.WARNING): + exit_code, host = upload_program(config, MockArgs(), ["192.168.1.100"]) assert exit_code == 0 assert host == "192.168.1.100" @@ -2159,9 +2174,10 @@ def test_upload_program_ota_encryption_key( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"old_noise_psk": old_key}, ) + # The notice belongs to run, whose image was just built from this config + assert not any("remove 'old_key'" in r.message for r in caplog.records) def test_upload_program_bare_encryption_block_never_falls_back( @@ -2191,10 +2207,7 @@ def test_upload_program_bare_encryption_block_never_falls_back( with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: upload_program(config, MockArgs(), ["192.168.1.100"]) assert run_ota.call_args.args[5] == key - assert run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_allow_plaintext_upload( @@ -2223,9 +2236,8 @@ def test_upload_program_ota_allow_plaintext_upload( assert exit_code == 0 assert mock_run_ota.call_args.args[2] == "pw" assert mock_run_ota.call_args.args[5] == key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": True, + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS | { + "allow_plaintext_upload": True } @@ -2235,6 +2247,7 @@ def test_upload_program_ota_allow_plaintext_upload( { CONF_ENCRYPTION: { CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "old_key": "AgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICE=", "allow_plaintext_upload": True, } }, @@ -2265,10 +2278,7 @@ def test_upload_program_ota_prompted_key_is_presented( assert exit_code == 0 assert mock_run_ota.call_args.args[5] == old_key - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_serial_ignores_prompted_key( @@ -2320,6 +2330,46 @@ def test_command_run_prompts_for_ota_key_before_compile() -> None: assert calls == ["prompt", "compile"] +@pytest.mark.parametrize( + "old_key", [None, "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="] +) +def test_command_run_says_when_old_key_can_go( + caplog: pytest.LogCaptureFixture, old_key: str | None +) -> None: + """Only run has just built the image from this config, so only run can + say the device will run `key` alone once it boots.""" + encryption = {CONF_KEY: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="} + if old_key is not None: + encryption["old_key"] = old_key + setup_core( + config={ + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_ENCRYPTION: encryption, + } + ] + }, + platform=PLATFORM_ESP32, + ) + args = MockArgs() + args.device = ["dev.local"] + args.no_logs = True + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch("esphome.__main__.choose_upload_log_host", return_value=["dev.local"]), + patch("esphome.__main__.get_serial_ports", return_value=[]), + patch("esphome.__main__.upload_program", return_value=(0, "dev.local")), + caplog.at_level(logging.WARNING), + ): + assert command_run(args, CORE.config) == 0 + assert any("remove 'old_key'" in r.message for r in caplog.records) is ( + old_key is not None + ) + + def test_upload_program_platform_hook_ignores_prompted_key( mock_get_port_type: Mock, tmp_path: Path, @@ -2636,8 +2686,7 @@ def test_upload_program_ota_api_key_opportunistic( expected_firmware, OTA_TYPE_UPDATE_APP, key, - plaintext_fallback=True, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS | {"plaintext_fallback": True}, ) @@ -2666,10 +2715,7 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == { - "plaintext_fallback": False, - "allow_plaintext_upload": False, - } + assert mock_run_ota.call_args.kwargs == RUN_OTA_DEFAULT_KWARGS def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2728,8 +2774,7 @@ def test_upload_program_ota_with_file_arg( Path("custom.bin"), OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2785,8 +2830,7 @@ def test_upload_program_ota_partition_table_with_file_arg( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -2849,8 +2893,7 @@ def test_upload_program_ota_partition_table_mqttip( partition_file, OTA_TYPE_UPDATE_PARTITION_TABLE, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3039,8 +3082,7 @@ def test_upload_program_ota_bootloader_with_file_arg( bootloader_file, OTA_TYPE_UPDATE_BOOTLOADER, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3551,8 +3593,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -3608,8 +3649,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5161,8 +5201,7 @@ def test_upload_program_ota_static_ip_with_mqttip( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5213,8 +5252,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) @@ -5398,8 +5436,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( expected_firmware, OTA_TYPE_UPDATE_APP, None, - plaintext_fallback=False, - allow_plaintext_upload=False, + **RUN_OTA_DEFAULT_KWARGS, ) From 318e4b79c110917303e49aade9d6fa13601ace63 Mon Sep 17 00:00:00 2001 From: Adam DeMuri Date: Mon, 12 Oct 2026 17:38:34 -0600 Subject: [PATCH 028/111] [apc1] Add support for the ScioSense APC1 air quality sensor (UART) (#19274) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/apc1/__init__.py | 3 + esphome/components/apc1/apc1.cpp | 352 ++++++++++++++++++++ esphome/components/apc1/apc1.h | 99 ++++++ esphome/components/apc1/sensor.py | 222 ++++++++++++ tests/components/apc1/common.yaml | 62 ++++ tests/components/apc1/test.esp32-idf.yaml | 7 + tests/components/apc1/test.esp8266-ard.yaml | 7 + tests/components/apc1/test.rp2040-ard.yaml | 7 + 9 files changed, 760 insertions(+) create mode 100644 esphome/components/apc1/__init__.py create mode 100644 esphome/components/apc1/apc1.cpp create mode 100644 esphome/components/apc1/apc1.h create mode 100644 esphome/components/apc1/sensor.py create mode 100644 tests/components/apc1/common.yaml create mode 100644 tests/components/apc1/test.esp32-idf.yaml create mode 100644 tests/components/apc1/test.esp8266-ard.yaml create mode 100644 tests/components/apc1/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f7875eb1407..fab41f5b1fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,6 +39,7 @@ esphome/components/am43/sensor/* @buxtronix esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix +esphome/components/apc1/* @ademuri esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 00000000000..355ecc23058 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 00000000000..beca9d85815 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -0,0 +1,352 @@ +#include "apc1.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::apc1 { + +ESPHOME_LOG_TAG(TAG, "apc1"); + +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58; +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61; + +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19; + +// Give a freshly powered sensor time to boot before the first retry +static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000; +// Re-send the init commands at most this often while no frames arrive +static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000; +// Active mode streams a frame every second; this long without one means the sensor stopped +static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; +// Drop a partial frame when the bytes stop mid-way +static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; + +// Frames and commands end with the 16-bit sum of every byte before it +static uint16_t apc1_checksum(const uint8_t *data, size_t len) { + uint16_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void APC1Component::setup() { + if (this->set_pin_ != nullptr) { + this->set_pin_->setup(); + this->set_pin_->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + // Attempt initial communication immediately for warm restarts + this->initialize_device_(); + + // Schedule delayed initialization to catch sensors during cold power-on bootup + this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); }); +} + +void APC1Component::initialize_device_() { + this->last_init_attempt_ = App.get_loop_component_start_time(); + if (this->active_mode_ && !this->idle_) { + this->set_active_mode(); + } + this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000); +} + +void APC1Component::dump_config() { + ESP_LOGCONFIG(TAG, "APC1:"); + LOG_PIN(" Set Pin: ", this->set_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_); + LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_); + LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_); + LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_); + LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_); + LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_); + LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_); + LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_); + LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); + LOG_SENSOR(" ", "eCO2", this->eco2_sensor_); + LOG_SENSOR(" ", "AQI", this->aqi_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_); + LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_); + LOG_SENSOR(" ", "RS0", this->rs0_sensor_); + LOG_SENSOR(" ", "RS2", this->rs2_sensor_); + LOG_SENSOR(" ", "RS3", this->rs3_sensor_); + LOG_SENSOR(" ", "Error Code", this->error_code_sensor_); +} + +void APC1Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // If in active mode and not idle, retry periodically if no valid data received + if (this->active_mode_ && !this->idle_) { + if (this->last_valid_frame_ == 0) { + if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands..."); + this->initialize_device_(); + } + } else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS && + now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_); + this->initialize_device_(); + } + } + + if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) { + ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer"); + this->rx_index_ = 0; + } + + while (this->available()) { + this->last_transmission_ = now; + uint8_t byte; + this->read_byte(&byte); + + if (this->rx_index_ == 0) { + if (byte == APC1_HEADER_1) { + this->rx_buffer_[this->rx_index_++] = byte; + } + continue; + } + + if (this->rx_index_ == 1) { + if (byte == APC1_HEADER_2) { + this->rx_buffer_[this->rx_index_++] = byte; + } else if (byte != APC1_HEADER_1) { + this->rx_index_ = 0; + } + continue; + } + + this->rx_buffer_[this->rx_index_++] = byte; + if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { + continue; + } + + // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a + // corrupt length from wrapping the total and walking past the buffer + const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]); + if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { + ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); + this->rx_index_ = 0; + continue; + } + const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; + if (this->rx_index_ == total_len) { + this->parse_frame_(total_len); + this->rx_index_ = 0; + } + } +} + +void APC1Component::parse_frame_(uint16_t total_len) { + uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); + uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + if (expected_checksum != calculated_checksum) { + ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, + expected_checksum); + return; + } + + if (total_len == APC1_FRAME_SIZE_MEASUREMENT) { + this->parse_measurement_frame_(); + } else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) { + this->parse_device_info_frame_(); + } else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) { + ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]); + } else { + ESP_LOGD(TAG, "Received frame of unknown length %u", total_len); + } +} + +// One message per error bit, bit 0 first +PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault", + "Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault"); + +void APC1Component::parse_measurement_frame_() { + const uint32_t now = App.get_loop_component_start_time(); + this->last_valid_frame_ = now; + + uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE]; + if (error_code != this->last_error_code_) { + if (error_code != 0) { + for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) { + if (error_code & (1 << bit)) { + ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX))); + } + } + this->status_set_warning(LOG_STR("Sensor reported hardware error")); + } else { + ESP_LOGI(TAG, "APC1: Hardware error cleared"); + this->status_clear_warning(); + } + this->last_error_code_ = error_code; + if (this->error_code_sensor_ != nullptr) { + this->error_code_sensor_->publish_state(error_code); + } + } + + uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI]; + if (aqi == 0) { + if (!this->gas_warming_up_) { + this->gas_warming_up_ = true; + ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)"); + } + } else if (this->gas_warming_up_) { + this->gas_warming_up_ = false; + ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi); + } + + // Measurement payload data (bytes 4-57): big-endian sensor readings. + // The values below feed the debug summary, so they are always decoded; every + // other reading is only decoded when its sensor is configured. + const auto u16 = [this](uint8_t offset) { + return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]); + }; + const auto u32 = [this](uint8_t offset) { + return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2], + this->rx_buffer_[offset + 3]); + }; + uint16_t pm_1_0 = u16(10); + uint16_t pm_2_5 = u16(12); + uint16_t pm_10_0 = u16(14); + uint16_t tvoc = u16(28); + uint16_t eco2 = u16(30); + float temp_comp = static_cast(u16(34)) / 10.0f; + float hum_comp = u16(36) / 10.0f; + + ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0, + pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp); + + if (this->pm_1_0_std_sensor_ != nullptr) + this->pm_1_0_std_sensor_->publish_state(u16(4)); + if (this->pm_2_5_std_sensor_ != nullptr) + this->pm_2_5_std_sensor_->publish_state(u16(6)); + if (this->pm_10_0_std_sensor_ != nullptr) + this->pm_10_0_std_sensor_->publish_state(u16(8)); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + + if (this->pm_0_3um_sensor_ != nullptr) + this->pm_0_3um_sensor_->publish_state(u16(16)); + if (this->pm_0_5um_sensor_ != nullptr) + this->pm_0_5um_sensor_->publish_state(u16(18)); + if (this->pm_1_0um_sensor_ != nullptr) + this->pm_1_0um_sensor_->publish_state(u16(20)); + if (this->pm_2_5um_sensor_ != nullptr) + this->pm_2_5um_sensor_->publish_state(u16(22)); + if (this->pm_5_0um_sensor_ != nullptr) + this->pm_5_0um_sensor_->publish_state(u16(24)); + if (this->pm_10_0um_sensor_ != nullptr) + this->pm_10_0um_sensor_->publish_state(u16(26)); + + if (this->tvoc_sensor_ != nullptr) + this->tvoc_sensor_->publish_state(tvoc); + if (this->eco2_sensor_ != nullptr) + this->eco2_sensor_->publish_state(eco2); + if (this->aqi_sensor_ != nullptr) + this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temp_comp); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(hum_comp); + + if (this->raw_temperature_sensor_ != nullptr) { + this->raw_temperature_sensor_->publish_state(static_cast(u16(38)) / 10.0f); + } + if (this->raw_humidity_sensor_ != nullptr) + this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f); + + if (this->rs0_sensor_ != nullptr) { + this->rs0_sensor_->publish_state(u32(42)); + } + if (this->rs2_sensor_ != nullptr) { + this->rs2_sensor_->publish_state(u32(50)); + } + if (this->rs3_sensor_ != nullptr) { + this->rs3_sensor_->publish_state(u32(54)); + } +} + +void APC1Component::parse_device_info_frame_() { + const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL; + uint16_t fw_version = + encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]); + ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)", + reinterpret_cast(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE), + encode_uint32(serial[0], serial[1], serial[2], serial[3]), + encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version); +} + +void APC1Component::arm_watchdog_() { + this->last_valid_frame_ = App.get_loop_component_start_time(); + this->last_init_attempt_ = this->last_valid_frame_; +} + +void APC1Component::set_active_mode() { + if (!this->active_mode_) { + this->arm_watchdog_(); + } + this->active_mode_ = true; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE); +} + +void APC1Component::set_passive_mode() { + this->active_mode_ = false; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE); +} + +void APC1Component::request_measurement() { + this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000); +} + +void APC1Component::set_idle_mode() { + this->idle_ = true; + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE); +} + +void APC1Component::set_measurement_mode() { + this->idle_ = false; + this->arm_watchdog_(); + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD); +} + +void APC1Component::send_command_(APC1Command cmd, uint16_t data) { + std::array buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 00000000000..850738ead6a --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::apc1 { + +static constexpr uint8_t APC1_HEADER_1 = 0x42; +static constexpr uint8_t APC1_HEADER_2 = 0x4D; +static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes +static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64; +static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23; +static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8; +static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7; + +enum class APC1Command : uint8_t { + APC1_COMMAND_MEASUREMENT_MODE = 0xE1, + APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2, + APC1_COMMAND_OPERATION_MODE = 0xE4, + APC1_COMMAND_READ_SENSOR_VERSION = 0xE9, +}; + +static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000; +static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001; + +static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000; +static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001; + +class APC1Component final : public uart::UARTDevice, public Component { + SUB_SENSOR(pm_1_0) + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_10_0) + SUB_SENSOR(pm_1_0_std) + SUB_SENSOR(pm_2_5_std) + SUB_SENSOR(pm_10_0_std) + + SUB_SENSOR(pm_0_3um) + SUB_SENSOR(pm_0_5um) + SUB_SENSOR(pm_1_0um) + SUB_SENSOR(pm_2_5um) + SUB_SENSOR(pm_5_0um) + SUB_SENSOR(pm_10_0um) + + SUB_SENSOR(tvoc) + SUB_SENSOR(eco2) + SUB_SENSOR(aqi) + + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(raw_temperature) + SUB_SENSOR(raw_humidity) + + SUB_SENSOR(rs0) + SUB_SENSOR(rs2) + SUB_SENSOR(rs3) + + SUB_SENSOR(error_code) + + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_active_mode(); + void set_passive_mode(); + void request_measurement(); + void set_idle_mode(); + void set_measurement_mode(); + + void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + protected: + void send_command_(APC1Command cmd, uint16_t data); + void initialize_device_(); + /// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot + void arm_watchdog_(); + void parse_frame_(uint16_t total_len); + void parse_measurement_frame_(); + void parse_device_info_frame_(); + + GPIOPin *set_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint32_t last_transmission_{0}; + uint32_t last_valid_frame_{0}; + uint32_t last_init_attempt_{0}; + std::array rx_buffer_{}; + uint8_t rx_index_{0}; + uint8_t last_error_code_{0xFF}; + bool active_mode_{true}; + bool idle_{false}; + bool gas_warming_up_{false}; +}; + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 00000000000..b64a49903f2 --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -0,0 +1,222 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import sensor, uart +from esphome.components.const import CONF_AQI +import esphome.config_validation as cv +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_PM_0_3UM, + CONF_PM_0_5UM, + CONF_PM_1_0, + CONF_PM_1_0_STD, + CONF_PM_1_0UM, + CONF_PM_2_5, + CONF_PM_2_5_STD, + CONF_PM_2_5UM, + CONF_PM_5_0UM, + CONF_PM_10_0, + CONF_PM_10_0_STD, + CONF_PM_10_0UM, + CONF_RESET_PIN, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_OMEGA, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_COUNT_DECILITRE, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@ademuri"] +DEPENDENCIES = ["uart"] + +CONF_SET_PIN = "set_pin" +CONF_RAW_TEMPERATURE = "raw_temperature" +CONF_RAW_HUMIDITY = "raw_humidity" +CONF_RS0 = "rs0" +CONF_RS2 = "rs2" +CONF_RS3 = "rs3" +CONF_ERROR_CODE = "error_code" + +apc1_ns = cg.esphome_ns.namespace("apc1") +APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component) + + +def _pm_mass_schema(device_class: str) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=device_class, + state_class=STATE_CLASS_MEASUREMENT, + ) + + +_PM_COUNT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNT_DECILITRE, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_HUMIDITY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, +) +_RESISTANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APC1Component), + cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ECO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AQI): sensor.sensor_schema( + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA, + cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema( + icon="mdi:alert-circle-outline", + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + schema = uart.final_validate_device_schema( + "apc1", baud_rate=9600, require_rx=True, require_tx=True + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_0_3um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_AQI, var.set_aqi_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor) + await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor) + await sensors(CONF_RS0, var.set_rs0_sensor) + await sensors(CONF_RS2, var.set_rs2_sensor) + await sensors(CONF_RS3, var.set_rs3_sensor) + await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) + + if (set_pin_config := config.get(CONF_SET_PIN)) is not None: + set_pin = await cg.gpio_pin_expression(set_pin_config) + cg.add(var.set_set_pin(set_pin)) + + if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: + reset_pin = await cg.gpio_pin_expression(reset_pin_config) + cg.add(var.set_reset_pin(reset_pin)) + + +APC1_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(APC1Component), + } +) + + +for _name, _call in ( + ("apc1.set_active_mode", "set_active_mode()"), + ("apc1.set_passive_mode", "set_passive_mode()"), + ("apc1.request_measurement", "request_measurement()"), + ("apc1.set_idle_mode", "set_idle_mode()"), + ("apc1.set_measurement_mode", "set_measurement_mode()"), +): + automation.register_apply_action( + _name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/apc1/common.yaml b/tests/components/apc1/common.yaml new file mode 100644 index 00000000000..496f587e42a --- /dev/null +++ b/tests/components/apc1/common.yaml @@ -0,0 +1,62 @@ +sensor: + - platform: apc1 + id: apc1_sensor + set_pin: ${set_pin} + reset_pin: ${reset_pin} + pm_1_0: + name: "APC1 PM1.0" + pm_2_5: + name: "APC1 PM2.5" + filters: + - throttle_average: 10s + pm_10_0: + name: "APC1 PM10" + pm_1_0_std: + name: "APC1 PM1.0 Std" + pm_2_5_std: + name: "APC1 PM2.5 Std" + pm_10_0_std: + name: "APC1 PM10 Std" + pm_0_3um: + name: "APC1 Particles >0.3um" + pm_0_5um: + name: "APC1 Particles >0.5um" + pm_1_0um: + name: "APC1 Particles >1.0um" + pm_2_5um: + name: "APC1 Particles >2.5um" + pm_5_0um: + name: "APC1 Particles >5.0um" + pm_10_0um: + name: "APC1 Particles >10um" + tvoc: + name: "APC1 TVOC" + eco2: + name: "APC1 eCO2" + aqi: + name: "APC1 AQI" + temperature: + name: "APC1 Temperature" + humidity: + name: "APC1 Humidity" + raw_temperature: + name: "APC1 Raw Temperature" + raw_humidity: + name: "APC1 Raw Humidity" + rs0: + name: "APC1 RS0" + rs2: + name: "APC1 MOX RS2" + rs3: + name: "APC1 MOX RS3" + error_code: + name: "APC1 Error Code" + +interval: + - interval: 60s + then: + - apc1.set_active_mode: apc1_sensor + - apc1.set_passive_mode: apc1_sensor + - apc1.request_measurement: apc1_sensor + - apc1.set_idle_mode: apc1_sensor + - apc1.set_measurement_mode: apc1_sensor diff --git a/tests/components/apc1/test.esp32-idf.yaml b/tests/components/apc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..a8fdc5956dc --- /dev/null +++ b/tests/components/apc1/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO32 + reset_pin: GPIO14 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.esp8266-ard.yaml b/tests/components/apc1/test.esp8266-ard.yaml new file mode 100644 index 00000000000..746829bea79 --- /dev/null +++ b/tests/components/apc1/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO4 + reset_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + apc1: !include common.yaml diff --git a/tests/components/apc1/test.rp2040-ard.yaml b/tests/components/apc1/test.rp2040-ard.yaml new file mode 100644 index 00000000000..e1dc80743f5 --- /dev/null +++ b/tests/components/apc1/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + set_pin: GPIO2 + reset_pin: GPIO3 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + apc1: !include common.yaml From 331d12af2f228ea6be18736c7f9f9604458e52e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:10 -1000 Subject: [PATCH 029/111] [binary_sensor] Share multi click timing tables in flash and skip the default cooldown setter (#20071) --- esphome/components/binary_sensor/__init__.py | 23 ++++++---- .../components/binary_sensor/automation.cpp | 27 ++++++------ esphome/components/binary_sensor/automation.h | 42 +++++++------------ .../binary_sensor/test_multi_click.py | 29 +++++++++++++ .../binary_sensor/test_multi_click.yaml | 33 +++++++++++++++ 5 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 tests/component_tests/binary_sensor/test_multi_click.py create mode 100644 tests/component_tests/binary_sensor/test_multi_click.yaml diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 109a22c7bca..1384f8b0086 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -74,6 +74,10 @@ from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] + +DEFAULT_INVALID_COOLDOWN_MS = ( + 1000 # Keep in sync with invalid_cooldown_ in automation.h +) DEVICE_CLASSES = [ DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY_CHARGING, @@ -131,10 +135,9 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTriggerBase = binary_sensor_ns.class_( - "MultiClickTriggerBase", automation.Trigger.template(), cg.Component +MultiClickTrigger = binary_sensor_ns.class_( + "MultiClickTrigger", automation.Trigger.template(), cg.Component ) -MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") @@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = ( cv.Length(min=1, max=255), ), cv.Optional( - CONF_INVALID_COOLDOWN, default="1s" + CONF_INVALID_COOLDOWN, + default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms", ): cv.positive_time_period_milliseconds, } ), @@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + table = cg.shared_progmem_array( + "multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings) ) - if CONF_INVALID_COOLDOWN in conf: - cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings)) + if ( + cooldown := conf[CONF_INVALID_COOLDOWN] + ).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS: + cg.add(trigger.set_invalid_cooldown(cooldown)) await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index d4b4906c30d..46f7d9488c4 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTriggerBase::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (!this->at_index_.has_value()) { + if (this->at_index_ == 0) { // Start matching MultiClickTriggerEvent evt = this->timing_[0]; if (evt.state == state) { @@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - // at_index_ has a value here (the !has_value() branch above returns). - size_t at_index = *this->at_index_; + uint8_t at_index = this->at_index_; if (at_index == this->timing_count_) { this->trigger_(); return; @@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) { MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); } else if (at_index + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length); this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); @@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) { this->at_index_ = at_index + 1; } -void MultiClickTriggerBase::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); @@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->schedule_cooldown_(); }); } -void MultiClickTriggerBase::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTriggerBase::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 49457200bc6..7426cc5614c 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include "esphome/core/component.h" @@ -13,10 +13,14 @@ namespace esphome::binary_sensor { struct MultiClickTriggerEvent { - bool state; + uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266 uint32_t min_length; uint32_t max_length; }; +// Read straight from PROGMEM; a byte-sized field would fault on ESP8266. +static_assert(std::is_same_v && + sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t), + "MultiClickTriggerEvent fields must all be uint32_t"); bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); @@ -70,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). -class MultiClickTriggerBase : public Trigger<>, public Component { +class MultiClickTrigger final : public Trigger<>, public Component { public: - explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} + /// `timing` is a codegen PROGMEM table shared by triggers with the same timing. + MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count) + : parent_(parent), timing_(timing), timing_count_(timing_count) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -85,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); - MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; - MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; + MultiClickTrigger(const MultiClickTrigger &) = delete; + MultiClickTrigger &operator=(const MultiClickTrigger &) = delete; protected: void on_state_(bool state); @@ -96,28 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - const MultiClickTriggerEvent *timing_{nullptr}; - uint32_t invalid_cooldown_{1000}; - optional at_index_{}; - uint8_t timing_count_{0}; + const MultiClickTriggerEvent *timing_; + uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py + uint8_t timing_count_; + uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; -/// Template wrapper that provides inline std::array storage for timing events. -/// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger final : public MultiClickTriggerBase { - public: - MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : MultiClickTriggerBase(parent) { - init_array_from(this->timing_storage_, timing); - this->timing_ = this->timing_storage_.data(); - this->timing_count_ = N; - } - - protected: - std::array timing_storage_{}; -}; - } // namespace esphome::binary_sensor diff --git a/tests/component_tests/binary_sensor/test_multi_click.py b/tests/component_tests/binary_sensor/test_multi_click.py new file mode 100644 index 00000000000..6f2556735f9 --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.py @@ -0,0 +1,29 @@ +"""Tests for the multi click trigger codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_multi_click_timing_tables(generate_main: Callable[[str | Path], str]) -> None: + """Identical timings share one PROGMEM table; the default cooldown is not set.""" + main_cpp = generate_main( + "tests/component_tests/binary_sensor/test_multi_click.yaml" + ) + + tables = re.findall( + r"static constexpr binary_sensor::MultiClickTriggerEvent (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(tables) == 2 + single, long_press = tables + triggers = re.findall( + r"binary_sensor::MultiClickTrigger\((\w+), (\w+), (\d+)\);", main_cpp + ) + assert triggers == [ + ("button_a", single, "2"), + ("button_a", long_press, "1"), + ("button_b", single, "2"), + ] + assert main_cpp.count("set_invalid_cooldown(") == 1 + assert "set_invalid_cooldown(500);" in main_cpp diff --git a/tests/component_tests/binary_sensor/test_multi_click.yaml b/tests/component_tests/binary_sensor/test_multi_click.yaml new file mode 100644 index 00000000000..c18737ea3c4 --- /dev/null +++ b/tests/component_tests/binary_sensor/test_multi_click.yaml @@ -0,0 +1,33 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +binary_sensor: + - platform: gpio + id: button_a + pin: GPIO32 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: a single + - timing: + - ON for at least 2s + invalid_cooldown: 500ms + then: + - logger.log: a long + - platform: gpio + id: button_b + pin: GPIO33 + on_multi_click: + - timing: + - ON for at most 1s + - OFF for at least 0.25s + then: + - logger.log: b single From b66cde0270b37d0716ae7442f0aa3dc6a4540224 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:39:38 -1000 Subject: [PATCH 030/111] [sensor] Share calibrate_linear and calibrate_polynomial tables in flash (#20074) --- esphome/components/sensor/__init__.py | 15 ++++-- esphome/components/sensor/filter.cpp | 18 ++++--- esphome/components/sensor/filter.h | 34 ++++--------- .../sensor/test_calibrate.yaml | 51 +++++++++++++++++++ tests/component_tests/sensor/test_sensor.py | 27 ++++++++++ 5 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/sensor/test_calibrate.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 1440e07f763..8f345e3e668 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -806,6 +806,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -832,9 +835,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -872,7 +877,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 732db5b0b4f..ed48ddebdbc 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -415,19 +415,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 01949f45df2..9b5ef493081 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -579,38 +579,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/tests/component_tests/sensor/test_calibrate.yaml b/tests/component_tests/sensor/test_calibrate.yaml new file mode 100644 index 00000000000..41a87c87795 --- /dev/null +++ b/tests/component_tests/sensor/test_calibrate.yaml @@ -0,0 +1,51 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +sensor: + - platform: template + id: exact_a + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: exact_b + lambda: return 1.0; + filters: + - calibrate_linear: + method: exact + datapoints: + - 0.0 -> 0.0 + - 10.0 -> 100.0 + - 20.0 -> 150.0 + - platform: template + id: least_squares + lambda: return 1.0; + filters: + - calibrate_linear: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + - platform: template + id: polynomial + lambda: return 1.0; + filters: + - calibrate_polynomial: + degree: 1 + datapoints: + - 0.0 -> 1.0 + - 10.0 -> 21.0 + # User ids equal to the old derived table name and to the helper's base name. + - platform: template + id: sensor_calibratelinearfilter_id_data + lambda: return 1.0; + - platform: template + id: sensor_calibrate_linear + lambda: return 1.0; diff --git a/tests/component_tests/sensor/test_sensor.py b/tests/component_tests/sensor/test_sensor.py index 9d18fa36b8f..07981c18976 100644 --- a/tests/component_tests/sensor/test_sensor.py +++ b/tests/component_tests/sensor/test_sensor.py @@ -1,5 +1,8 @@ """Tests for the sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import extract_packed_value @@ -15,3 +18,27 @@ def test_sensor_device_class_set(generate_main): # Then: device_class: voltage means packed value must be non-zero packed = extract_packed_value(main_cpp, "s_1") assert packed != 0 + + +def test_calibration_tables_are_shared_progmem( + generate_main: Callable[[str], str], +) -> None: + """Calibration data is a PROGMEM table, shared by filters with identical data.""" + main_cpp = generate_main("tests/component_tests/sensor/test_calibrate.yaml") + + linear = re.findall( + r"static constexpr std::array (\w+)\[\] PROGMEM", main_cpp + ) + assert len(linear) == 2 # the two exact filters share one table + exact, least_squares = linear + assert main_cpp.count(f"sensor::CalibrateLinearFilter({exact});") == 2 + assert f"sensor::CalibrateLinearFilter({least_squares});" in main_cpp + + poly = re.search( + r"static constexpr float (\w+)\[\] PROGMEM = \{2\.0f, 1\.0f, 2\.0f\};", main_cpp + ) + assert poly is not None + assert f"sensor::CalibratePolynomialFilter({poly.group(1)});" in main_cpp + # Table names never reuse a user id. + assert exact != "sensor_calibrate_linear" + assert "sensor_calibratelinearfilter_id_data" not in (exact, least_squares) From 3ad83ad02bf868c696f4b37441e289885018d0c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:40:38 -1000 Subject: [PATCH 031/111] [text_sensor] Share substitute and map filter tables in flash (#20076) --- esphome/components/text_sensor/__init__.py | 36 ++++++++---------- esphome/components/text_sensor/filter.cpp | 24 ++++++------ esphome/components/text_sensor/filter.h | 34 ++++++----------- .../text_sensor/test_text_sensor.py | 38 +++++++++++++++++++ ...est_text_sensor_filter_table_id_clash.yaml | 18 +++++++++ .../test_text_sensor_filter_tables.yaml | 25 ++++++++++++ 6 files changed, 121 insertions(+), 54 deletions(-) create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml create mode 100644 tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 959ce77c7a9..2ff0421f392 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -59,6 +59,7 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) PrependFilter = text_sensor_ns.class_("PrependFilter", Filter) SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter) MapFilter = text_sensor_ns.class_("MapFilter", Filter) +Substitution = text_sensor_ns.struct("Substitution") @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -102,34 +103,29 @@ def validate_mapping(value): )(value) -@FILTER_REGISTRY.register( - "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) -) -async def substitute_filter_to_code(config, filter_id): - substitutions = [ +def _substitution_table(config: list[ConfigType]) -> MockObj: + """Shared PROGMEM Substitution table, ended by an empty pair so filters store no count.""" + pairs = [ cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), + Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO]) ) for conf in config ] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(substitutions)), substitutions + end = cg.RawExpression("nullptr") + pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end))) + return cg.shared_progmem_array( + "text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs) ) +@FILTER_REGISTRY.register( + "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) +) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) -async def map_filter_to_code(config, filter_id): - mappings = [ - cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), - ) - for conf in config - ] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) +async def substitution_filter_to_code( + config: list[ConfigType], filter_id: ID +) -> MockObj: + return cg.new_Pvariable(filter_id, _substitution_table(config)) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 9d18fb9b1a6..54457d82386 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute — non-template helper -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - const size_t from_len = strlen(substitutions[i].from); - const size_t to_len = strlen(substitutions[i].to); +// Substitute +bool SubstituteFilter::new_value(std::string &value) { + for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) { + const size_t from_len = strlen(s->from); + const size_t to_len = strlen(s->to); std::size_t pos = 0; - while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, substitutions[i].to, to_len); + while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, s->to, to_len); pos += to_len; } } return true; } -// Map — non-template helper -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - if (value == mappings[i].from) { - value.assign(mappings[i].to); +// Map +bool MapFilter::new_value(std::string &value) { + for (const Substitution *m = this->mappings_; m->from != nullptr; m++) { + if (value == m->from) { + value.assign(m->to); return true; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb640..7898d466861 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -123,25 +121,18 @@ struct Substitution { const char *to; }; -/// Non-template helper (implementation in filter.cpp) -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); - /// A simple filter that replaces a substring with another substring. -/// N is set by code generation to match the exact number of substitutions configured in YAML. -template class SubstituteFilter : public Filter { +/// `substitutions` is a codegen table in flash, shared by filters with the same list and +/// ended by an entry whose `from` is nullptr, so no count is stored. +class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions) { - init_array_from(this->substitutions_, substitutions); - } - bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } + explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {} + bool new_value(std::string &value) override; protected: - std::array substitutions_{}; + const Substitution *substitutions_; }; -/// Non-template helper (implementation in filter.cpp) -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); - /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 * - * N is set by code generation to match the exact number of mappings configured in YAML. + * `mappings` is a codegen table in flash, shared by filters with the same list and ended by + * an entry whose `from` is nullptr, so no count is stored. */ -template class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings) { - init_array_from(this->mappings_, mappings); - } - bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } + explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {} + bool new_value(std::string &value) override; protected: - std::array mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index eb25af3095f..9eda24f7b94 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -1,5 +1,8 @@ """Tests for the text sensor component.""" +from collections.abc import Callable +import re + from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value @@ -62,3 +65,38 @@ def test_text_sensor_device_class_set(generate_main): assert packed_ts_2 != 0 packed_ts_3 = extract_packed_value(main_cpp, "ts_3") assert packed_ts_3 != 0 + + +def test_substitution_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Map and substitute pairs live in PROGMEM tables; equal lists share one.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + map_table, sub_table = tables + assert main_cpp.count(f"text_sensor::MapFilter({map_table})") == 2 + assert f"text_sensor::SubstituteFilter({sub_table})" in main_cpp + # Each table ends with an empty pair so the filters store no count. + assert main_cpp.count(".from = nullptr") == 2 + + +def test_substitution_table_names_avoid_user_ids( + generate_main: Callable[[str], str], +) -> None: + """A user id equal to a table name cannot collide with the generated table.""" + main_cpp = generate_main( + "tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml" + ) + + tables = re.findall( + r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp + ) + assert len(tables) == 2 + assert "text_sensor_substitutions" not in tables + assert "text_sensor_mapfilter_id_table" not in tables diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml new file mode 100644 index 00000000000..d0526cab197 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: text_sensor_substitutions + filters: + - map: + - "ON -> Open" + - platform: template + id: text_sensor_mapfilter_id_table + filters: + - map: + - "OFF -> Closed" diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml new file mode 100644 index 00000000000..ac422c33099 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini_lite + +text_sensor: + - platform: template + id: ts_map_a + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_map_b + filters: + - map: + - "ON -> Open" + - "OFF -> Closed" + - platform: template + id: ts_sub + filters: + - substitute: + - "foo -> bar" From 80fa6efd1e2fd117b2f5ae601fc60fe56b3661ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 13:44:49 -1000 Subject: [PATCH 032/111] [core] Document string tables, shared CRC helpers, NaN sentinels and polling start-up in AGENTS.md (#20599) --- AGENTS.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6bdd9467c01..5031c4a457f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,21 @@ file does, and it is the authority when they disagree. The most useful starting `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array a `TEMPLATABLE_BYTES` field so a static code stays in flash. + * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), + logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. + A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. + * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`); + pick the helper's polynomial and bit-order arguments instead of writing a CRC loop. + * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. + * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the + end of `setup()` when the entities need a state at boot. + * **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing + resend and give-up logic handles it; never advance on an answer that was only logged. + * **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python + tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual + listener base (a second vptr per entity). + * **A global feature define** says the code is compiled in, not that this instance uses it: keep the + per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a @@ -589,7 +604,9 @@ file does, and it is the authority when they disagree. The most useful starting `script/cpp_unit_test.py `; see `tests/components/README.md`. Set the loop clock in a test with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test. * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove - timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its + `responses` match on content every time they see it; to answer a repeated request differently, use an + `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs on. A variant per board for platform-identical code only adds CI time. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: From 8aab968136acb4867fc170c3eb4eab03c48759a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Doherty Date: Mon, 12 Oct 2026 17:11:10 -0700 Subject: [PATCH 033/111] [aranet] Add Aranet BLE sensor support (#18263) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/aranet/__init__.py | 2 + esphome/components/aranet/aranet.cpp | 157 ++++++++++++++++++ esphome/components/aranet/aranet.h | 42 +++++ esphome/components/aranet/sensor.py | 150 +++++++++++++++++ tests/components/aranet/common-ln.yaml | 7 + tests/components/aranet/common.h | 70 ++++++++ tests/components/aranet/common.yaml | 27 +++ .../components/aranet/sensor/test_aranet.cpp | 144 ++++++++++++++++ tests/components/aranet/test.esp32-idf.yaml | 3 + tests/components/aranet/test.ln882x-ard.yaml | 3 + .../aranet/validate.bk72xx-ard.yaml | 12 ++ 12 files changed, 618 insertions(+) create mode 100644 esphome/components/aranet/__init__.py create mode 100644 esphome/components/aranet/aranet.cpp create mode 100644 esphome/components/aranet/aranet.h create mode 100644 esphome/components/aranet/sensor.py create mode 100644 tests/components/aranet/common-ln.yaml create mode 100644 tests/components/aranet/common.h create mode 100644 tests/components/aranet/common.yaml create mode 100644 tests/components/aranet/sensor/test_aranet.cpp create mode 100644 tests/components/aranet/test.esp32-idf.yaml create mode 100644 tests/components/aranet/test.ln882x-ard.yaml create mode 100644 tests/components/aranet/validate.bk72xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fab41f5b1fa..075b8113d88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -44,6 +44,7 @@ esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex +esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr diff --git a/esphome/components/aranet/__init__.py b/esphome/components/aranet/__init__.py new file mode 100644 index 00000000000..d1d75568e94 --- /dev/null +++ b/esphome/components/aranet/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@patrickod"] +DOMAIN = "aranet" diff --git a/esphome/components/aranet/aranet.cpp b/esphome/components/aranet/aranet.cpp new file mode 100644 index 00000000000..4fa28eec17a --- /dev/null +++ b/esphome/components/aranet/aranet.cpp @@ -0,0 +1,157 @@ +#include "aranet.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::aranet { + +ESPHOME_LOG_TAG(TAG, "aranet"); +static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702; +static constexpr size_t ARANET4_DATA_LENGTH = 22; +static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24; + +enum class AranetDeviceType : uint8_t { + ARANET_DEVICE_TYPE_ARANET4 = 0, + ARANET_DEVICE_TYPE_ARANET2 = 1, + ARANET_DEVICE_TYPE_RADIATION = 2, + ARANET_DEVICE_TYPE_RADON = 3, +}; + +static inline void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); +} + +// Aranet does not publish an official advertisement specification. The layout below is corroborated by these +// independently maintained implementations: +// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py +// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h + +void Aranet::dump_config() { + ESP_LOGCONFIG(TAG, "Aranet"); + LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_); + LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_); + LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_); + LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_); + LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_); +} + +bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID); + for (const auto &manufacturer_data : device.get_manufacturer_datas()) { + if (manufacturer_data.uuid != manufacturer_uuid) + continue; + + const auto &data = manufacturer_data.data; + if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) { + if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) { + ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast(data.size())); + this->warned_ |= WARNED_INVALID_LENGTH; + } + return false; + } + + // Aranet4 has no device type byte, so its flags and trailer start one byte earlier + const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH; + if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) { + if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) { + ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device"); + this->warned_ |= WARNED_INTEGRATION_DISABLED; + } + return false; + } + + if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) { + if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) { + ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]); + this->warned_ |= WARNED_UNSUPPORTED_TYPE; + } + return false; + } + const auto device_type = + is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast(data[0]); + + // Battery, measurement interval, measurement age and measurement counter + const uint8_t *trailer = &data[is_aranet4 ? 15 : 17]; + const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]); + const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]); + const uint8_t measurement_counter = trailer[6]; + + publish_if_set(this->measurement_age_sensor_, measurement_age); + publish_if_set(this->signal_strength_sensor_, device.get_rssi()); + + if (measurement_counter == this->last_measurement_counter_) + return true; + this->last_measurement_counter_ = measurement_counter; + + // Not meaningful for Aranet Radiation, which stores its dose duration in these bytes + const float temperature = encode_uint16(data[11], data[10]) / 20.0f; + const float pressure = encode_uint16(data[13], data[12]) / 10.0f; + switch (device_type) { + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: { + const uint16_t co2 = encode_uint16(data[9], data[8]); + publish_if_set(this->co2_sensor_, co2); + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + publish_if_set(this->humidity_sensor_, data[14]); + ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature, + data[14], pressure); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: { + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: { + const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]); + const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]); + const uint16_t radiation_rate = encode_uint16(data[15], data[14]); + publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f); + publish_if_set(this->radiation_total_sensor_, radiation_total); + publish_if_set(this->radiation_duration_sensor_, radiation_duration); + ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate, + radiation_total); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: { + const uint16_t radon = encode_uint16(data[9], data[8]); + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->radon_sensor_, radon); + // Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model. + if (temperature != 0) + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + if (humidity != 0) + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon, + temperature, humidity, pressure); + break; + } + default: + break; + } + publish_if_set(this->battery_level_sensor_, trailer[0]); + publish_if_set(this->measurement_interval_sensor_, measurement_interval); + + return true; + } + + return false; +} + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/aranet.h b/esphome/components/aranet/aranet.h new file mode 100644 index 00000000000..7064ad7d451 --- /dev/null +++ b/esphome/components/aranet/aranet.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::aranet { + +class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + explicit Aranet(uint64_t address) : address_(address) {} + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0; + static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1; + static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2; + + // Small state first so it fills the padding before the first pointer + uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement + uint8_t warned_{0}; + + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(pressure) + SUB_SENSOR(battery_level) + SUB_SENSOR(measurement_interval) + SUB_SENSOR(measurement_age) + SUB_SENSOR(signal_strength) + SUB_SENSOR(radon) + SUB_SENSOR(radiation_rate) + SUB_SENSOR(radiation_total) + SUB_SENSOR(radiation_duration) + + protected: + uint64_t address_; +}; + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py new file mode 100644 index 00000000000..f22d242f9cc --- /dev/null +++ b/esphome/components/aranet/sensor.py @@ -0,0 +1,150 @@ +import esphome.codegen as cg +from esphome.components import ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CO2, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_PRESSURE, + CONF_RADON, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RADIOACTIVE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_BECQUEREL_PER_CUBIC_METER, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_HECTOPASCAL, + UNIT_MICROSILVERTS_PER_HOUR, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, + UNIT_SECOND, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base"] + +CONF_MEASUREMENT_AGE = "measurement_age" +CONF_MEASUREMENT_INTERVAL = "measurement_interval" +CONF_RADIATION_RATE = "radiation_rate" +CONF_RADIATION_TOTAL = "radiation_total" +CONF_RADIATION_DURATION = "radiation_duration" +UNIT_NANOSIEVERT = "nSv" + +aranet_ns = cg.esphome_ns.namespace("aranet") +Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component) + +DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Aranet), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_HECTOPASCAL, + accuracy_decimals=1, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_RADON, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR, + accuracy_decimals=3, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_NANOSIEVERT, + accuracy_decimals=0, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor) + await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor) + await sensors(CONF_RADON, var.set_radon_sensor) + await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor) + await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor) + await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor) diff --git a/tests/components/aranet/common-ln.yaml b/tests/components/aranet/common-ln.yaml new file mode 100644 index 00000000000..1652ae5c6a3 --- /dev/null +++ b/tests/components/aranet/common-ln.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature diff --git a/tests/components/aranet/common.h b/tests/components/aranet/common.h new file mode 100644 index 00000000000..41b5001a1f4 --- /dev/null +++ b/tests/components/aranet/common.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "esphome/components/aranet/aranet.h" + +namespace esphome::aranet::testing { + +constexpr uint8_t MAC_ADDRESS[6] = {0x55, 0x44, 0x33, 0x22, 0x11, 0x00}; +constexpr uint64_t ADDRESS = 0x001122334455ULL; + +inline const std::vector ARANET4_PAYLOAD = { + 0x20, // Smart Home integrations enabled + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58, 0x02, // CO2: 600 ppm + 0xA4, 0x01, // Temperature: 21.0 C (420 / 20) + 0x94, 0x27, // Pressure: 1013.2 hPa (10132 / 10) + 45, // Humidity: 45% + 87, // Battery: 87% + 0x00, // Display status + 0x2C, 0x01, // Measurement interval: 300 s + 12, 0x00, // Measurement age: 12 s + 7, // Measurement counter +}; + +inline const std::vector ARANET2_PAYLOAD = {0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x99, 0x01, 0x00, 0x00, 0x0A, 0x02, + 0x00, 0x3B, 0x09, 0x78, 0x00, 0x52, 0x00, 0x64}; + +inline const std::vector RADIATION_PAYLOAD = {0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0xD0, 0x33, + 0x00, 0x00, 0x6C, 0x60, 0x06, 0x00, 0x82, 0x00, + 0x00, 0x63, 0x00, 0x2C, 0x01, 0x58, 0x00, 0x72}; + +inline const std::vector RADON_PAYLOAD = {0x03, 0x21, 0x04, 0x06, 0x01, 0x00, 0x00, 0x00, + 0x07, 0x00, 0xFE, 0x01, 0xC9, 0x27, 0xCE, 0x01, + 0x00, 0x64, 0x01, 0x58, 0x02, 0xF6, 0x01, 0x08}; + +inline ble_device_base::ESPBTDevice make_device(const std::vector &payload, int rssi = -61, + const uint8_t *mac_address = MAC_ADDRESS) { + // Manufacturer specific data AD structure for company id 0x0702 + std::vector advertisement{static_cast(payload.size() + 3), 0xFF, 0x02, 0x07}; + advertisement.insert(advertisement.end(), payload.begin(), payload.end()); + + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac_address, rssi, 0, advertisement.data(), advertisement.size()); + return device; +} + +struct Harness { + Harness() { + this->aranet.set_co2_sensor(&this->co2); + this->aranet.set_temperature_sensor(&this->temperature); + this->aranet.set_humidity_sensor(&this->humidity); + this->aranet.set_pressure_sensor(&this->pressure); + this->aranet.set_battery_level_sensor(&this->battery); + this->aranet.set_measurement_interval_sensor(&this->interval); + this->aranet.set_measurement_age_sensor(&this->age); + this->aranet.set_signal_strength_sensor(&this->signal_strength); + this->aranet.set_radon_sensor(&this->radon); + this->aranet.set_radiation_rate_sensor(&this->radiation_rate); + this->aranet.set_radiation_total_sensor(&this->radiation_total); + this->aranet.set_radiation_duration_sensor(&this->radiation_duration); + } + + Aranet aranet{ADDRESS}; + sensor::Sensor co2, temperature, humidity, pressure, battery, interval, age, signal_strength, radon, radiation_rate, + radiation_total, radiation_duration; +}; + +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/common.yaml b/tests/components/aranet/common.yaml new file mode 100644 index 00000000000..09338269ea4 --- /dev/null +++ b/tests/components/aranet/common.yaml @@ -0,0 +1,27 @@ +sensor: + - platform: aranet + mac_address: 00:11:22:33:44:55 + co2: + name: Aranet4 Carbon Dioxide + temperature: + name: Aranet4 Temperature + humidity: + name: Aranet4 Humidity + pressure: + name: Aranet4 Pressure + battery_level: + name: Aranet4 Battery Level + measurement_interval: + name: Aranet4 Measurement Interval + measurement_age: + name: Aranet4 Measurement Age + signal_strength: + name: Aranet4 Signal Strength + radon: + name: Aranet Radon Concentration + radiation_rate: + name: Aranet Radiation Dose Rate + radiation_total: + name: Aranet Radiation Total Dose + radiation_duration: + name: Aranet Radiation Dose Duration diff --git a/tests/components/aranet/sensor/test_aranet.cpp b/tests/components/aranet/sensor/test_aranet.cpp new file mode 100644 index 00000000000..deba1114e5d --- /dev/null +++ b/tests/components/aranet/sensor/test_aranet.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::aranet::testing { +namespace { + +TEST(Aranet, DecodesAranet4) { + Harness h; + auto device = make_device(ARANET4_PAYLOAD); + ASSERT_EQ(device.address_uint64(), ADDRESS); + + EXPECT_TRUE(h.aranet.parse_device(device)); + EXPECT_FLOAT_EQ(h.co2.state, 600.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 21.0f); + EXPECT_FLOAT_EQ(h.humidity.state, 45.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1013.2f); + EXPECT_FLOAT_EQ(h.battery.state, 87.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 12.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -61.0f); + EXPECT_FALSE(h.radon.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, UpdatesDiagnosticsForRepeatedMeasurement) { + Harness h; + int co2_updates = 0; + int age_updates = 0; + int signal_strength_updates = 0; + h.co2.add_on_state_callback([&co2_updates](float) { co2_updates++; }); + h.age.add_on_state_callback([&age_updates](float) { age_updates++; }); + h.signal_strength.add_on_state_callback([&signal_strength_updates](float) { signal_strength_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61))); + auto payload = ARANET4_PAYLOAD; + payload[19] = 18; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload, -54))); + + EXPECT_EQ(co2_updates, 1); + EXPECT_EQ(age_updates, 2); + EXPECT_EQ(signal_strength_updates, 2); + EXPECT_FLOAT_EQ(h.age.state, 18.0f); + EXPECT_FLOAT_EQ(h.signal_strength.state, -54.0f); +} + +TEST(Aranet, RejectsDisabledIntegrationAndMalformedPayload) { + Harness h; + auto payload = ARANET4_PAYLOAD; + + payload[0] = 0; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + payload.pop_back(); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, RejectsMismatchedAddressAndUnsupportedDeviceType) { + Harness h; + constexpr uint8_t other_mac_address[6] = {0x66, 0x44, 0x33, 0x22, 0x11, 0x00}; + EXPECT_FALSE(h.aranet.parse_device(make_device(ARANET4_PAYLOAD, -61, other_mac_address))); + + auto payload = ARANET4_PAYLOAD; + payload.insert(payload.begin(), 0x09); + payload.push_back(0x00); + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + + // Type 0 is Aranet4, which never sends the 24 byte layout; the second warning stays quiet + payload[0] = 0x00; + EXPECT_FALSE(h.aranet.parse_device(make_device(payload))); + EXPECT_FALSE(h.signal_strength.has_state()); +} + +TEST(Aranet, DecodesAranet2) { + Harness h; + int temperature_updates = 0; + h.temperature.add_on_state_callback([&temperature_updates](float) { temperature_updates++; }); + + EXPECT_TRUE(h.aranet.parse_device(make_device(ARANET2_PAYLOAD))); + EXPECT_NEAR(h.temperature.state, 20.45f, 0.001f); + EXPECT_FLOAT_EQ(h.humidity.state, 52.2f); + EXPECT_FLOAT_EQ(h.battery.state, 59.0f); + EXPECT_FLOAT_EQ(h.interval.state, 120.0f); + EXPECT_FLOAT_EQ(h.age.state, 82.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + + auto repeated_payload = ARANET2_PAYLOAD; + repeated_payload[10] = 0x00; + repeated_payload[11] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(repeated_payload))); + EXPECT_EQ(temperature_updates, 1); +} + +TEST(Aranet, DecodesAranetRadiation) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADIATION_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radiation_rate.state, 0.13f); + EXPECT_FLOAT_EQ(h.radiation_total.state, 13264.0f); + EXPECT_FLOAT_EQ(h.radiation_duration.state, 417900.0f); + EXPECT_FLOAT_EQ(h.battery.state, 99.0f); + EXPECT_FLOAT_EQ(h.interval.state, 300.0f); + EXPECT_FLOAT_EQ(h.age.state, 88.0f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); + EXPECT_FALSE(h.pressure.has_state()); + EXPECT_FALSE(h.radon.has_state()); +} + +TEST(Aranet, DecodesAranetRadon) { + Harness h; + EXPECT_TRUE(h.aranet.parse_device(make_device(RADON_PAYLOAD))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.temperature.state, 25.5f); + EXPECT_FLOAT_EQ(h.humidity.state, 46.2f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.co2.has_state()); + EXPECT_FALSE(h.radiation_rate.has_state()); + EXPECT_FALSE(h.radiation_total.has_state()); + EXPECT_FALSE(h.radiation_duration.has_state()); +} + +TEST(Aranet, RadonOneSkipsZeroTemperatureAndHumidity) { + Harness h; + auto payload = RADON_PAYLOAD; + payload[10] = payload[11] = 0x00; + payload[14] = payload[15] = 0x00; + EXPECT_TRUE(h.aranet.parse_device(make_device(payload))); + EXPECT_FLOAT_EQ(h.radon.state, 7.0f); + EXPECT_FLOAT_EQ(h.pressure.state, 1018.5f); + EXPECT_FALSE(h.temperature.has_state()); + EXPECT_FALSE(h.humidity.has_state()); +} + +} // namespace +} // namespace esphome::aranet::testing diff --git a/tests/components/aranet/test.esp32-idf.yaml b/tests/components/aranet/test.esp32-idf.yaml new file mode 100644 index 00000000000..ca7354799f1 --- /dev/null +++ b/tests/components/aranet/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + aranet: !include common.yaml diff --git a/tests/components/aranet/test.ln882x-ard.yaml b/tests/components/aranet/test.ln882x-ard.yaml new file mode 100644 index 00000000000..9c195fc5678 --- /dev/null +++ b/tests/components/aranet/test.ln882x-ard.yaml @@ -0,0 +1,3 @@ +packages: + ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml + aranet: !include common-ln.yaml diff --git a/tests/components/aranet/validate.bk72xx-ard.yaml b/tests/components/aranet/validate.bk72xx-ard.yaml new file mode 100644 index 00000000000..97a2fe61b20 --- /dev/null +++ b/tests/components/aranet/validate.bk72xx-ard.yaml @@ -0,0 +1,12 @@ +# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the +# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding) +# on a non-esp32 platform; codegen and compilation are not exercised here. +bk72xx_ble_tracker: + id: ble_hub + +sensor: + - platform: aranet + ble_hub_id: ble_hub + mac_address: 00:11:22:33:44:55 + co2: + name: BK Aranet4 Carbon Dioxide From 4cae63bbaa8deed355e7d262c58a6e105ca865c2 Mon Sep 17 00:00:00 2001 From: gledian Date: Tue, 13 Oct 2026 02:27:16 +0200 Subject: [PATCH 034/111] [ecocomfort2] Add the read-only sensors and clock sync (Part 2) (#15447) Co-authored-by: gledian Co-authored-by: Claude Opus 4.6 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ecocomfort2/__init__.py | 7 +- .../components/ecocomfort2/binary_sensor.py | 33 +++ .../ecocomfort2/ecocomfort2_hub.cpp | 233 +++++++++++++++--- .../components/ecocomfort2/ecocomfort2_hub.h | 60 +++++ esphome/components/ecocomfort2/sensor.py | 62 +++++ esphome/components/ecocomfort2/text_sensor.py | 24 ++ tests/components/ecocomfort2/common.yaml | 35 +++ 7 files changed, 420 insertions(+), 34 deletions(-) create mode 100644 esphome/components/ecocomfort2/binary_sensor.py create mode 100644 esphome/components/ecocomfort2/sensor.py create mode 100644 esphome/components/ecocomfort2/text_sensor.py diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 472ac8f7cf8..8a0e1654188 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -1,12 +1,12 @@ import esphome.codegen as cg -from esphome.components import ble_client, esp32_ble +from esphome.components import ble_client, esp32_ble, time from esphome.components.esp32_ble import ( CONF_AUTH_REQ_MODE, CONF_IO_CAPABILITY, CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -27,6 +27,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) .extend(ble_client.BLE_CLIENT_SCHEMA) @@ -52,6 +53,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) + if (time_id := config.get(CONF_TIME_ID)) is not None: + cg.add(var.set_time(await cg.get_variable(time_id))) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/ecocomfort2/binary_sensor.py b/esphome/components/ecocomfort2/binary_sensor.py new file mode 100644 index 00000000000..15e5b69d2d7 --- /dev/null +++ b/esphome/components/ecocomfort2/binary_sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONF_BOOST = "boost" + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BOOST): binary_sensor.binary_sensor_schema(), + } + ), + cv.has_at_least_one_key(CONF_CONNECTED, CONF_BOOST), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_CONNECTED, hub.set_connected_binary_sensor) + await sensors(CONF_BOOST, hub.set_boost_binary_sensor) diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index a20fdd9112c..00ce0f20804 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -3,22 +3,39 @@ #include "ecocomfort2_hub.h" #include "esphome/core/log.h" +#include + namespace esphome::ecocomfort2 { ESPHOME_LOG_TAG(TAG, "ecocomfort2"); -static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); -static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); +void Ecocomfort2Hub::setup() { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_initial_state(false); +#endif +#ifdef USE_TIME + // The clock is written after each time-source sync + if (this->time_ != nullptr) + this->time_->add_on_time_sync_callback([this]() { this->sync_clock_(); }); +#endif +} void Ecocomfort2Hub::update() { if (!this->is_ready()) { ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); return; } - auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), - this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + // The GATT client runs the requests one after the other + this->read_char_(this->char_handle_state_); + this->read_char_(this->char_handle_oper_); +} + +void Ecocomfort2Hub::read_char_(uint16_t handle) { + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle, + ESP_GATT_AUTH_REQ_NONE); if (status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + ESP_LOGW(TAG, "[%s] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status); } } @@ -28,6 +45,22 @@ void Ecocomfort2Hub::dump_config() { " Address: %s", this->parent_->address_str()); LOG_UPDATE_INTERVAL(this); +#ifdef USE_TIME + ESP_LOGCONFIG(TAG, " Clock sync: %s", YESNO(this->time_ != nullptr)); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); + LOG_SENSOR(" ", "Direction", this->direction_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Connected", this->connected_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Boost", this->boost_binary_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Firmware", this->firmware_text_sensor_); +#endif } void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, @@ -39,17 +72,34 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if this->cancel_timeout(READBACK_TIMEOUT_ID); this->status_set_warning(); this->char_handle_oper_ = 0; + this->char_handle_state_ = 0; + this->char_handle_info_ = 0; + this->char_handle_clock_ = 0; + this->set_connected_(false); + this->publish_unknown_(); break; } case ESP_GATTC_SEARCH_CMPL_EVT: { - // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. - auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); - if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + const auto service_uuid = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); + const auto oper_uuid = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + const auto state_uuid = espbt::ESPBTUUID::from_raw("438d3433-7e5a-459a-a8e4-66343fad2bb0"); + const auto info_uuid = espbt::ESPBTUUID::from_raw("f5f56229-dd4f-480f-a829-9189269d8b37"); + const auto clock_uuid = espbt::ESPBTUUID::from_raw("82788997-49e4-4533-b949-7ed433678044"); + // Keep the handles, not the characteristics: ble_client frees its services once all nodes are established. + auto *oper = this->parent_->get_characteristic(service_uuid, oper_uuid); + auto *state = this->parent_->get_characteristic(service_uuid, state_uuid); + if (oper == nullptr || state == nullptr) { + ESP_LOGW(TAG, "[%s] C_OPER or C_STATE characteristic not found, disconnecting", this->parent_->address_str()); this->parent_->disconnect(); break; } - this->char_handle_oper_ = chr->handle; + this->char_handle_oper_ = oper->handle; + this->char_handle_state_ = state->handle; + // The version and clock characteristics only feed optional features, so a unit without them still works + auto *info = this->parent_->get_characteristic(service_uuid, info_uuid); + this->char_handle_info_ = info != nullptr ? info->handle : 0; + auto *clock = this->parent_->get_characteristic(service_uuid, clock_uuid); + this->char_handle_clock_ = clock != nullptr ? clock->handle : 0; this->node_state = espbt::ClientState::ESTABLISHED; this->status_set_warning(); @@ -71,31 +121,22 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if break; } case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + if (param->read.conn_id != this->parent_->get_conn_id()) { break; } - if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { - // ble_client reconnects and pairs again - ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); - this->parent_->disconnect(); + const uint16_t handle = param->read.handle; + if (handle == this->char_handle_oper_) { + if (this->read_ok_(param)) + this->on_oper_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_state_) { + if (this->read_ok_(param)) + this->on_state_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_info_) { + if (this->read_ok_(param)) + this->on_info_(param->read.value, param->read.value_len); + } else { break; } - if (param->read.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); - break; - } - if (param->read.value_len < 2) { - ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); - break; - } - this->mode_ = param->read.value[0]; - this->speed_ = param->read.value[1]; - ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef ECOCOMFORT2_CHILD_COUNT - for (auto *child : this->children_) { - child->on_status(); - } -#endif break; } default: @@ -103,6 +144,21 @@ void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if } } +bool Ecocomfort2Hub::read_ok_(const esp_ble_gattc_cb_param_t *param) { + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + return false; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] Read of handle 0x%x failed, status=%d", this->parent_->address_str(), param->read.handle, + param->read.status); + return false; + } + return true; +} + void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || this->node_state != espbt::ClientState::ESTABLISHED) { @@ -122,9 +178,122 @@ void Ecocomfort2Hub::on_paired_() { this->cancel_timeout(AUTH_TIMEOUT_ID); this->status_clear_warning(); ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->set_connected_(true); +#ifdef USE_TIME + if (this->time_ != nullptr) + this->sync_clock_(); +#endif +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr && this->char_handle_info_ != 0) + this->read_char_(this->char_handle_info_); +#endif this->update(); } +void Ecocomfort2Hub::set_connected_(bool connected) { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_state(connected); +#endif +} + +// Readings from a unit that is out of reach would otherwise look current for hours +void Ecocomfort2Hub::publish_unknown_() { +#ifdef USE_SENSOR + for (auto *sensor : {this->temperature_sensor_, this->humidity_sensor_, this->voc_sensor_, this->direction_sensor_}) { + if (sensor != nullptr) + sensor->publish_state(NAN); + } +#endif +} + +// C_OPER: mode byte, speed/flags byte +void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), len); + return; + } + this->mode_ = data[0]; + this->speed_ = data[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); +#endif +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif +} + +// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { + if (len < 9) { + ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); + return; + } + const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float humidity = encode_uint16(data[5], data[6]) / 100.0f; + const uint16_t voc = encode_uint16(data[7], data[8]); + ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, + humidity, voc, data[2]); +#ifdef USE_SENSOR + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->direction_sensor_ != nullptr) + this->direction_sensor_->publish_state(data[2]); +#endif +} + +// C_INFO: firmware version packed as 4-bit major, 6-bit minor and 6-bit patch +void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_INFO too short: %u bytes", this->parent_->address_str(), len); + return; + } + const uint16_t field = encode_uint16(data[0] & 0x0F, data[1]); + char version[12]; + snprintf(version, sizeof(version), "%u.%u.%u", data[0] >> 4, field >> 6, field & 0x3F); + ESP_LOGD(TAG, "[%s] Firmware %s", this->parent_->address_str(), version); +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr) + this->firmware_text_sensor_->publish_state(version); +#endif +} + +#ifdef USE_TIME +// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +void Ecocomfort2Hub::sync_clock_() { + if (!this->is_ready() || this->char_handle_clock_ == 0) + return; + const ESPTime now = this->time_->now(); + if (!now.is_valid()) + return; + // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 + const uint8_t weekday = (now.day_of_week + 5) % 7; + uint8_t data[8]{now.hour, + now.minute, + now.second, + weekday, + now.day_of_month, + now.month, + static_cast(now.year >> 8), + static_cast(now.year)}; + ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_CLOCK write failed, status=%d", this->parent_->address_str(), status); + } +} +#endif + void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { if (!this->is_ready()) { ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); @@ -139,7 +308,7 @@ void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); return; } - this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->read_char_(this->char_handle_oper_); }); } #ifdef ECOCOMFORT2_CHILD_COUNT diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index 5a609d9283e..ed046fe0495 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -8,6 +8,19 @@ #include "esphome/core/helpers.h" #include "ecocomfort2_child.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + namespace esphome::ecocomfort2 { namespace espbt = esphome::esp32_ble_tracker; @@ -33,6 +46,7 @@ static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { public: + void setup() override; void update() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BLUETOOTH; } @@ -44,6 +58,23 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling #ifdef ECOCOMFORT2_CHILD_COUNT void register_child(Ecocomfort2Client *obj); #endif +#ifdef USE_SENSOR + void set_temperature_sensor(sensor::Sensor *s) { this->temperature_sensor_ = s; } + void set_humidity_sensor(sensor::Sensor *s) { this->humidity_sensor_ = s; } + void set_voc_sensor(sensor::Sensor *s) { this->voc_sensor_ = s; } + void set_direction_sensor(sensor::Sensor *s) { this->direction_sensor_ = s; } +#endif +#ifdef USE_BINARY_SENSOR + void set_connected_binary_sensor(binary_sensor::BinarySensor *s) { this->connected_binary_sensor_ = s; } + void set_boost_binary_sensor(binary_sensor::BinarySensor *s) { this->boost_binary_sensor_ = s; } +#endif +#ifdef USE_TEXT_SENSOR + void set_firmware_text_sensor(text_sensor::TextSensor *s) { this->firmware_text_sensor_ = s; } +#endif +#ifdef USE_TIME + /// Keeps the unit's clock in step with this time source while connected. + void set_time(time::RealTimeClock *time) { this->time_ = time; } +#endif /// True once the link is established and paired. bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } @@ -62,11 +93,40 @@ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public Polling static constexpr uint32_t READBACK_DELAY_MS = 300; void on_paired_(); + void set_connected_(bool connected); + void publish_unknown_(); + void read_char_(uint16_t handle); + bool read_ok_(const esp_ble_gattc_cb_param_t *param); + void on_oper_(const uint8_t *data, uint16_t len); + void on_state_(const uint8_t *data, uint16_t len); + void on_info_(const uint8_t *data, uint16_t len); +#ifdef USE_TIME + void sync_clock_(); +#endif #ifdef ECOCOMFORT2_CHILD_COUNT StaticVector children_; +#endif +#ifdef USE_SENSOR + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *voc_sensor_{nullptr}; + sensor::Sensor *direction_sensor_{nullptr}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *connected_binary_sensor_{nullptr}; + binary_sensor::BinarySensor *boost_binary_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *firmware_text_sensor_{nullptr}; +#endif +#ifdef USE_TIME + time::RealTimeClock *time_{nullptr}; #endif uint16_t char_handle_oper_{0}; + uint16_t char_handle_state_{0}; + uint16_t char_handle_info_{0}; + uint16_t char_handle_clock_{0}; uint8_t mode_{OPER_OFF}; uint8_t speed_{SPEED_OFF}; }; diff --git a/esphome/components/ecocomfort2/sensor.py b/esphome/components/ecocomfort2/sensor.py new file mode 100644 index 00000000000..878cc05f874 --- /dev/null +++ b/esphome/components/ecocomfort2/sensor.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_HUMIDITY, + CONF_TEMPERATURE, + CONF_VOC, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_BILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_VOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DIRECTION): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY, CONF_VOC, CONF_DIRECTION), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_VOC, hub.set_voc_sensor) + await sensors(CONF_DIRECTION, hub.set_direction_sensor) diff --git a/esphome/components/ecocomfort2/text_sensor.py b/esphome/components/ecocomfort2/text_sensor.py new file mode 100644 index 00000000000..4f2ac365399 --- /dev/null +++ b/esphome/components/ecocomfort2/text_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = text_sensor.sub_text_sensors(config) + await sensors(CONF_VERSION, hub.set_firmware_text_sensor) diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml index b4951e804f8..84fd33a983b 100644 --- a/tests/components/ecocomfort2/common.yaml +++ b/tests/components/ecocomfort2/common.yaml @@ -3,6 +3,14 @@ esp32_ble: auth_req_mode: sc_bond max_key_size: 16 +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + id: sntp_time + ble_client: - mac_address: 01:02:03:04:05:06 id: ecocomfort2_ble_1 @@ -12,6 +20,7 @@ ble_client: ecocomfort2: - id: ecocomfort2_hub_1 ble_client_id: ecocomfort2_ble_1 + time_id: sntp_time - id: ecocomfort2_hub_2 ble_client_id: ecocomfort2_ble_2 @@ -22,3 +31,29 @@ fan: - platform: ecocomfort2 name: Ecocomfort2 Fan 2 ecocomfort2_id: ecocomfort2_hub_2 + +sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + temperature: + name: Ecocomfort2 Temperature + humidity: + name: Ecocomfort2 Humidity + voc: + name: Ecocomfort2 VOC + direction: + name: Ecocomfort2 Direction + +binary_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_1 + connected: + name: Ecocomfort2 Connected + boost: + name: Ecocomfort2 Boost + +text_sensor: + - platform: ecocomfort2 + ecocomfort2_id: ecocomfort2_hub_2 + version: + name: Ecocomfort2 Firmware From dc9a7cffbdbb7490c791ef65fe9b362230af64f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:28:38 +0200 Subject: [PATCH 035/111] [ota] Refuse a second OTA while one is already running (#20588) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ota/ota_backend.cpp | 58 ++++++++++ esphome/components/ota/ota_backend.h | 24 ++++ .../ota/ota_backend_arduino_libretiny.cpp | 9 +- .../ota/ota_backend_arduino_libretiny.h | 1 + .../ota/ota_backend_arduino_rp2.cpp | 5 + .../components/ota/ota_backend_arduino_rp2.h | 1 + .../components/ota/ota_backend_esp8266.cpp | 5 + esphome/components/ota/ota_backend_esp8266.h | 1 + .../components/ota/ota_backend_esp_idf.cpp | 10 +- esphome/components/ota/ota_backend_esp_idf.h | 6 + esphome/components/ota/ota_backend_host.cpp | 5 + esphome/components/ota/ota_backend_host.h | 1 + .../web_server/ota/ota_web_server.cpp | 104 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 4 + .../web_server_idf/web_server_idf.h | 9 ++ esphome/espota2.py | 5 + tests/components/ota/test_update_lock.cpp | 85 ++++++++++++++ tests/unit_tests/test_espota2.py | 1 + 18 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 tests/components/ota/test_update_lock.cpp diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3b..a83195d42dc 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index a505d58efef..840d1ac11c7 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -51,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -95,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 02a5c5750f8..33af42b6a7a 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -14,6 +14,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f23..fc277bb5e41 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -16,6 +16,7 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 74f44c0b3bc..d4d94be2363 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -16,6 +16,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index 15142869ac3..93b9dcb75ce 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -21,6 +21,7 @@ class ArduinoRP2OTABackend final { static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 5be55a52000..3d6148e64f6 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -51,6 +51,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 1f1ec37eee4..bd4c9a1bce7 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -23,6 +23,7 @@ class ESP8266OTABackend final { static constexpr bool supports_compression() { return true; } protected: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 2316aad5cf6..fd6fd7f6f15 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -20,6 +20,8 @@ ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 4f4093a5945..d166bb361fa 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -36,6 +36,12 @@ class IDFOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 456fbb58638..923b0afe18a 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index e53868f102b..6c86fe10bab 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -22,6 +22,7 @@ class HostOTABackend final { static constexpr bool supports_compression() { return false; } protected: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3cf1d9802ef..8a668ae0fc1 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) - if (Update.isRunning()) { - Update.abort(); - } -#endif -#endif // USE_ARDUINO - this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // is unknown (contentLength includes multipart overhead) // Pass 0 to indicate unknown size error_code = this->ota_backend_->begin(0); + if (error_code == ota::OTA_RESPONSE_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2153e232a05..d9a7d65e8ce 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -311,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index f9094b05c50..4d4490d9076 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -183,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -198,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); diff --git a/esphome/espota2.py b/esphome/espota2.py index f775afe7f66..d8a2ff9000d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -54,6 +54,7 @@ RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94 +RESPONSE_ERROR_UPDATE_BUSY = 0x95 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -203,6 +204,10 @@ _ERROR_MESSAGES: dict[int, str] = { "of the YAML this upload uses, or update your esphome installation if it " "predates OTA encryption." ), + RESPONSE_ERROR_UPDATE_BUSY: ( + "The device is busy with another update that writes to its flash. " + "Wait for it to finish, then retry." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/tests/components/ota/test_update_lock.cpp b/tests/components/ota/test_update_lock.cpp new file mode 100644 index 00000000000..28314cb1c27 --- /dev/null +++ b/tests/components/ota/test_update_lock.cpp @@ -0,0 +1,85 @@ +#include + +#include "esphome/components/ota/ota_backend_factory.h" + +namespace esphome::ota::testing { + +TEST(UpdateLock, SecondHolderIsRefused) { + UpdateLock first; + UpdateLock second; + EXPECT_TRUE(first.try_acquire()); + EXPECT_FALSE(second.try_acquire()); + first.release(); + EXPECT_TRUE(second.try_acquire()); +} + +TEST(UpdateLock, AcquireIsIdempotentForTheHolder) { + UpdateLock lock; + EXPECT_TRUE(lock.try_acquire()); + EXPECT_TRUE(lock.try_acquire()); + lock.release(); + UpdateLock other; + EXPECT_TRUE(other.try_acquire()); +} + +TEST(UpdateLock, ReleaseByNonHolderKeepsLock) { + UpdateLock holder; + UpdateLock other; + EXPECT_TRUE(holder.try_acquire()); + other.release(); + EXPECT_FALSE(other.try_acquire()); +} + +TEST(UpdateLock, DestructorReleases) { + { + UpdateLock scoped; + EXPECT_TRUE(scoped.try_acquire()); + } + UpdateLock next; + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, IsHeldTracksOwnership) { + UpdateLock holder; + UpdateLock other; + EXPECT_FALSE(holder.is_held()); + EXPECT_TRUE(holder.try_acquire()); + EXPECT_TRUE(holder.is_held()); + EXPECT_FALSE(other.try_acquire()); + EXPECT_FALSE(other.is_held()); +} + +class TestUpdateLock : public UpdateLock { + public: + using UpdateLock::force_release; +}; + +TEST(UpdateLock, HoldUntilRebootOutlivesHolder) { + { + UpdateLock committed; + EXPECT_TRUE(committed.try_acquire()); + committed.hold_until_reboot(); + EXPECT_FALSE(committed.is_held()); + } + UpdateLock next; + EXPECT_FALSE(next.try_acquire()); + TestUpdateLock::force_release(); + EXPECT_TRUE(next.try_acquire()); +} + +TEST(UpdateLock, SecondBackendBeginIsBusy) { + auto first = make_ota_backend(); + auto second = make_ota_backend(); + ASSERT_EQ(first->begin(1024), OTA_RESPONSE_OK); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_ERROR_UPDATE_BUSY); + // Native OTA calls abort() after a refused begin(). + second->abort(); + uint8_t data[16] = {}; + EXPECT_EQ(first->write(data, sizeof(data)), OTA_RESPONSE_OK); + first->abort(); + first.reset(); + EXPECT_EQ(second->begin(1024), OTA_RESPONSE_OK); + second->abort(); +} + +} // namespace esphome::ota::testing diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6fa6c616b67..f1e04da8c87 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -263,6 +263,7 @@ def test_receive_exactly_closed_connection_is_network_error(mock_socket: Mock) - espota2.RESPONSE_ERROR_BOOTLOADER_UPDATE, "An error occurred while updating the bootloader", ), + (espota2.RESPONSE_ERROR_UPDATE_BUSY, "The device is busy with another update"), (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), ], ) From a3e373b063ff9a60e4b3f72d72fa1b7633c04615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:31:36 -1000 Subject: [PATCH 036/111] [noise] Add generate_encryption_key and use it in the wizard and dashboard_import (#20600) --- esphome/components/dashboard_import/__init__.py | 6 ++---- esphome/components/noise/__init__.py | 6 ++++++ esphome/wizard.py | 6 ++---- tests/component_tests/noise/test_encryption_key.py | 14 +++++++++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index aca0901d2b8..576887f6dc7 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -1,13 +1,12 @@ -import base64 from pathlib import Path import re -import secrets from typing import Any from ruamel.yaml import YAML from esphome import git import esphome.codegen as cg +from esphome.components.noise import generate_encryption_key from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI @@ -175,8 +174,7 @@ def import_config( "esphome": esphome_core, } if encryption: - noise_psk = secrets.token_bytes(32) - key = base64.b64encode(noise_psk).decode() + key = generate_encryption_key() config["api"] = {"encryption": {"key": key}} output = dump(config) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 1769c110184..781079c1901 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -1,5 +1,6 @@ import base64 import binascii +import secrets from typing import Any import esphome.codegen as cg @@ -22,6 +23,11 @@ noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) +def generate_encryption_key() -> str: + """A fresh random key in the base64 form the yaml takes.""" + return base64.b64encode(secrets.token_bytes(32)).decode() + + def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: diff --git a/esphome/wizard.py b/esphome/wizard.py index 897d5f60a14..10ea0d0b4cc 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -1,4 +1,3 @@ -import base64 from pathlib import Path import secrets import string @@ -7,6 +6,7 @@ import unicodedata import voluptuous as vol +from esphome.components.noise import generate_encryption_key import esphome.config_validation as cv from esphome.const import ALLOWED_NAME_CHARS, ENV_QUICKWIZARD from esphome.core import CORE @@ -516,9 +516,7 @@ def wizard(path: Path) -> int: safe_print() sleep(0.5) - # Generate encryption key (32 bytes, base64 encoded) for secure API communication - noise_psk = secrets.token_bytes(32) - api_encryption_key = base64.b64encode(noise_psk).decode() + api_encryption_key = generate_encryption_key() safe_print( "For secure API communication, I've generated a random encryption key." diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py index 2b79bd5464f..3a895e25de6 100644 --- a/tests/component_tests/noise/test_encryption_key.py +++ b/tests/component_tests/noise/test_encryption_key.py @@ -5,7 +5,11 @@ from __future__ import annotations import pytest from esphome import config_validation as cv -from esphome.components.noise import decode_encryption_key, validate_encryption_key +from esphome.components.noise import ( + decode_encryption_key, + generate_encryption_key, + validate_encryption_key, +) KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" @@ -42,3 +46,11 @@ def test_validate_encryption_key_rejects_all_zeros() -> None: key, so it never reaches a build.""" with pytest.raises(cv.Invalid, match="all-zeros key is reserved"): validate_encryption_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + + +def test_generate_encryption_key_passes_the_validator() -> None: + """A fresh key is 32 random bytes in the base64 form the yaml takes.""" + key = generate_encryption_key() + assert validate_encryption_key(key) == key + assert len(decode_encryption_key(key)) == 32 + assert key != generate_encryption_key() From bdd2dcd8c6f5af6fa7a2938a7ca38798b6a43020 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:39:08 +1300 Subject: [PATCH 037/111] [weikai] Mark configurable classes as final (#17292) Co-authored-by: J. Nick Koston --- esphome/components/weikai/weikai.cpp | 4 ++-- esphome/components/weikai/weikai.h | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index 2576856e2f6..938b191756d 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { void WeikaiChannel::setup_channel() { ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name()); // we enable transmit and receive on this channel - if (this->check_channel_down()) { + if (this->check_channel_down_()) { ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name()); } this->reset_fifo_(); @@ -378,7 +378,7 @@ size_t WeikaiChannel::rx_in_fifo_() { return available; } -bool WeikaiChannel::check_channel_down() { +bool WeikaiChannel::check_channel_down_() { // to check if we channel is up we write to the LCR W/R register // note that this will put a break on the tx line for few ms WeikaiRegister &lcr = this->reg(WKREG_LCR); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 02a39d3c848..eb8a41ad9d0 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -266,7 +266,7 @@ class WeikaiComponent : public Component { /////////////////////////////////////////////////////////////////////////////// /// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin. /////////////////////////////////////////////////////////////////////////////// -class WeikaiGPIOPin : public GPIOPin { +class WeikaiGPIOPin final : public GPIOPin { public: void set_parent(WeikaiComponent *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } @@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin { /// uart::UARTComponent virtual class. This class is common to the different members of the Weikai /// components family and therefore avoid code duplication. /////////////////////////////////////////////////////////////////////////////////////////////////// -class WeikaiChannel : public uart::UARTComponent { +class WeikaiChannel final : public uart::UARTComponent { public: /// @brief We belongs to this WeikaiComponent /// @param parent pointer to the component we belongs to @@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent { const char *get_channel_name() { return this->name_.c_str(); } /// @brief Setup the channel - void virtual setup_channel(); + void setup_channel(); /// @brief dump channel information - void virtual dump_channel(); + void dump_channel(); /// @brief Factory method to create a WeikaiRegister proxy object /// @param reg address of the register @@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent { /// @brief check if channel is alive /// @return true if OK - bool virtual check_channel_down(); + bool check_channel_down_(); #ifdef TEST_COMPONENT /// @ingroup test_ From f3f3ca5a8a7427f92889a5bb62ffeca6cf3cce33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:50:24 -1000 Subject: [PATCH 038/111] [runtime_image] Add CodSpeed benchmarks for JPEG decoding (#20603) --- .../components/runtime_image/__init__.py | 12 + .../runtime_image/bench_jpeg_decode.cpp | 64 ++++++ .../components/runtime_image/jpeg_96x96.h | 213 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 tests/benchmarks/components/runtime_image/__init__.py create mode 100644 tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp create mode 100644 tests/benchmarks/components/runtime_image/jpeg_96x96.h diff --git a/tests/benchmarks/components/runtime_image/__init__.py b/tests/benchmarks/components/runtime_image/__init__.py new file mode 100644 index 00000000000..775fda50c10 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/__init__.py @@ -0,0 +1,12 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in benchmark builds; the JPEG decoder is normally + # enabled by process_runtime_image_config(), so enable it here. + async def to_code_benchmark(config: ConfigType) -> None: + enable_format("JPEG") + + manifest.to_code = to_code_benchmark diff --git a/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp new file mode 100644 index 00000000000..900b340e9c2 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_jpeg_decode.cpp @@ -0,0 +1,64 @@ +#include + +#include + +#include "esphome/components/runtime_image/runtime_image.h" +#include "jpeg_96x96.h" + +namespace esphome::benchmarks { + +using runtime_image::RuntimeImage; + +// A fixed-size image so every decode scales the same JPEG into the same buffer +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, int width, int height) + : RuntimeImage(runtime_image::JPEG, type, image::TRANSPARENCY_OPAQUE, nullptr, false, width, height) {} +}; + +// One full decode session, fed the way online_image feeds a download +static bool decode(BenchImage &img, std::vector &buffer) { + if (!img.begin_decode(buffer.size(), runtime_image::JPEG)) { + return false; + } + size_t offset = 0; + while (offset < buffer.size()) { + const int consumed = img.feed_data(buffer.data() + offset, buffer.size() - offset); + if (consumed <= 0) { + return false; + } + offset += consumed; + } + return img.end_decode(); +} + +static void run(benchmark::State &state, image::ImageType type, int width, int height) { + BenchImage img(type, width, height); + std::vector buffer(JPEG_96X96, JPEG_96X96 + JPEG_96X96_SIZE); + for (auto _ : state) { + if (!decode(img, buffer)) { + state.SkipWithError("decode failed"); + return; + } + benchmark::DoNotOptimize(img.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * static_cast(width) * height); +} + +// --- JPEG into an RGB565 image: same size, half size and double size --- + +static void JpegDecode_Rgb565_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 96, 96); } +BENCHMARK(JpegDecode_Rgb565_Native); + +static void JpegDecode_Rgb565_Half(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 48, 48); } +BENCHMARK(JpegDecode_Rgb565_Half); + +static void JpegDecode_Rgb565_Double(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB565, 192, 192); } +BENCHMARK(JpegDecode_Rgb565_Double); + +// --- JPEG into an RGB image at the same size, the generic per-pixel path for reference --- + +static void JpegDecode_Rgb_Native(benchmark::State &state) { run(state, image::IMAGE_TYPE_RGB, 96, 96); } +BENCHMARK(JpegDecode_Rgb_Native); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/runtime_image/jpeg_96x96.h b/tests/benchmarks/components/runtime_image/jpeg_96x96.h new file mode 100644 index 00000000000..625515ec489 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/jpeg_96x96.h @@ -0,0 +1,213 @@ +#pragma once + +#include +#include + +namespace esphome::benchmarks { + +// 96x96 baseline JPEG, quality 85, no chroma subsampling: a gradient with rings, 3791 bytes +static const uint8_t JPEG_96X96[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x05, 0x03, 0x04, 0x04, 0x04, 0x03, 0x05, 0x04, 0x04, 0x04, 0x05, 0x05, 0x05, + 0x06, 0x07, 0x0C, 0x08, 0x07, 0x07, 0x07, 0x07, 0x0F, 0x0B, 0x0B, 0x09, 0x0C, 0x11, 0x0F, 0x12, 0x12, 0x11, 0x0F, + 0x11, 0x11, 0x13, 0x16, 0x1C, 0x17, 0x13, 0x14, 0x1A, 0x15, 0x11, 0x11, 0x18, 0x21, 0x18, 0x1A, 0x1D, 0x1D, 0x1F, + 0x1F, 0x1F, 0x13, 0x17, 0x22, 0x24, 0x22, 0x1E, 0x24, 0x1C, 0x1E, 0x1F, 0x1E, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x05, + 0x05, 0x05, 0x07, 0x06, 0x07, 0x0E, 0x08, 0x08, 0x0E, 0x1E, 0x14, 0x11, 0x14, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, + 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x60, 0x00, 0x60, 0x03, 0x01, 0x11, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xF9, 0x66, 0x0B, 0x6E, + 0x7A, 0x57, 0xED, 0x30, 0x8B, 0xB9, 0xE7, 0x53, 0xAA, 0x69, 0x5A, 0xDB, 0xF4, 0xE2, 0xBD, 0xEC, 0x12, 0x67, 0x5C, + 0x2A, 0x9A, 0x90, 0x41, 0x81, 0xD2, 0xBE, 0x96, 0x9C, 0xAD, 0x13, 0xAE, 0x9D, 0x40, 0xB8, 0x87, 0xE5, 0xE9, 0x5F, + 0x1B, 0xC4, 0x55, 0xD2, 0x83, 0x3D, 0x8C, 0x2C, 0xF5, 0x33, 0x9A, 0x0C, 0xC9, 0xD2, 0xBF, 0x01, 0xCD, 0x6A, 0xF3, + 0x57, 0x3E, 0x97, 0x0F, 0x53, 0x43, 0x77, 0x45, 0xB7, 0xE9, 0xC5, 0x7E, 0x99, 0xC1, 0x29, 0xE8, 0xCB, 0xA9, 0x54, + 0xD0, 0xD4, 0x21, 0xC4, 0x5D, 0x2B, 0xD5, 0xE3, 0x9A, 0xC9, 0x61, 0xDA, 0x3B, 0x70, 0x35, 0x35, 0x21, 0xD1, 0xAD, + 0xF3, 0x3F, 0x4E, 0xF5, 0xF9, 0x87, 0x06, 0x3E, 0x6C, 0x6D, 0xFC, 0xCF, 0x76, 0xA5, 0x4F, 0x74, 0xD8, 0xD5, 0x21, + 0xC4, 0x20, 0x62, 0xBE, 0xE7, 0xC4, 0x3A, 0xD6, 0xC2, 0x24, 0x6F, 0x80, 0xA9, 0xEF, 0x16, 0x3C, 0x3F, 0x6F, 0xF2, + 0x13, 0x8A, 0xF2, 0xFC, 0x39, 0x6D, 0x51, 0x93, 0x3D, 0x4A, 0xF5, 0x35, 0x40, 0xD0, 0x67, 0x52, 0x1C, 0x77, 0xAF, + 0x8F, 0xCC, 0xEA, 0x73, 0xF1, 0x0A, 0xB7, 0x73, 0xD8, 0xC3, 0xD4, 0xB5, 0x23, 0x53, 0x56, 0x83, 0xF7, 0x28, 0x31, + 0x5F, 0x47, 0xE2, 0x45, 0x4B, 0x61, 0x29, 0x47, 0xD0, 0xEA, 0xCB, 0xEA, 0x7B, 0xCC, 0xF2, 0xB8, 0x6D, 0x39, 0xE9, + 0x5F, 0xAD, 0xC3, 0x09, 0xA9, 0xFC, 0x5D, 0x4E, 0xB9, 0xA1, 0x6D, 0x6B, 0x8C, 0x71, 0x5E, 0xB6, 0x1E, 0x87, 0x29, + 0xD9, 0x0A, 0xC5, 0xE4, 0x87, 0x0B, 0xD2, 0xBA, 0x6B, 0xD6, 0x54, 0xE0, 0x77, 0xD1, 0xA9, 0x72, 0x0B, 0x94, 0xE2, + 0xBF, 0x2D, 0xE2, 0x7C, 0xC9, 0x28, 0xB5, 0x73, 0xDE, 0xC2, 0x48, 0xA9, 0x14, 0x1B, 0xA4, 0xE9, 0x5F, 0x8E, 0x4E, + 0xA3, 0xAF, 0x88, 0xD0, 0xFA, 0x0A, 0x55, 0x2C, 0x8E, 0x97, 0x48, 0xB5, 0xC2, 0x03, 0x8A, 0xFD, 0xBB, 0x84, 0x70, + 0xFE, 0xCE, 0x92, 0x93, 0x14, 0xEB, 0x5D, 0x8F, 0xD4, 0xE3, 0xE3, 0x15, 0xF3, 0xDC, 0x7F, 0x8F, 0x4A, 0x2E, 0x37, + 0x3D, 0x5C, 0x04, 0x89, 0x74, 0x2B, 0x5C, 0xC9, 0x9C, 0x57, 0x81, 0xC0, 0x14, 0x1C, 0xEB, 0x73, 0x9E, 0xB5, 0x6A, + 0xDA, 0x58, 0xBF, 0xAB, 0x45, 0xC0, 0x5C, 0x57, 0x77, 0x89, 0x38, 0xC5, 0xCA, 0xA9, 0xA3, 0xB7, 0x2F, 0x9F, 0x52, + 0xFE, 0x89, 0x6D, 0xB6, 0xD8, 0xB6, 0x3B, 0x57, 0xA1, 0xC0, 0xD0, 0xF6, 0x39, 0x74, 0xAA, 0x3E, 0xC7, 0x75, 0x4A, + 0xB7, 0x9D, 0x86, 0x5B, 0x41, 0xE6, 0x6A, 0x5D, 0x3B, 0xD7, 0xE7, 0xF8, 0x59, 0x7D, 0x6B, 0x88, 0xAE, 0xBB, 0x9E, + 0xDC, 0x2A, 0x72, 0xD1, 0x34, 0xB5, 0x68, 0x7E, 0x68, 0xD3, 0x15, 0xED, 0x78, 0x93, 0x88, 0xBD, 0x4A, 0x54, 0x57, + 0x91, 0xD9, 0x97, 0x4F, 0x46, 0xCF, 0x37, 0x8A, 0xD8, 0x7A, 0x57, 0xF4, 0x83, 0x84, 0x51, 0xFC, 0x4B, 0x4A, 0xA9, + 0x6A, 0x38, 0x40, 0x1D, 0x2B, 0x1A, 0xB8, 0x88, 0x53, 0x47, 0xA3, 0x4A, 0x6D, 0x8B, 0x20, 0x00, 0x57, 0xC7, 0xE7, + 0x19, 0xD4, 0x69, 0xC5, 0xEA, 0x7A, 0xF8, 0x67, 0x72, 0x94, 0xCB, 0xB8, 0xE2, 0xBF, 0x15, 0xCF, 0xB3, 0x77, 0x5A, + 0x4D, 0x26, 0x7D, 0x16, 0x19, 0xD8, 0xB5, 0xA7, 0x59, 0x97, 0x70, 0x71, 0x5C, 0xD9, 0x06, 0x06, 0x58, 0x8A, 0xC9, + 0xD8, 0xF4, 0xBD, 0xBD, 0x91, 0xD3, 0xDA, 0xDB, 0x08, 0xE1, 0xE9, 0xDA, 0xBF, 0x79, 0xC2, 0x46, 0x38, 0x2C, 0x25, + 0xDF, 0x62, 0x21, 0x57, 0x9A, 0x46, 0x7D, 0xE2, 0x79, 0x92, 0xED, 0x15, 0xF8, 0x3F, 0x17, 0x66, 0x4F, 0x15, 0x8A, + 0xE4, 0x4C, 0xFA, 0x3C, 0x1C, 0xB9, 0x63, 0x73, 0x6F, 0x45, 0xB3, 0xD9, 0x16, 0xE2, 0x2B, 0xF4, 0x3E, 0x09, 0xC1, + 0xFD, 0x5B, 0x0B, 0xED, 0x64, 0x6B, 0x52, 0xBD, 0xE5, 0x62, 0x2B, 0xE8, 0xFC, 0xCB, 0x90, 0xA0, 0x77, 0xAF, 0xCE, + 0x78, 0xCB, 0x1C, 0xF1, 0x99, 0x8A, 0xA7, 0x1E, 0xE7, 0xB9, 0x82, 0x97, 0x2C, 0x2E, 0x6E, 0x5B, 0xDB, 0x88, 0x6C, + 0x3A, 0x76, 0xAF, 0xD1, 0xE8, 0xC9, 0x65, 0xB9, 0x1B, 0x6F, 0x4D, 0x0D, 0x69, 0xD4, 0xE7, 0xAA, 0x33, 0x43, 0xB5, + 0xDF, 0x74, 0x5C, 0x8A, 0xFC, 0xF3, 0x81, 0x69, 0x3C, 0x56, 0x65, 0x3A, 0xEC, 0xF6, 0xEA, 0xD6, 0xE5, 0x82, 0x45, + 0xAB, 0xB8, 0x7C, 0xDD, 0x41, 0x10, 0x0E, 0x86, 0xB1, 0xE2, 0xBC, 0x47, 0xD7, 0xB3, 0xE8, 0x51, 0x5D, 0x19, 0xE8, + 0xE0, 0xE7, 0xCB, 0x4A, 0xE7, 0x98, 0xA3, 0x0C, 0x57, 0xEE, 0xB5, 0xB8, 0x92, 0x09, 0x6E, 0x7F, 0x14, 0xD2, 0x4C, + 0x79, 0x71, 0x5F, 0x33, 0x98, 0xF1, 0x44, 0x52, 0x76, 0x67, 0xAF, 0x42, 0x24, 0x4E, 0x4B, 0x57, 0xE7, 0x19, 0xAE, + 0x7F, 0x2A, 0xCD, 0xA4, 0xCF, 0x6F, 0x0F, 0xA1, 0x2D, 0xAD, 0xA3, 0x48, 0xC3, 0x8A, 0xF0, 0xB0, 0x98, 0x5A, 0xB8, + 0xCA, 0xA7, 0xAB, 0x0A, 0xCA, 0x28, 0xE8, 0xF4, 0xBD, 0x3F, 0x62, 0x82, 0x56, 0xBF, 0x68, 0xE1, 0xAC, 0x95, 0x61, + 0xE0, 0xA7, 0x24, 0x0F, 0x13, 0x72, 0xDD, 0xE6, 0x11, 0x36, 0x8A, 0x8E, 0x2D, 0xCF, 0x61, 0x85, 0xA2, 0xE1, 0x16, + 0x7A, 0x18, 0x3F, 0x79, 0xDC, 0xA9, 0x65, 0x6A, 0xD3, 0xCE, 0x0E, 0x3B, 0xD7, 0xE3, 0x59, 0x56, 0x1E, 0x79, 0x9E, + 0x3B, 0x99, 0xF7, 0x3E, 0x85, 0x56, 0x50, 0x89, 0xD2, 0x79, 0x22, 0xDE, 0xD7, 0x18, 0xC1, 0xC5, 0x7E, 0xD7, 0x98, + 0x62, 0x61, 0x94, 0xE5, 0xB6, 0xD9, 0xD8, 0x54, 0x27, 0xED, 0x26, 0x53, 0xD3, 0xED, 0x4C, 0xF7, 0x7B, 0x88, 0xE3, + 0x35, 0xF8, 0xC6, 0x47, 0x46, 0x79, 0xAE, 0x6B, 0xED, 0x1E, 0xD7, 0x3E, 0x87, 0xDB, 0x72, 0x42, 0xC6, 0xC6, 0xA6, + 0x9B, 0x21, 0x11, 0x8A, 0xFB, 0x6E, 0x3F, 0xCC, 0xD6, 0x17, 0x07, 0x1C, 0x34, 0x1E, 0xA6, 0xF8, 0x07, 0xCD, 0x2B, + 0x96, 0xB4, 0x6B, 0x5F, 0x2A, 0xD4, 0xC8, 0x47, 0x6A, 0xCF, 0x82, 0xF0, 0xEB, 0x01, 0x96, 0x4F, 0x15, 0x3E, 0xA8, + 0xF4, 0x6A, 0x56, 0xE7, 0x9A, 0x8A, 0x1D, 0xA5, 0xDB, 0x19, 0xF5, 0x03, 0x21, 0x1C, 0x03, 0x5F, 0x25, 0xC3, 0x31, + 0x79, 0x9E, 0x7B, 0x3C, 0x44, 0xB6, 0x4C, 0xF6, 0x1D, 0x5E, 0x4A, 0x49, 0x1E, 0x2D, 0x1A, 0xB9, 0xA9, 0x9E, 0x6F, + 0x5E, 0x47, 0xF1, 0xE5, 0x26, 0x8B, 0x11, 0x40, 0xED, 0xDA, 0xB8, 0xE5, 0x53, 0x11, 0x5D, 0x9E, 0x9D, 0x2A, 0x89, + 0x1A, 0x36, 0x9A, 0x73, 0x39, 0x19, 0x5A, 0xF4, 0xF0, 0x19, 0x15, 0x6C, 0x44, 0x93, 0x68, 0xED, 0x8E, 0x25, 0x23, + 0x7B, 0x4F, 0xD3, 0x42, 0x80, 0x48, 0xAF, 0xD5, 0x32, 0x4E, 0x1C, 0x85, 0x04, 0xA5, 0x34, 0x57, 0xD6, 0xAE, 0x68, + 0x48, 0x16, 0x14, 0xC0, 0xC5, 0x7A, 0x99, 0xCE, 0x75, 0x47, 0x2F, 0xA2, 0xE3, 0x16, 0x76, 0xE1, 0xAF, 0x36, 0x67, + 0xBA, 0x34, 0xF2, 0xE0, 0x7A, 0xD7, 0xE0, 0x79, 0xB6, 0x69, 0x57, 0x35, 0xC4, 0xF2, 0xC4, 0xFA, 0x5C, 0x3C, 0x94, + 0x11, 0xBD, 0xA4, 0x69, 0xE2, 0x24, 0x0E, 0xC2, 0xBF, 0x51, 0xE1, 0x2C, 0x96, 0x38, 0x2A, 0x3E, 0xDA, 0xA2, 0x2E, + 0x78, 0x9E, 0x67, 0x64, 0x1A, 0x81, 0xDE, 0xFE, 0x5A, 0xD7, 0xC6, 0xF1, 0xCF, 0x10, 0x3C, 0x55, 0x5F, 0xAB, 0xD2, + 0x67, 0xB1, 0x81, 0xF7, 0x55, 0xD9, 0xA7, 0xA3, 0xD8, 0x88, 0x61, 0xF3, 0x18, 0x76, 0xAF, 0xA2, 0xE0, 0xDC, 0xB2, + 0x39, 0x76, 0x11, 0xE2, 0x6A, 0xEE, 0x75, 0xD4, 0xC4, 0x73, 0xCA, 0xC8, 0x63, 0xC4, 0x6E, 0xAF, 0x42, 0x81, 0x90, + 0x0D, 0x7E, 0x7D, 0x9B, 0x62, 0xA7, 0x9E, 0xE7, 0x0A, 0x9C, 0x75, 0x49, 0x9E, 0xDE, 0x1E, 0x6A, 0x95, 0x3B, 0x9A, + 0xF7, 0x91, 0x88, 0x2D, 0x16, 0x25, 0x1C, 0x91, 0x5F, 0x67, 0xC5, 0xF8, 0xF8, 0xE5, 0x39, 0x54, 0x70, 0xB4, 0xF7, + 0x68, 0xDF, 0x07, 0x2F, 0x69, 0x3E, 0x66, 0x59, 0xD2, 0x2D, 0x44, 0x16, 0x6D, 0x33, 0x0C, 0x12, 0x2B, 0x8B, 0x84, + 0xA8, 0x2C, 0xAF, 0x29, 0xA9, 0x8C, 0xA9, 0xA3, 0x68, 0xF4, 0xE7, 0x5B, 0x9E, 0xA2, 0x8A, 0x3C, 0x86, 0x1D, 0x23, + 0xDA, 0xBE, 0xBA, 0x1C, 0x16, 0xAF, 0xAA, 0x3F, 0x8D, 0x69, 0xE3, 0x4D, 0x0B, 0x6D, 0x29, 0x46, 0x32, 0x2B, 0xD6, + 0xC2, 0xF0, 0x95, 0x2A, 0x7A, 0xC9, 0x1D, 0x90, 0xC6, 0x36, 0x69, 0xC1, 0x69, 0x1C, 0x63, 0xA0, 0xAF, 0x76, 0x18, + 0x6C, 0x26, 0x0A, 0x37, 0x76, 0x3B, 0x29, 0xD6, 0x94, 0x87, 0x4B, 0x2A, 0xA0, 0xC2, 0xD7, 0xCC, 0x67, 0x7C, 0x5F, + 0x47, 0x0B, 0x07, 0x18, 0x33, 0xD6, 0xC3, 0x41, 0xC9, 0xEA, 0x54, 0x22, 0x49, 0xDF, 0x00, 0x1A, 0xFC, 0x67, 0x32, + 0xCE, 0x31, 0x39, 0xAD, 0x5E, 0x58, 0x1F, 0x43, 0x87, 0x71, 0x82, 0x36, 0x34, 0x9D, 0x2F, 0x18, 0x77, 0x15, 0xF7, + 0x5C, 0x29, 0xC2, 0xDC, 0x96, 0xAD, 0x59, 0x1B, 0xCB, 0x17, 0x7D, 0x11, 0xA1, 0x76, 0xEB, 0x14, 0x7B, 0x12, 0xBD, + 0x1E, 0x2D, 0xE2, 0x6A, 0x58, 0x0A, 0x0E, 0x85, 0x17, 0xA9, 0xDB, 0x83, 0x8B, 0x93, 0xBB, 0x19, 0xA5, 0x58, 0x34, + 0xF3, 0x79, 0x8C, 0x38, 0xAF, 0xCF, 0xB8, 0x5F, 0x27, 0xAB, 0x9A, 0xE2, 0xFE, 0xB1, 0x57, 0x63, 0xDB, 0x96, 0x25, + 0x42, 0x36, 0x46, 0xAD, 0xF9, 0x11, 0x44, 0x22, 0x4E, 0xB5, 0xF5, 0xBC, 0x6B, 0x9F, 0xC3, 0x01, 0x86, 0x58, 0x4A, + 0x0F, 0x5D, 0x8E, 0x9C, 0x17, 0xBC, 0xF9, 0x99, 0x63, 0x44, 0xB1, 0xD8, 0xA6, 0x79, 0x05, 0x79, 0x9C, 0x0F, 0x94, + 0xAC, 0x3D, 0x29, 0x63, 0xF1, 0x1E, 0xA7, 0xA7, 0x53, 0x11, 0x77, 0xCA, 0x87, 0x98, 0x5A, 0xF2, 0xF8, 0x28, 0x19, + 0x50, 0x6B, 0xE6, 0x73, 0x1C, 0x4D, 0x4E, 0x23, 0xCE, 0x95, 0x38, 0xEB, 0x14, 0xCF, 0x5E, 0x84, 0xD5, 0x1A, 0x77, + 0x34, 0x75, 0x04, 0x09, 0x12, 0x5B, 0x20, 0xE4, 0xD7, 0xB7, 0xC7, 0x39, 0x8A, 0xC2, 0xE1, 0xA9, 0xE5, 0x98, 0x7D, + 0xDE, 0x87, 0x66, 0x06, 0x5C, 0xD2, 0xE7, 0x67, 0x94, 0xC7, 0x72, 0x9E, 0xD5, 0xFA, 0x85, 0x5E, 0x3A, 0xC3, 0xA5, + 0xA3, 0x3F, 0x8B, 0xE9, 0x52, 0x64, 0x9F, 0x6A, 0x1D, 0xAB, 0xE7, 0xF1, 0xBE, 0x20, 0xC5, 0x2F, 0x75, 0x9E, 0xAD, + 0x0A, 0x22, 0x19, 0xA4, 0x7E, 0x00, 0x35, 0xF0, 0xD9, 0x8F, 0x18, 0x62, 0xB1, 0x4D, 0xA8, 0x1E, 0xD5, 0x08, 0x46, + 0x3B, 0x93, 0x5B, 0x58, 0xCB, 0x33, 0x0C, 0x83, 0x5E, 0x56, 0x1B, 0x2B, 0xC7, 0x66, 0x75, 0x2F, 0x2B, 0x9E, 0x9C, + 0x31, 0x11, 0x82, 0x37, 0xB4, 0xFD, 0x29, 0x23, 0x01, 0x9C, 0x0A, 0xFD, 0x47, 0x23, 0xE1, 0x3A, 0x38, 0x28, 0xAA, + 0x95, 0x8D, 0x3E, 0xB8, 0xE5, 0xA2, 0x2D, 0x5C, 0x4A, 0x91, 0x2E, 0xC4, 0xEB, 0x58, 0xF1, 0x27, 0x17, 0xD0, 0xC0, + 0x52, 0x74, 0x68, 0x3D, 0x4F, 0x47, 0x09, 0x07, 0x27, 0x76, 0x43, 0x67, 0x65, 0x25, 0xD4, 0xBB, 0x98, 0x71, 0x5F, + 0x98, 0x65, 0xB9, 0x7E, 0x2F, 0x3F, 0xC5, 0xFB, 0x4A, 0x9B, 0x1E, 0xEC, 0x6B, 0xC6, 0x94, 0x6C, 0x8D, 0xFF, 0x00, + 0x2E, 0x3B, 0x3B, 0x7C, 0x0C, 0x67, 0x15, 0xFA, 0x96, 0x3B, 0x1B, 0x85, 0xE1, 0xBC, 0x0F, 0x24, 0x7E, 0x2B, 0x17, + 0x42, 0x52, 0xAD, 0x2B, 0x95, 0xEC, 0x6C, 0xDE, 0xF2, 0xE7, 0x7B, 0x0F, 0x96, 0xBF, 0x2D, 0xCA, 0x30, 0x58, 0x8E, + 0x22, 0xCC, 0x3D, 0xBD, 0x5F, 0x86, 0xE7, 0xBA, 0xAB, 0xAA, 0x50, 0xB2, 0x35, 0x6F, 0xB1, 0x14, 0x42, 0x08, 0xC7, + 0x3D, 0x2B, 0xEA, 0x38, 0xCF, 0x3E, 0x86, 0x03, 0x0C, 0xB2, 0xFC, 0x2E, 0xEF, 0x4D, 0x0E, 0xAC, 0x15, 0xE4, 0xF9, + 0xE4, 0x5B, 0xD2, 0xEC, 0xD6, 0xDA, 0xD8, 0xCF, 0x20, 0xF9, 0x88, 0xA8, 0xE1, 0x8C, 0x0C, 0x32, 0x4C, 0xBE, 0x58, + 0xFC, 0x4F, 0xC4, 0xD1, 0xE8, 0xCA, 0xBB, 0xA9, 0x35, 0x14, 0x3F, 0x4E, 0xB5, 0x37, 0x77, 0x86, 0x67, 0x1F, 0x2A, + 0x9A, 0xF9, 0xBC, 0x86, 0x85, 0x4C, 0xFF, 0x00, 0x36, 0x96, 0x3A, 0xB7, 0xC1, 0x16, 0x7A, 0xDE, 0xD9, 0x52, 0x82, + 0x8A, 0x3C, 0x76, 0x1D, 0x26, 0x53, 0xD8, 0xD7, 0xB8, 0xB8, 0x37, 0x1D, 0x27, 0xAD, 0xCF, 0xE3, 0x7A, 0x78, 0xD8, + 0x97, 0xED, 0xB4, 0x57, 0x38, 0xC8, 0xAF, 0x43, 0x0F, 0xC0, 0x55, 0xA6, 0xFD, 0xF3, 0xBA, 0x18, 0xF4, 0x6A, 0xDA, + 0x69, 0x11, 0xA6, 0x0B, 0x0A, 0xFA, 0x9C, 0x17, 0x05, 0xE1, 0x70, 0xDE, 0xF5, 0x43, 0xAA, 0x18, 0xD6, 0xF6, 0x34, + 0x11, 0x20, 0x81, 0x78, 0x03, 0x35, 0xE9, 0xD7, 0xCC, 0x72, 0xDC, 0xA6, 0x1A, 0x5A, 0xE8, 0xED, 0xA3, 0x29, 0xD4, + 0x64, 0x53, 0x5C, 0xB3, 0x9D, 0xB1, 0x8A, 0xFC, 0xCF, 0x3E, 0xE3, 0xAA, 0xB8, 0xA6, 0xE9, 0x61, 0xCF, 0x73, 0x0B, + 0x49, 0x47, 0x56, 0x4D, 0x61, 0xA6, 0x4B, 0x3B, 0x86, 0x90, 0x1C, 0x57, 0x95, 0x92, 0xF0, 0xCE, 0x2F, 0x35, 0xAA, + 0xAA, 0xE2, 0x36, 0x3D, 0x4F, 0xAD, 0xC6, 0x0A, 0xC8, 0xDB, 0x11, 0xC3, 0x67, 0x16, 0x06, 0x33, 0x5F, 0xA2, 0x63, + 0x33, 0x0C, 0x0F, 0x0D, 0xE1, 0x79, 0x61, 0x6E, 0x63, 0x5A, 0x12, 0x95, 0x69, 0x6A, 0x57, 0x86, 0xDE, 0x5B, 0xE9, + 0xFA, 0x1D, 0xB9, 0xAF, 0xCA, 0x23, 0xF5, 0xDE, 0x28, 0xC6, 0xDD, 0xDF, 0x92, 0xE7, 0xBF, 0x4E, 0xAC, 0x68, 0x44, + 0xDB, 0x31, 0xC7, 0x63, 0x6F, 0xB5, 0x47, 0xCD, 0x8A, 0xFD, 0x07, 0x30, 0xC7, 0xE1, 0x38, 0x5F, 0x01, 0xEC, 0xA9, + 0xFC, 0x6D, 0x1A, 0xD0, 0x94, 0xAB, 0xCA, 0xEC, 0x4D, 0x2E, 0xC1, 0xAE, 0x26, 0xF3, 0xE5, 0x07, 0x68, 0xE7, 0x9A, + 0xF8, 0xAE, 0x19, 0xCA, 0x2A, 0xE6, 0xF8, 0xB7, 0x98, 0x62, 0xFE, 0x15, 0xAE, 0xA7, 0xB2, 0xF1, 0x0A, 0x9C, 0x79, + 0x22, 0x5C, 0xBB, 0x06, 0xE2, 0x65, 0xB7, 0x84, 0x7C, 0xA3, 0x83, 0x8A, 0x5C, 0x57, 0x9C, 0xCF, 0x37, 0xC5, 0x47, + 0x2D, 0xC1, 0xFC, 0x2B, 0x4D, 0x0F, 0x43, 0x06, 0xD5, 0x38, 0xF3, 0xC8, 0xD0, 0x78, 0x96, 0xCA, 0xD0, 0x44, 0x83, + 0xE7, 0x6A, 0xF4, 0xF3, 0x7C, 0x5D, 0x2E, 0x19, 0xCA, 0x56, 0x12, 0x8F, 0xF1, 0x24, 0x8E, 0xDC, 0x3C, 0xDD, 0x6A, + 0x9C, 0xCF, 0x63, 0xC8, 0xE3, 0xBB, 0x8B, 0xB0, 0x15, 0xF7, 0xB5, 0x3C, 0x44, 0xC2, 0x25, 0xA2, 0x3F, 0x8C, 0xE9, + 0x50, 0x91, 0x30, 0xBD, 0x1F, 0xC2, 0xB5, 0xE2, 0xE2, 0xFC, 0x4A, 0x5B, 0x53, 0x47, 0xA9, 0x47, 0x0F, 0xDC, 0x70, + 0x9E, 0x69, 0x38, 0x55, 0x35, 0xF2, 0x78, 0xCE, 0x34, 0xCC, 0x71, 0x8F, 0x96, 0x92, 0x67, 0xB1, 0x42, 0x9C, 0x23, + 0xB9, 0x62, 0xDB, 0x4F, 0xB8, 0x9C, 0x82, 0xC0, 0xE2, 0xB8, 0xB0, 0xF9, 0x26, 0x6B, 0x9A, 0xCE, 0xF5, 0x2F, 0x63, + 0xD2, 0x86, 0x26, 0x10, 0xD8, 0xDA, 0xB2, 0xD2, 0x62, 0x84, 0x06, 0x93, 0x15, 0xF7, 0xB9, 0x5F, 0x07, 0xE1, 0x32, + 0xE8, 0xFB, 0x5C, 0x4B, 0xD4, 0xD9, 0x63, 0x65, 0x3D, 0x22, 0x59, 0x9A, 0x78, 0xE2, 0x5D, 0x91, 0x01, 0x9F, 0x6A, + 0xE4, 0xCF, 0x78, 0xDF, 0x0D, 0x80, 0x83, 0xA1, 0x83, 0xDF, 0xC8, 0xF4, 0xB0, 0xD4, 0xE5, 0x27, 0x79, 0x0D, 0xB4, + 0xB1, 0x9A, 0xF2, 0x4D, 0xCE, 0x0E, 0xDA, 0xF8, 0x5C, 0x06, 0x53, 0x98, 0x71, 0x16, 0x23, 0xDA, 0xD7, 0xBF, 0x29, + 0xED, 0x47, 0x11, 0x0A, 0x4A, 0xC8, 0xD9, 0xD9, 0x0D, 0x8C, 0x3B, 0x54, 0x0D, 0xD5, 0xF7, 0x78, 0xDC, 0xCB, 0x01, + 0xC2, 0xF8, 0x4F, 0x67, 0x4A, 0xCE, 0x66, 0xF4, 0x25, 0x3A, 0xF2, 0xBB, 0x1B, 0x65, 0x65, 0x2D, 0xEC, 0xDE, 0x64, + 0x83, 0x09, 0x5F, 0x03, 0x96, 0x65, 0xB8, 0xCE, 0x25, 0xC6, 0x7D, 0x67, 0x13, 0xA4, 0x11, 0xED, 0x2A, 0xF1, 0xA3, + 0x1B, 0x2D, 0xCD, 0x2B, 0x96, 0x58, 0x90, 0x5B, 0xC0, 0x39, 0xE9, 0xC5, 0x7B, 0x7C, 0x51, 0xC4, 0x54, 0xF0, 0x34, + 0x56, 0x5B, 0x97, 0xFC, 0x4F, 0x4D, 0x0E, 0xCC, 0x22, 0x72, 0x7C, 0xF3, 0x2D, 0xE9, 0xF6, 0x49, 0x69, 0x01, 0x9E, + 0x60, 0x37, 0x9F, 0x5A, 0x59, 0x1E, 0x5F, 0x47, 0x87, 0xB0, 0x52, 0xCC, 0x31, 0x9F, 0x1B, 0xD5, 0x5C, 0xF4, 0x3D, + 0xBB, 0xAB, 0x2E, 0x48, 0xEC, 0x4B, 0x61, 0x68, 0xD7, 0x97, 0x26, 0xE2, 0x51, 0x88, 0xD6, 0xBC, 0x1C, 0x9F, 0x0D, + 0x57, 0x88, 0xB3, 0x09, 0x66, 0x38, 0xBD, 0x29, 0xC7, 0x54, 0x7A, 0xCA, 0xB2, 0xA3, 0x1E, 0x48, 0xEE, 0x79, 0x3C, + 0x3A, 0x1A, 0x77, 0x35, 0xFA, 0x4A, 0xF0, 0xEA, 0x8A, 0xDE, 0x47, 0xF1, 0x85, 0x3C, 0xC1, 0x97, 0x60, 0xD1, 0xE0, + 0x5F, 0xBC, 0x45, 0x75, 0x52, 0xE0, 0x8C, 0xBA, 0x8E, 0xB5, 0x24, 0x8E, 0xEA, 0x78, 0xE9, 0xBD, 0x8B, 0xD0, 0xDA, + 0xDA, 0x42, 0x3B, 0x57, 0x57, 0xB3, 0xC8, 0xF2, 0xD5, 0x76, 0xD6, 0x87, 0x7D, 0x2A, 0xB5, 0x66, 0x4A, 0x6E, 0xA2, + 0x41, 0x88, 0xD6, 0xBC, 0x1C, 0xC7, 0xC4, 0x1C, 0x1E, 0x15, 0x38, 0x61, 0xA3, 0xA9, 0xEA, 0xD0, 0xA3, 0x29, 0x7C, + 0x43, 0x55, 0x6E, 0xAE, 0x9B, 0x0A, 0x08, 0x15, 0xF0, 0xB8, 0x9C, 0xDF, 0x38, 0xCF, 0x67, 0xCB, 0x4D, 0x34, 0x99, + 0xEC, 0xD1, 0x74, 0xE9, 0x2D, 0x4D, 0x4B, 0x1D, 0x1D, 0x53, 0x12, 0x4E, 0x7F, 0x3A, 0xFA, 0x4C, 0xA3, 0x82, 0x69, + 0x61, 0xD7, 0xD6, 0x31, 0xF2, 0xFB, 0xCE, 0xB5, 0x8E, 0x6F, 0x48, 0x97, 0x25, 0x9D, 0x22, 0x5F, 0x2A, 0x05, 0xC9, + 0xF6, 0xA3, 0x3C, 0xE3, 0x4C, 0x36, 0x02, 0x1F, 0x55, 0xCB, 0xD5, 0xDE, 0xDA, 0x1E, 0x86, 0x1A, 0x9B, 0x93, 0xE6, + 0x99, 0x25, 0x86, 0x99, 0x25, 0xC3, 0xF9, 0xB3, 0xE4, 0x2F, 0x5E, 0x6B, 0xE7, 0x72, 0x7E, 0x1B, 0xC5, 0xE6, 0xF5, + 0x7E, 0xB9, 0x8F, 0x76, 0x8E, 0xFA, 0x9E, 0xB2, 0xC5, 0xC6, 0x9A, 0xE5, 0x89, 0xA1, 0x3C, 0x8B, 0x12, 0x88, 0x2D, + 0xD7, 0x9E, 0x9C, 0x57, 0xA7, 0xC4, 0x1C, 0x55, 0x47, 0x03, 0x4F, 0xFB, 0x3F, 0x2C, 0x57, 0x96, 0xDA, 0x1D, 0xB8, + 0x58, 0xB9, 0x3E, 0x79, 0x96, 0x74, 0xED, 0x3D, 0x61, 0x5F, 0xB4, 0xDC, 0xF5, 0xEB, 0xCD, 0x73, 0x64, 0x39, 0x1D, + 0x3C, 0xBA, 0x9B, 0xCC, 0xF3, 0x37, 0xEF, 0x6F, 0xA9, 0xE9, 0x7D, 0x65, 0xCD, 0xF2, 0x40, 0x99, 0x21, 0x93, 0x50, + 0xB8, 0x00, 0x02, 0x22, 0x5A, 0xF1, 0x2B, 0xD7, 0xC5, 0xF1, 0x86, 0x61, 0xC9, 0x0D, 0x28, 0xC7, 0xEE, 0x3D, 0x4A, + 0x35, 0x23, 0x87, 0x85, 0xFA, 0x97, 0xA7, 0x50, 0x8A, 0xB6, 0x76, 0xC3, 0x93, 0xC1, 0x22, 0xBB, 0x38, 0x8B, 0x37, + 0x8E, 0x1E, 0x9C, 0x72, 0x5C, 0xAF, 0x77, 0xA3, 0x68, 0xEF, 0xC2, 0xBB, 0xBF, 0x69, 0x33, 0xC6, 0xA2, 0x5B, 0xD3, + 0xFC, 0x26, 0xBD, 0x19, 0x66, 0xBC, 0x43, 0x3D, 0x2C, 0xCF, 0xE3, 0x9A, 0x4E, 0x92, 0x2D, 0x45, 0x69, 0x7B, 0x27, + 0x63, 0x58, 0xBC, 0x37, 0x11, 0x62, 0xF4, 0x77, 0x3D, 0x1A, 0x55, 0xA9, 0x44, 0xBD, 0x6F, 0xA3, 0xCE, 0xFC, 0xB9, + 0x35, 0xD3, 0x43, 0x81, 0xF3, 0x2C, 0x53, 0xBD, 0x79, 0x9D, 0xF4, 0xF1, 0xD0, 0x8E, 0xC6, 0x9D, 0xB6, 0x93, 0x6F, + 0x16, 0x0C, 0x84, 0x1A, 0xFA, 0x0C, 0x37, 0x06, 0xE5, 0x99, 0x7A, 0xE7, 0xC4, 0xCD, 0x36, 0x75, 0xC3, 0x1B, 0x39, + 0x7C, 0x25, 0xBF, 0x3A, 0xDA, 0x01, 0xB6, 0x25, 0x04, 0xD4, 0x63, 0xB8, 0xC3, 0x2A, 0xCA, 0x63, 0xC9, 0x84, 0x8A, + 0x6C, 0xF4, 0x68, 0x42, 0x73, 0xD6, 0x42, 0x22, 0x5D, 0x5E, 0x36, 0x15, 0x48, 0x5A, 0xF8, 0x9A, 0xF9, 0x96, 0x75, + 0xC4, 0x75, 0x39, 0x29, 0xA6, 0xA2, 0xCF, 0x66, 0x8C, 0xA9, 0xD1, 0x5A, 0x9A, 0x96, 0x7A, 0x64, 0x36, 0xCB, 0xE6, + 0x4E, 0x41, 0x6A, 0xFA, 0x4C, 0xBF, 0x86, 0xB2, 0xFC, 0x92, 0x1F, 0x59, 0xC7, 0xC9, 0x39, 0x1D, 0x51, 0xC5, 0xCE, + 0xA3, 0xB4, 0x09, 0x1E, 0x59, 0x2E, 0x1B, 0xC9, 0xB7, 0x4C, 0x2F, 0x4E, 0x2B, 0xE7, 0xF3, 0x7E, 0x2C, 0xC5, 0xE6, + 0xF3, 0xFA, 0x9E, 0x59, 0x1B, 0x47, 0x6D, 0x0F, 0x5B, 0x0F, 0x08, 0xD3, 0x5C, 0xD3, 0x34, 0x2C, 0xEC, 0x22, 0xB4, + 0x4F, 0x3A, 0x72, 0x0B, 0xD7, 0xA5, 0x97, 0x64, 0xB8, 0x2E, 0x1E, 0xA3, 0xF5, 0xDC, 0xC2, 0x57, 0xA9, 0xBE, 0xA7, + 0x6C, 0x71, 0x32, 0xAA, 0xF9, 0x61, 0xB1, 0x22, 0x43, 0x36, 0xA1, 0x36, 0x00, 0x2B, 0x10, 0xAF, 0x9D, 0xAD, 0x88, + 0xC7, 0xF1, 0x86, 0x2F, 0x92, 0x1E, 0xED, 0x15, 0xF9, 0x1E, 0xB5, 0x19, 0xC7, 0x0F, 0x1D, 0x77, 0x2F, 0xB8, 0x4B, + 0x68, 0xC5, 0xB5, 0xB0, 0xCB, 0x9E, 0x09, 0x15, 0xE8, 0xE6, 0xF9, 0xCD, 0x0C, 0x92, 0x82, 0xCB, 0x32, 0xC5, 0x7A, + 0x8F, 0x46, 0xD1, 0xDB, 0x87, 0xBD, 0x47, 0xCF, 0x3D, 0x8B, 0x56, 0x96, 0x89, 0x65, 0x0F, 0x9F, 0x37, 0x32, 0xB7, + 0x41, 0x58, 0x60, 0x30, 0xD4, 0x38, 0x67, 0x08, 0xF3, 0x0C, 0x73, 0xBD, 0x69, 0x6C, 0x8F, 0x4A, 0x35, 0x9D, 0x69, + 0x72, 0x47, 0x63, 0xC8, 0xE3, 0xBD, 0x8F, 0xB4, 0x5F, 0xA5, 0x7D, 0xB4, 0xFC, 0x4A, 0xC2, 0x2F, 0x86, 0x97, 0xE0, + 0x7F, 0x19, 0xD2, 0xA1, 0x2E, 0xE4, 0xE9, 0x7A, 0xC7, 0xEE, 0x45, 0xFA, 0x57, 0x9F, 0x5B, 0xC4, 0xBA, 0x8F, 0x4A, + 0x34, 0xBF, 0x03, 0xD3, 0xA3, 0x41, 0x75, 0x64, 0xE9, 0x25, 0xE4, 0xBC, 0x24, 0x64, 0x57, 0x91, 0x5B, 0x8B, 0x33, + 0xEC, 0x7F, 0xBB, 0x46, 0x0D, 0x1E, 0xA5, 0x18, 0xD2, 0x8E, 0xEC, 0xB5, 0x06, 0x99, 0x79, 0x39, 0xCC, 0x84, 0x81, + 0x58, 0xD3, 0xE1, 0xBC, 0xF7, 0x34, 0x77, 0xC4, 0x4D, 0xA4, 0x7A, 0x34, 0xF1, 0x54, 0xA1, 0xB1, 0xA5, 0x6D, 0xA5, + 0x5B, 0x40, 0x37, 0x4C, 0xC0, 0x9A, 0xF7, 0x30, 0xFC, 0x27, 0x94, 0xE5, 0x4B, 0xDA, 0x63, 0x2A, 0x26, 0xD1, 0xD9, + 0x0C, 0x64, 0xE7, 0xA4, 0x4B, 0x26, 0xE2, 0x38, 0xC6, 0xCB, 0x68, 0xF2, 0x7D, 0x85, 0x71, 0x66, 0x1C, 0x77, 0x86, + 0xC2, 0xAF, 0x61, 0x96, 0x53, 0xBB, 0xF2, 0x3D, 0x2A, 0x14, 0x9C, 0xB5, 0x9B, 0x25, 0xB6, 0xD3, 0xEE, 0x6E, 0xDB, + 0x7C, 0xC4, 0xAA, 0xD7, 0x87, 0x87, 0xC8, 0xB3, 0x6C, 0xFE, 0xA7, 0xB6, 0xC7, 0x4B, 0x96, 0x07, 0xAB, 0x0C, 0x4D, + 0x3A, 0x4A, 0xD1, 0xDC, 0xD1, 0x51, 0x6D, 0x64, 0x9B, 0x22, 0x50, 0xCF, 0x5E, 0xC6, 0x2B, 0x38, 0xCA, 0x78, 0x66, + 0x97, 0xB1, 0xC2, 0x25, 0x2A, 0x87, 0x65, 0x17, 0x3A, 0xCE, 0xF2, 0xD8, 0x92, 0xD6, 0xC2, 0x7B, 0xC7, 0xF3, 0x6E, + 0x32, 0xA9, 0xE9, 0x5F, 0x3F, 0x84, 0xCA, 0x33, 0x0E, 0x22, 0xAB, 0xF5, 0xBC, 0xC6, 0x5C, 0xB4, 0xD6, 0xB6, 0x67, + 0xAD, 0x0C, 0x44, 0x28, 0xAE, 0x58, 0x6E, 0x5E, 0x66, 0x08, 0x05, 0xB5, 0x9A, 0x64, 0xF4, 0x24, 0x57, 0x46, 0x6B, + 0xC4, 0x94, 0xF0, 0xF1, 0xFE, 0xCB, 0xC9, 0x63, 0x77, 0xB3, 0x68, 0xEF, 0xC3, 0xA6, 0xFD, 0xFA, 0x8C, 0xB7, 0x6B, + 0x67, 0x1D, 0x94, 0x7E, 0x74, 0xFF, 0x00, 0x34, 0xA7, 0xA0, 0xA5, 0x84, 0xC1, 0x61, 0x38, 0x66, 0x83, 0xC7, 0x66, + 0x0F, 0x9A, 0xB3, 0xD9, 0x1E, 0x84, 0x2B, 0xCA, 0xB3, 0xE5, 0x86, 0xC5, 0x8B, 0x4B, 0x47, 0xBA, 0x93, 0xED, 0x37, + 0x3F, 0x2C, 0x63, 0x90, 0x0D, 0x79, 0xB8, 0x2C, 0x25, 0x7C, 0xF6, 0xBB, 0xCD, 0x33, 0x47, 0xCB, 0x46, 0x3A, 0xA4, + 0xCF, 0x4E, 0x35, 0xD5, 0x25, 0xC9, 0x0D, 0xCF, 0xFF, 0xD9, +}; +static constexpr size_t JPEG_96X96_SIZE = sizeof(JPEG_96X96); + +} // namespace esphome::benchmarks From 39a7f4482ae097afbb039ca6bb4a950a0cd02197 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:52:16 -1000 Subject: [PATCH 039/111] [bh1745] Use SUB_SENSOR for the sensors (#20606) --- esphome/components/bh1745/bh1745.h | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h index 06d0987d67d..6463aab38d0 100644 --- a/esphome/components/bh1745/bh1745.h +++ b/esphome/components/bh1745/bh1745.h @@ -22,6 +22,13 @@ enum class AdcGain : uint8_t { }; class BH1745Component : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(red_counts) + SUB_SENSOR(green_counts) + SUB_SENSOR(blue_counts) + SUB_SENSOR(clear_counts) + SUB_SENSOR(illuminance) + SUB_SENSOR(color_temperature) + public: void setup() override; void dump_config() override; @@ -31,25 +38,11 @@ class BH1745Component : public PollingComponent, public i2c::I2CDevice { void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } - void set_red_counts_sensor(sensor::Sensor *red) { this->red_counts_sensor_ = red; } - void set_green_counts_sensor(sensor::Sensor *green) { this->green_counts_sensor_ = green; } - void set_blue_counts_sensor(sensor::Sensor *blue) { this->blue_counts_sensor_ = blue; } - void set_clear_counts_sensor(sensor::Sensor *clear) { this->clear_counts_sensor_ = clear; } - void set_illuminance_sensor(sensor::Sensor *illuminance) { this->illuminance_sensor_ = illuminance; } - void set_color_temperature_sensor(sensor::Sensor *cct) { this->color_temperature_sensor_ = cct; } - /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. void enable_interrupt_output() { this->interrupt_output_ = true; } void set_interrupt_state(bool on); protected: - sensor::Sensor *red_counts_sensor_{nullptr}; - sensor::Sensor *green_counts_sensor_{nullptr}; - sensor::Sensor *blue_counts_sensor_{nullptr}; - sensor::Sensor *clear_counts_sensor_{nullptr}; - sensor::Sensor *illuminance_sensor_{nullptr}; - sensor::Sensor *color_temperature_sensor_{nullptr}; - float glass_attenuation_factor_{1.0f}; float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; From c3c235dea7c2f46611789d507db3bd4231b3924c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:56:58 -1000 Subject: [PATCH 040/111] [pzem6l24] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20609) --- esphome/components/pzem6l24/pzem6l24.cpp | 140 +++++++++++----------- esphome/components/pzem6l24/pzem6l24.h | 141 +++++++---------------- esphome/components/pzem6l24/sensor.py | 42 ++++++- 3 files changed, 150 insertions(+), 173 deletions(-) diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp index 41524eebd27..57331a639c7 100644 --- a/esphome/components/pzem6l24/pzem6l24.cpp +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -172,52 +172,52 @@ void PZEM6L24::publish_(const uint8_t *data) { // every sensor from flash. static constexpr SensorEntry SENSORS[] PROGMEM = { // Voltages (×0.1 V) - {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, - {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_a_sensor_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_sensor_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_sensor_, 4, REG_U16, SCALE_DECI}, // Currents (×0.01 A) - {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, - {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_a_sensor_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_sensor_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_sensor_, 10, REG_U16, SCALE_CENTI}, // Frequency (×0.01 Hz) - {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + {&PZEM6L24::frequency_sensor_, 12, REG_U16, SCALE_CENTI}, // Active powers (×0.1 W, signed) - {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, - {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_a_sensor_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_sensor_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_sensor_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_sensor_, 64, REG_I32, SCALE_DECI}, // Reactive powers (×0.1 var, signed) - {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, - {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_a_sensor_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_sensor_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_sensor_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_sensor_, 68, REG_I32, SCALE_DECI}, // Apparent powers (×0.1 VA, signed) - {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, - {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, - {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_a_sensor_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_sensor_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_sensor_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_sensor_, 72, REG_I32, SCALE_DECI}, // Power factors (×0.01), packed two per register: // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C - {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, - {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, - {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_a_sensor_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_sensor_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_sensor_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_sensor_, 78, REG_U8, SCALE_CENTI}, // Active energies (×0.1 kWh, unsigned) - {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, - {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_a_sensor_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_sensor_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_sensor_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_sensor_, 116, REG_U32, SCALE_DECI}, // Reactive energies (×0.1 kvarh, unsigned) - {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, - {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_a_sensor_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_sensor_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_sensor_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_sensor_, 120, REG_U32, SCALE_DECI}, // Apparent energies (×0.1 kVAh, unsigned) - {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, - {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, - {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_a_sensor_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_sensor_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_sensor_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_sensor_, 124, REG_U32, SCALE_DECI}, }; for (const SensorEntry &flash_entry : SENSORS) { @@ -268,41 +268,41 @@ void PZEM6L24::dump_config() { " Address: 0x%02X", this->address_); LOG_UPDATE_INTERVAL(this); - LOG_SENSOR(" ", "Voltage A", this->voltage_a_); - LOG_SENSOR(" ", "Voltage B", this->voltage_b_); - LOG_SENSOR(" ", "Voltage C", this->voltage_c_); - LOG_SENSOR(" ", "Current A", this->current_a_); - LOG_SENSOR(" ", "Current B", this->current_b_); - LOG_SENSOR(" ", "Current C", this->current_c_); - LOG_SENSOR(" ", "Active Power A", this->active_power_a_); - LOG_SENSOR(" ", "Active Power B", this->active_power_b_); - LOG_SENSOR(" ", "Active Power C", this->active_power_c_); - LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); - LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); - LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); - LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); - LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); - LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); - LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); - LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); - LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); - LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); - LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); - LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); - LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); - LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); - LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); - LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); - LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); - LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); - LOG_SENSOR(" ", "Frequency", this->frequency_); - LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); - LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); - LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); - LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); - LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); - LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); - LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_sensor_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_sensor_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_sensor_); + LOG_SENSOR(" ", "Current A", this->current_a_sensor_); + LOG_SENSOR(" ", "Current B", this->current_b_sensor_); + LOG_SENSOR(" ", "Current C", this->current_c_sensor_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_sensor_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_sensor_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_sensor_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_sensor_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_sensor_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_sensor_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_sensor_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_sensor_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_sensor_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_sensor_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_sensor_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_sensor_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_sensor_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_sensor_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_sensor_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_sensor_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_sensor_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_sensor_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_sensor_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_sensor_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_sensor_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_sensor_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_sensor_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_sensor_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_sensor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_sensor_); } // Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h index bf13f25053e..9d8351f6f76 100644 --- a/esphome/components/pzem6l24/pzem6l24.h +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -36,54 +36,51 @@ constexpr std::array build_reset_pdu(ResetPhase phase) { } class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + protected: + // Ahead of the sensor pointers so the two bytes share the base class's tail padding. + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + public: - // Per-phase sensors; each setter is named after its config key - void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } - void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } - void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } - - void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } - void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } - void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } - - void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } - void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } - void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } - - void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } - void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } - void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } - - void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } - void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } - void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } - - void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } - void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } - void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } - - void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } - void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } - void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } - - void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } - void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } - void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } - - void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } - void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } - void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } - - // Combined sensors - void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } - void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } - void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } - void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } - void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } - void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } - void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } - void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + SUB_SENSOR(voltage_a) + SUB_SENSOR(voltage_b) + SUB_SENSOR(voltage_c) + SUB_SENSOR(current_a) + SUB_SENSOR(current_b) + SUB_SENSOR(current_c) + SUB_SENSOR(active_power_a) + SUB_SENSOR(active_power_b) + SUB_SENSOR(active_power_c) + SUB_SENSOR(reactive_power_a) + SUB_SENSOR(reactive_power_b) + SUB_SENSOR(reactive_power_c) + SUB_SENSOR(apparent_power_a) + SUB_SENSOR(apparent_power_b) + SUB_SENSOR(apparent_power_c) + SUB_SENSOR(power_factor_a) + SUB_SENSOR(power_factor_b) + SUB_SENSOR(power_factor_c) + SUB_SENSOR(active_energy_a) + SUB_SENSOR(active_energy_b) + SUB_SENSOR(active_energy_c) + SUB_SENSOR(reactive_energy_a) + SUB_SENSOR(reactive_energy_b) + SUB_SENSOR(reactive_energy_c) + SUB_SENSOR(apparent_energy_a) + SUB_SENSOR(apparent_energy_b) + SUB_SENSOR(apparent_energy_c) + SUB_SENSOR(frequency) + SUB_SENSOR(total_active_power) + SUB_SENSOR(total_reactive_power) + SUB_SENSOR(total_apparent_power) + SUB_SENSOR(total_power_factor) + SUB_SENSOR(total_active_energy) + SUB_SENSOR(total_reactive_energy) + SUB_SENSOR(total_apparent_energy) + public: // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. void reset_energy(ResetPhase phase_option); @@ -104,58 +101,6 @@ class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevic void request_failed_(std::span request_pdu); void read_failed_(); void read_finished_(); - - // Register reads accepted by the hub but not yet resolved by a terminal callback. - uint8_t reads_outstanding_{0}; - // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. - uint8_t consecutive_failures_{0}; - - // Per-phase sensors - sensor::Sensor *voltage_a_{nullptr}; - sensor::Sensor *voltage_b_{nullptr}; - sensor::Sensor *voltage_c_{nullptr}; - - sensor::Sensor *current_a_{nullptr}; - sensor::Sensor *current_b_{nullptr}; - sensor::Sensor *current_c_{nullptr}; - - sensor::Sensor *active_power_a_{nullptr}; - sensor::Sensor *active_power_b_{nullptr}; - sensor::Sensor *active_power_c_{nullptr}; - - sensor::Sensor *reactive_power_a_{nullptr}; - sensor::Sensor *reactive_power_b_{nullptr}; - sensor::Sensor *reactive_power_c_{nullptr}; - - sensor::Sensor *apparent_power_a_{nullptr}; - sensor::Sensor *apparent_power_b_{nullptr}; - sensor::Sensor *apparent_power_c_{nullptr}; - - sensor::Sensor *power_factor_a_{nullptr}; - sensor::Sensor *power_factor_b_{nullptr}; - sensor::Sensor *power_factor_c_{nullptr}; - - sensor::Sensor *active_energy_a_{nullptr}; - sensor::Sensor *active_energy_b_{nullptr}; - sensor::Sensor *active_energy_c_{nullptr}; - - sensor::Sensor *reactive_energy_a_{nullptr}; - sensor::Sensor *reactive_energy_b_{nullptr}; - sensor::Sensor *reactive_energy_c_{nullptr}; - - sensor::Sensor *apparent_energy_a_{nullptr}; - sensor::Sensor *apparent_energy_b_{nullptr}; - sensor::Sensor *apparent_energy_c_{nullptr}; - - // Combined sensors - sensor::Sensor *frequency_{nullptr}; - sensor::Sensor *total_active_power_{nullptr}; - sensor::Sensor *total_reactive_power_{nullptr}; - sensor::Sensor *total_apparent_power_{nullptr}; - sensor::Sensor *total_power_factor_{nullptr}; - sensor::Sensor *total_active_energy_{nullptr}; - sensor::Sensor *total_reactive_energy_{nullptr}; - sensor::Sensor *total_apparent_energy_{nullptr}; }; } // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index fd0430d85be..5e883acd7e1 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -144,7 +144,7 @@ _COMBINED_QUANTITIES = { CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, } -# Every sensor by config key; the C++ setter is "set__sensor". +# Every sensor by config key; each needs its sensors(...) call in to_code _SENSORS = { f"{name}_{phase}": schema for name, schema in _PHASE_QUANTITIES.items() @@ -192,7 +192,39 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for key in _SENSORS: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors("voltage_a", var.set_voltage_a_sensor) + await sensors("voltage_b", var.set_voltage_b_sensor) + await sensors("voltage_c", var.set_voltage_c_sensor) + await sensors("current_a", var.set_current_a_sensor) + await sensors("current_b", var.set_current_b_sensor) + await sensors("current_c", var.set_current_c_sensor) + await sensors("active_power_a", var.set_active_power_a_sensor) + await sensors("active_power_b", var.set_active_power_b_sensor) + await sensors("active_power_c", var.set_active_power_c_sensor) + await sensors("reactive_power_a", var.set_reactive_power_a_sensor) + await sensors("reactive_power_b", var.set_reactive_power_b_sensor) + await sensors("reactive_power_c", var.set_reactive_power_c_sensor) + await sensors("apparent_power_a", var.set_apparent_power_a_sensor) + await sensors("apparent_power_b", var.set_apparent_power_b_sensor) + await sensors("apparent_power_c", var.set_apparent_power_c_sensor) + await sensors("power_factor_a", var.set_power_factor_a_sensor) + await sensors("power_factor_b", var.set_power_factor_b_sensor) + await sensors("power_factor_c", var.set_power_factor_c_sensor) + await sensors("active_energy_a", var.set_active_energy_a_sensor) + await sensors("active_energy_b", var.set_active_energy_b_sensor) + await sensors("active_energy_c", var.set_active_energy_c_sensor) + await sensors("reactive_energy_a", var.set_reactive_energy_a_sensor) + await sensors("reactive_energy_b", var.set_reactive_energy_b_sensor) + await sensors("reactive_energy_c", var.set_reactive_energy_c_sensor) + await sensors("apparent_energy_a", var.set_apparent_energy_a_sensor) + await sensors("apparent_energy_b", var.set_apparent_energy_b_sensor) + await sensors("apparent_energy_c", var.set_apparent_energy_c_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TOTAL_ACTIVE_POWER, var.set_total_active_power_sensor) + await sensors(CONF_TOTAL_REACTIVE_POWER, var.set_total_reactive_power_sensor) + await sensors(CONF_TOTAL_APPARENT_POWER, var.set_total_apparent_power_sensor) + await sensors(CONF_TOTAL_POWER_FACTOR, var.set_total_power_factor_sensor) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_TOTAL_APPARENT_ENERGY, var.set_total_apparent_energy_sensor) From afedddafe2f81821c946f04b28545655d658d763 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 14:57:01 -1000 Subject: [PATCH 041/111] [systa_bus] Use SUB_SENSOR and explicit sub_sensors calls for the sensors (#20610) --- .../components/systa_bus/sensor/__init__.py | 13 ++++++++----- .../systa_bus/sensor/systa_bus_sensor.h | 18 ++++++------------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index e4aa0684c78..d3223484ffc 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -43,7 +43,7 @@ _PUMP_SPEED_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) -# Sensors per model, keyed by config key; the C++ setter is "set__sensor" +# Sensors per model, keyed by config key MODEL_SENSORS = { CONF_SYSTASOLAR_AQUA: { CONF_TEMPERATURE_TSA: _TEMPERATURE_SCHEMA, @@ -77,10 +77,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - for key in MODEL_SENSORS[config[CONF_MODEL]]: - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + # The schema only lets a model's own keys through, so unconfigured ones are skipped here + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE_TSA, var.set_temperature_tsa_sensor) + await sensors(CONF_TEMPERATURE_TSE, var.set_temperature_tse_sensor) + await sensors(CONF_TEMPERATURE_TWU, var.set_temperature_twu_sensor) + await sensors(CONF_TEMPERATURE_TW2, var.set_temperature_tw2_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.h b/esphome/components/systa_bus/sensor/systa_bus_sensor.h index 3688176d006..1b135c9666f 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.h +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -8,21 +8,15 @@ namespace esphome::systa_bus { class SystaSolarAquaSensor : public SystaBusListener, public Component { + SUB_SENSOR(temperature_tsa) + SUB_SENSOR(temperature_tse) + SUB_SENSOR(temperature_twu) + SUB_SENSOR(temperature_tw2) + SUB_SENSOR(pump_speed) + public: void dump_config() override; - void set_temperature_tsa_sensor(sensor::Sensor *sensor) { this->temperature_tsa_sensor_ = sensor; } - void set_temperature_tse_sensor(sensor::Sensor *sensor) { this->temperature_tse_sensor_ = sensor; } - void set_temperature_twu_sensor(sensor::Sensor *sensor) { this->temperature_twu_sensor_ = sensor; } - void set_temperature_tw2_sensor(sensor::Sensor *sensor) { this->temperature_tw2_sensor_ = sensor; } - void set_pump_speed_sensor(sensor::Sensor *sensor) { this->pump_speed_sensor_ = sensor; } void handle_message(std::span message) override; - - protected: - sensor::Sensor *temperature_tsa_sensor_{nullptr}; - sensor::Sensor *temperature_tse_sensor_{nullptr}; - sensor::Sensor *temperature_twu_sensor_{nullptr}; - sensor::Sensor *temperature_tw2_sensor_{nullptr}; - sensor::Sensor *pump_speed_sensor_{nullptr}; }; } // namespace esphome::systa_bus From 58188305c4e3237120630762c8815b62515d3493 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:09:32 +0200 Subject: [PATCH 042/111] [uart] Keep the swapped UART0 pins on an ESP8266 reload (#20463) --- esphome/components/uart/uart_component_esp8266.cpp | 8 ++++++++ esphome/components/uart/uart_component_esp8266.h | 3 ++- tests/components/uart/test.esp8266-ard.yaml | 6 ++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 0819a1bfb00..9316e90eda1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -24,6 +24,8 @@ static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; __attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool ESP8266UartComponent::serial0_swapped = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + uint32_t ESP8266UartComponent::get_config() { uint32_t config = 0; @@ -111,6 +113,7 @@ void ESP8266UartComponent::setup() { this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); this->hw_serial_->swap(); ESP8266UartComponent::serial0_in_use = true; + ESP8266UartComponent::serial0_swapped = true; } else #endif // USE_ESP8266_UART_SERIAL #ifdef USE_ESP8266_UART_SERIAL1 @@ -133,6 +136,11 @@ void ESP8266UartComponent::load_settings(bool dump_config) { SerialConfig config = static_cast(get_config()); this->hw_serial_->begin(this->baud_rate_, config); this->hw_serial_->setRxBufferSize(this->rx_buffer_size_); +#ifdef USE_ESP8266_UART_SERIAL + // begin() puts UART0 back on GPIO1/GPIO3, so swap it again like setup() did for GPIO15/GPIO13 + if (this->hw_serial_ == &Serial && ESP8266UartComponent::serial0_swapped) + this->hw_serial_->swap(); +#endif // USE_ESP8266_UART_SERIAL } else { this->sw_serial_->setup(this->tx_pin_, this->rx_pin_, this->baud_rate_, this->stop_bits_, this->data_bits_, this->parity_, this->rx_buffer_size_); diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 3d4a2ba85bc..a29373a9d4b 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -92,7 +92,8 @@ class ESP8266UartComponent final : public UARTComponent, public Component { ESP8266SoftwareSerial *sw_serial_{nullptr}; private: - static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static bool serial0_swapped; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; } // namespace esphome::uart diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 4c9d6fd7367..09d23e2925e 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -7,6 +7,8 @@ esphome: - uart.write: id: uart_id data: [0x00, 0x20, 0x42] + - lambda: |- + id(uart_swapped).load_settings(false); uart: - id: uart_id @@ -23,6 +25,10 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_swapped + tx_pin: 15 + rx_pin: 13 + baud_rate: 9600 switch: - platform: uart From 54f228766cc0ed6bd86340fbf903eb0b44f33a99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:12:39 -1000 Subject: [PATCH 043/111] [stcc4] Use SUB_SENSOR for the output sensors (#20605) --- esphome/components/stcc4/stcc4.h | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h index ad25130ec73..1b8e02acccc 100644 --- a/esphome/components/stcc4/stcc4.h +++ b/esphome/components/stcc4/stcc4.h @@ -12,14 +12,15 @@ enum class MeasurementMode : uint8_t { }; class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void update() override; - void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; } - void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } - void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } void set_ambient_pressure_compensation(float pressure_in_hpa); @@ -36,9 +37,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); - sensor::Sensor *co2_sensor_{nullptr}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_source_{nullptr}; sensor::Sensor *humidity_source_{nullptr}; sensor::Sensor *ambient_pressure_source_{nullptr}; From e7a7aa150dab1f645520bf7807f477a9e8400384 Mon Sep 17 00:00:00 2001 From: Gunni Date: Tue, 13 Oct 2026 01:36:57 +0000 Subject: [PATCH 044/111] [yaml_util] Raise a clear error for a non-mapping `defaults:` in a package (#19425) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 15 ++++++- tests/unit_tests/test_substitutions.py | 58 ++++++++++++++++++++++++++ tests/unit_tests/test_yaml_util.py | 23 ++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index f02819a3edb..b5e2ec17e8a 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -191,10 +191,23 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: If the value is already tagged, the new context vars are merged with existing ones, with new vars taking precedence. Returns the value tagged with ConfigContext. Returns the original value if value is not a list/string/dict. + + Raises EsphomeError if a `defaults:` key is present but is not a mapping. """ if isinstance(value, dict) and CONF_DEFAULTS in value: + defaults = value.pop(CONF_DEFAULTS) + if defaults is None: + raise EsphomeError( + f"'{CONF_DEFAULTS}' is empty; " + f"remove the key or give it at least one value" + ) + if not isinstance(defaults, dict): + raise EsphomeError( + f"'{CONF_DEFAULTS}' must be a key to value mapping, " + f"got {type(defaults)}" + ) context_vars = { - **value.pop(CONF_DEFAULTS), + **defaults, **(context_vars or {}), } diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 198b002d43c..b0d3656101e 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -988,6 +988,64 @@ def test_resolve_include_error_shows_expanded_from_when_substituted( assert "expanded from '${device}.yaml'" in msg +def test_resolve_include_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` in an included file surfaces as cv.Invalid naming that file. + + add_context() raises EsphomeError, so resolve_include's existing handler can + wrap it with the filename and document path. A bare TypeError would escape + that handler and reach the user as an unhandled traceback. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + include = yaml_util.IncludeFile(parent, "_network/ota.yaml", loader) + + with pytest.raises(cv.Invalid) as exc_info: + substitutions.resolve_include(include, [], substitutions.ContextVars()) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + +def test_do_packages_pass_nested_empty_defaults_names_file(tmp_path: Path) -> None: + """An empty `defaults:` two package levels deep still names the offending file. + + Mirrors a real layout: the top-level config includes a network package whose + own `packages:` block includes the file with the empty `defaults:`. + """ + parent = tmp_path / "main.yaml" + parent.write_text("") + + def inner_loader(_path: Path) -> dict[str, Any]: + return {"defaults": None, "ota": [{"platform": "esphome"}]} + + def outer_loader(_path: Path) -> dict[str, Any]: + return { + "packages": { + "otas": yaml_util.IncludeFile(parent, "_network/ota.yaml", inner_loader) + } + } + + config = OrderedDict( + { + "packages": { + "wifis": yaml_util.IncludeFile(parent, "network.yaml", outer_loader) + } + } + ) + + with pytest.raises(cv.Invalid) as exc_info: + do_packages_pass(config) + + msg = str(exc_info.value) + assert "_network/ota.yaml" in msg + assert "'defaults' is empty" in msg + + def test_resolve_include_error_no_expanded_from_for_literal_filename( tmp_path: Path, ) -> None: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c30b03467f1..c3276823ecc 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -569,6 +569,29 @@ def test_config_context_defaults_explicit_vars_override() -> None: assert "defaults" not in tagged +def test_config_context_defaults_empty_raises() -> None: + """Test that an empty defaults: key raises EsphomeError, not a bare TypeError. + + A ``defaults:`` block with an empty (or comments-only) body parses as None. + Unpacking it used to fail with ``TypeError: 'NoneType' object is not a + mapping``, which escapes the EsphomeError handler in resolve_include and + reaches the user as an unhandled traceback naming no file. + """ + data = {"defaults": None, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' is empty"): + yaml_util.add_context(data, None) + + +@pytest.mark.parametrize("defaults", ["oops", ["a", "b"], 42]) +def test_config_context_defaults_non_mapping_raises(defaults) -> None: + """Test that a non-mapping defaults: value raises EsphomeError naming the type.""" + data = {"defaults": defaults, "key": "value"} + + with pytest.raises(EsphomeError, match="'defaults' must be a key to value mapping"): + yaml_util.add_context(data, None) + + def test_represent_extend() -> None: """Test that Extend objects are dumped as plain !extend scalars.""" assert yaml_util.dump({"key": Extend("my_id")}) == "key: !extend 'my_id'\n" From 88fdf822cdbc47d127ed686029dc038e17ae0ac3 Mon Sep 17 00:00:00 2001 From: jcianci12 Date: Tue, 13 Oct 2026 11:41:30 +1000 Subject: [PATCH 045/111] [runtime_image] Speed up RGB565 JPEG decoding (#19454) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../runtime_image/image_decoder.cpp | 3 + .../components/runtime_image/image_decoder.h | 4 + .../components/runtime_image/jpeg_decoder.cpp | 90 +++++++++++++++++-- .../components/runtime_image/jpeg_decoder.h | 12 ++- .../components/runtime_image/runtime_image.h | 2 + .../runtime_image/test_decoder_reuse.cpp | 84 +++++++++++++++++ 6 files changed, 188 insertions(+), 7 deletions(-) diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 7056ce35aa8..55e25d76b72 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -29,4 +29,7 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) { } } +uint8_t *ImageDecoder::image_buffer_() const { return this->image_->buffer_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->is_big_endian_; } + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 2a8b3938886..8e0399ac354 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -127,6 +127,10 @@ class ImageDecoder { } protected: + /// The image's pixel buffer, for decoders that write their output straight into it + uint8_t *image_buffer_() const; + bool image_is_big_endian_() const; + double x_scale_ = 1.0; double y_scale_ = 1.0; RuntimeImage *image_; diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 74ced63a4e8..d4783701c4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_IMAGE_JPEG #include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -15,6 +16,67 @@ ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { +bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) { + if (this->x_boundaries_.capacity() < static_cast(width + 1) && !this->x_boundaries_.try_init(width + 1)) { + return false; + } + if (this->y_boundaries_.capacity() < static_cast(height + 1) && !this->y_boundaries_.try_init(height + 1)) { + return false; + } + + this->source_width_ = width; + this->source_height_ = height; + this->x_boundaries_.clear(); + this->y_boundaries_.clear(); + const uint32_t target_width = this->image_->get_buffer_width(); + const uint32_t target_height = this->image_->get_buffer_height(); + // Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source + // pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6; + // the generic draw() path rounds down and keeps the odd ones instead + for (uint32_t x = 0; x <= static_cast(width); x++) { + this->x_boundaries_.push_back(static_cast((x * target_width + width - 1) / width)); + } + for (uint32_t y = 0; y <= static_cast(height); y++) { + this->y_boundaries_.push_back(static_cast((y * target_height + height - 1) / height)); + } + return true; +} + +void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) { + if (pixels == nullptr) { + return; + } + uint8_t *const buffer = this->image_buffer_(); + const uint32_t buffer_width = this->image_->get_buffer_width(); + // JPEGDEC gives little-endian words; a big-endian image stores the high byte first + const uint8_t first = this->image_is_big_endian_() ? 1 : 0; + for (int source_y = 0; source_y < h; source_y++) { + const int absolute_y = y + source_y; + if (absolute_y < 0 || absolute_y >= this->source_height_) { + continue; + } + const uint16_t y1 = this->y_boundaries_[absolute_y]; + const uint16_t y2 = this->y_boundaries_[absolute_y + 1]; + for (int source_x = 0; source_x < w; source_x++) { + const int absolute_x = x + source_x; + if (absolute_x < 0 || absolute_x >= this->source_width_) { + continue; + } + const uint16_t x1 = this->x_boundaries_[absolute_x]; + const uint16_t x2 = this->x_boundaries_[absolute_x + 1]; + const uint8_t *pixel = pixels + (source_y * w + source_x) * 2; + for (uint16_t destination_y = y1; destination_y < y2; destination_y++) { + uint8_t *destination = buffer + (destination_y * buffer_width + x1) * 2; + for (uint16_t destination_x = x1; destination_x < x2; destination_x++) { + destination[0] = pixel[first]; + destination[1] = pixel[1 - first]; + destination += 2; + } + } + } + } +} + /** * @brief Callback method that will be called by the JPEGDEC engine when a chunk * of the image is decoded. @@ -22,7 +84,12 @@ namespace esphome::runtime_image { * @param jpeg The JPEGDRAW object, including the context data. */ static int draw_callback(JPEGDRAW *jpeg) { - ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser; + JpegDecoder *decoder = (JpegDecoder *) jpeg->pUser; + + if (decoder == nullptr) { + ESP_LOGE(TAG, "Decoder pointer is null!"); + return 0; + } // Some very big images take too long to decode, so feed the watchdog on each callback // to avoid crashing if the executing task has a watchdog enabled. @@ -33,6 +100,11 @@ static int draw_callback(JPEGDRAW *jpeg) { #ifdef USE_ESP_IDF } #endif + if (decoder->uses_direct_rgb565()) { + decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight, + reinterpret_cast(jpeg->pPixels)); + return 1; + } size_t position = 0; size_t height = static_cast(jpeg->iHeight); size_t width = static_cast(jpeg->iWidth); @@ -42,10 +114,6 @@ static int draw_callback(JPEGDRAW *jpeg) { auto ba = decode_value(jpeg->pPixels[position++]); Color color(rg[1], rg[0], ba[1], ba[0]); - if (!decoder) { - ESP_LOGE(TAG, "Decoder pointer is null!"); - return 0; - } decoder->draw(jpeg->x + x, jpeg->y + y, 1, 1, color); } } @@ -78,10 +146,20 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp()); this->jpeg_.setUserPointer(this); - this->jpeg_.setPixelType(RGB8888); + this->direct_rgb565_ = this->image_->get_type() == image::IMAGE_TYPE_RGB565 && !this->image_->has_transparency(); + if (this->direct_rgb565_) { + // JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build + // returns little-endian words for it), so the byte order is applied in draw_rgb565 instead + this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN); + } else { + this->jpeg_.setPixelType(RGB8888); + } if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { return DECODE_ERROR_OUT_OF_MEMORY; } + if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) { + return DECODE_ERROR_OUT_OF_MEMORY; + } if (!this->jpeg_.decode(0, 0, 0)) { auto error = this->jpeg_.getLastError(); ESP_LOGE(TAG, "Error while decoding: %d", error); diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index 67c9b77f4d7..f72f69422d0 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -1,8 +1,8 @@ #pragma once - #include "image_decoder.h" #include "runtime_image.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_RUNTIME_IMAGE_JPEG #include @@ -23,8 +23,18 @@ class JpegDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; + bool uses_direct_rgb565() const { return this->direct_rgb565_; } + void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels); + protected: + bool prepare_rgb565_scaling_(int width, int height); + JPEGDEC jpeg_{}; + FixedVector x_boundaries_{}; + FixedVector y_boundaries_{}; + int source_width_{0}; + int source_height_{0}; + bool direct_rgb565_{false}; }; } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 55d5c0ae866..33bad52acf7 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -234,6 +234,8 @@ class RuntimeImage : public image::Image { bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; + + friend class ImageDecoder; }; } // namespace esphome::runtime_image diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp index e62e2a98d92..9f7190980bf 100644 --- a/tests/components/runtime_image/test_decoder_reuse.cpp +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -101,6 +101,9 @@ class TestableRuntimeImage : public RuntimeImage { explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE) : RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {} + TestableRuntimeImage(ImageFormat format, image::ImageType type, bool is_big_endian, int fixed_width, int fixed_height) + : RuntimeImage(format, type, image::TRANSPARENCY_OPAQUE, nullptr, is_big_endian, fixed_width, fixed_height) {} + ImageDecoder *decoder() { return this->decoder_.get(); } }; @@ -373,6 +376,87 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { EXPECT_EQ(img.decoder(), first); EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; } + +// JPEGDEC's colour conversion differs by one step between its SIMD and scalar builds, so each +// channel is checked within one step; the byte order is still exact +static void expect_rgb565_near(uint16_t actual, uint16_t expected, const char *order, size_t pixel) { + EXPECT_NEAR(actual >> 11, expected >> 11, 1) << order << " pixel " << pixel << " red"; + EXPECT_NEAR((actual >> 5) & 0x3F, (expected >> 5) & 0x3F, 1) << order << " pixel " << pixel << " green"; + EXPECT_NEAR(actual & 0x1F, expected & 0x1F, 1) << order << " pixel " << pixel << " blue"; +} + +TEST(RuntimeImageDecoder, JpegDecodesDirectlyToScaledRgb565WithBothByteOrders) { + // Halving the 8x8 gradient keeps its even rows and columns. The words are JPEGDEC's output for those + // pixels, red in the top five bits: a decode of the fixture with PIL gives (51, 7, 30) for source + // pixel (2, 0), which is word 0x3803 here. + static const uint16_t EXPECTED[4 * 4] = { + 0x0000, 0x3803, 0x8008, 0xB80B, 0x0203, 0x3A07, 0x81EC, 0xBA0F, + 0x03E7, 0x3C0B, 0x83F0, 0xBC13, 0x05EC, 0x3E0F, 0x85F4, 0xBE17, + }; + TestableRuntimeImage rgb565_little_endian(JPEG, image::IMAGE_TYPE_RGB565, false, 4, 4); + TestableRuntimeImage rgb565_big_endian(JPEG, image::IMAGE_TYPE_RGB565, true, 4, 4); + + ASSERT_TRUE(decode_all(rgb565_little_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_TRUE(decode_all(rgb565_big_endian, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(rgb565_little_endian.get_width(), 4); + ASSERT_EQ(rgb565_little_endian.get_height(), 4); + ASSERT_EQ(rgb565_big_endian.get_width(), 4); + ASSERT_EQ(rgb565_big_endian.get_height(), 4); + + const uint8_t *little = rgb565_little_endian.get_data_start(); + const uint8_t *big = rgb565_big_endian.get_data_start(); + for (size_t i = 0; i < 4 * 4; i++) { + expect_rgb565_near(little[i * 2] | (little[i * 2 + 1] << 8), EXPECTED[i], "little-endian", i); + expect_rgb565_near((big[i * 2] << 8) | big[i * 2 + 1], EXPECTED[i], "big-endian", i); + } +} + +// Target pixel index each source pixel paints when `source` pixels map onto `target`, the way +// prepare_rgb565_scaling_ rounds: source n covers targets ceil(n * target / source) up to the next start +static uint16_t scaled_boundary(int n, int source, int target) { return (n * target + source - 1) / source; } + +// The RGB565 word at (x, y) of a decoded image, in its own byte order +static uint16_t rgb565_word(const TestableRuntimeImage &img, int x, int y) { + const uint8_t *p = img.get_data_start() + (y * img.get_width() + x) * 2; + return p[0] | (p[1] << 8); +} + +// Every scaled direct decode must equal the unscaled one sampled through the boundary table, +// for shrinking, growing and a ratio that is not a whole number +TEST(RuntimeImageDecoder, JpegDirectRgb565ScalesBySourcePixelSpans) { + TestableRuntimeImage full(JPEG, image::IMAGE_TYPE_RGB565, false, 8, 8); + ASSERT_TRUE(decode_all(full, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + + for (int size : {4, 6, 16}) { + TestableRuntimeImage scaled(JPEG, image::IMAGE_TYPE_RGB565, false, size, size); + ASSERT_TRUE(decode_all(scaled, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(scaled.get_width(), size); + ASSERT_EQ(scaled.get_height(), size); + for (int n = 0; n < 8; n++) { + for (int m = 0; m < 8; m++) { + for (int y = scaled_boundary(m, 8, size); y < scaled_boundary(m + 1, 8, size); y++) { + for (int x = scaled_boundary(n, 8, size); x < scaled_boundary(n + 1, 8, size); x++) { + EXPECT_EQ(rgb565_word(scaled, x, y), rgb565_word(full, n, m)) + << size << "x" << size << " pixel " << x << "," << y; + } + } + } + } + } +} + +// A second decode into the same image reuses the boundary tables and gives the same pixels +TEST(RuntimeImageDecoder, JpegDirectRgb565RepeatsWithReusedTables) { + TestableRuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, false, 6, 6); + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels); +} #endif // USE_RUNTIME_IMAGE_JPEG TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) { From a035c17e0482bde73510d321334d03fcc57b15e8 Mon Sep 17 00:00:00 2001 From: t2bottom <99073885+t2bottom@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:42:24 +0200 Subject: [PATCH 046/111] [gt911] Fix the interrupt pin initialisation after reset (#15021) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../gt911/touchscreen/gt911_touchscreen.cpp | 23 +++++++++++-------- .../gt911/touchscreen/gt911_touchscreen.h | 13 ++--------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 07dd325a2c7..388b8165fd7 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -24,9 +24,13 @@ static const size_t MAX_BUTTONS = 4; // max number of buttons scanned return; \ } +// Reset sequence (Goodix GT911 datasheet): INT held low while RST is released selects address 0x5D, then +// T3 + T4 pass before INT is handed back to the chip as its interrupt output +static constexpr uint32_t RESET_LOW_MS = 10; +static constexpr uint32_t RESET_SETTLE_MS = 5 + 50 + 1; + void GT911Touchscreen::setup() { if (this->reset_pin_ != nullptr) { - // temporarily set the interrupt pin to output to control address selection this->reset_pin_->setup(); this->reset_pin_->digital_write(false); if (this->interrupt_pin_ != nullptr) { @@ -34,20 +38,19 @@ void GT911Touchscreen::setup() { this->interrupt_pin_->pin_mode(gpio::FLAG_OUTPUT); this->interrupt_pin_->digital_write(false); } - delay(2); - this->reset_pin_->digital_write(true); - // wait at least T3+T4 ms as per the datasheet - this->set_timeout(5 + 50 + 1, [this] { this->setup_internal_(); }); + this->set_timeout(RESET_LOW_MS, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_SETTLE_MS, [this] { this->setup_internal_(); }); + }); return; } this->setup_internal_(); } void GT911Touchscreen::setup_internal_() { - if (this->interrupt_pin_ != nullptr) { - if (this->interrupt_pin_->is_internal()) - this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); - } + // Expander pins must leave output mode too, or the chip cannot drive its interrupt line + if (this->interrupt_pin_ != nullptr) + this->interrupt_pin_->pin_mode(gpio::FLAG_INPUT); uint8_t data[4]; i2c::ErrorCode err = this->write(GET_SWITCHES, sizeof(GET_SWITCHES)); @@ -72,7 +75,7 @@ void GT911Touchscreen::setup_internal_() { ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } else { - // IO expander pin: leave as output for configuration only. + // IO expander pin: no hardware interrupt, the touch state is polled. ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 465df528e5f..13fb6b05dab 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -14,17 +14,8 @@ class GT911ButtonListener { class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: - /// @brief Initialize the GT911 touchscreen. - /// - /// If @ref reset_pin_ is set, the touchscreen will be hardware reset, - /// and the rest of the setup will be scheduled to run 50ms later using @ref set_timeout() - /// to allow the device to stabilize after reset. - /// - /// If @ref interrupt_pin_ is set, it will be temporarily configured during reset - /// to control I2C address selection. - /// - /// After the timeout, or immediately if no reset is performed, @ref setup_internal_() - /// is called to complete the initialization. + /// With a reset pin the chip is reset with the interrupt pin held low, which selects address 0x5D, and the + /// rest of the setup runs from timeouts once the chip is ready. Without one setup_internal_() runs at once. void setup() override; void dump_config() override; bool can_proceed() override { return this->setup_done_; } From 36900e36f91ecd271cb6f1afee820f161aec635c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:09 -1000 Subject: [PATCH 047/111] [esp32_power_management] Build the flash power-down path in a standalone test variant (#20612) --- tests/components/esp32_power_management/common.yaml | 2 +- .../test-flash-power-down.esp32-idf.yaml | 8 ++++++++ .../components/esp32_power_management/test.esp32-ard.yaml | 1 + .../esp32_power_management/test.esp32-h2-idf.yaml | 1 + .../components/esp32_power_management/test.esp32-idf.yaml | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml diff --git a/tests/components/esp32_power_management/common.yaml b/tests/components/esp32_power_management/common.yaml index 74d93e6577f..78d64c634a3 100644 --- a/tests/components/esp32_power_management/common.yaml +++ b/tests/components/esp32_power_management/common.yaml @@ -5,7 +5,7 @@ power_management: min_frequency: ${esp32_power_management_min_frequency} enable_light_sleep: true idle_time_before_sleep: 3 - power_down_flash: true + power_down_flash: ${esp32_power_management_power_down_flash} power_down_peripherals: ${esp32_power_management_power_down_peripherals} profiling: true trace: true diff --git a/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml new file mode 100644 index 00000000000..364ad5329db --- /dev/null +++ b/tests/components/esp32_power_management/test-flash-power-down.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + esp32_power_management_max_frequency: 160MHZ + esp32_power_management_min_frequency: 40MHZ + esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "true" + +packages: + esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-ard.yaml b/tests/components/esp32_power_management/test.esp32-ard.yaml index 2b25b2d23f9..513617a53b4 100644 --- a/tests/components/esp32_power_management/test.esp32-ard.yaml +++ b/tests/components/esp32_power_management/test.esp32-ard.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml index c571a2be0cd..7bf893aeff6 100644 --- a/tests/components/esp32_power_management/test.esp32-h2-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-h2-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 96MHZ esp32_power_management_min_frequency: 32MHZ esp32_power_management_power_down_peripherals: "true" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml diff --git a/tests/components/esp32_power_management/test.esp32-idf.yaml b/tests/components/esp32_power_management/test.esp32-idf.yaml index 2b25b2d23f9..513617a53b4 100644 --- a/tests/components/esp32_power_management/test.esp32-idf.yaml +++ b/tests/components/esp32_power_management/test.esp32-idf.yaml @@ -2,6 +2,7 @@ substitutions: esp32_power_management_max_frequency: 160MHZ esp32_power_management_min_frequency: 40MHZ esp32_power_management_power_down_peripherals: "false" + esp32_power_management_power_down_flash: "false" packages: esp32_power_management: !include common.yaml From fb14db267cbca1fe96a1a5ed4f4e8eac8c8c85f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:45:52 -1000 Subject: [PATCH 048/111] [const] Move CONF_SERIAL_NUMBER to the shared component constants (#20611) --- esphome/components/const/__init__.py | 1 + esphome/components/hoermann_hcp/text_sensor/__init__.py | 2 +- esphome/components/sun_gtil2/text_sensor.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 78778553aab..49f55ab47f0 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -59,6 +59,7 @@ CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" +CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" CONF_SLOT = "slot" CONF_STATE_SAVE_INTERVAL = "state_save_interval" diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py index da460905c0e..7aedf599249 100644 --- a/esphome/components/hoermann_hcp/text_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP from esphome.types import ConfigType @@ -9,7 +10,6 @@ from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] CONF_DOOR_STATE = "door_state" -CONF_SERIAL_NUMBER = "serial_number" HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index c0a60cacbf3..753401e6aa8 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -1,13 +1,12 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import CONF_STATE from esphome.types import ConfigType from . import CONF_SUN_GTIL2_ID, SunGTIL2Component -CONF_SERIAL_NUMBER = "serial_number" - CONFIG_SCHEMA = cv.All( cv.Schema( { From 5a39097b3a73052d09fe06d8fb5770605961e22e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:46:41 -1000 Subject: [PATCH 049/111] [light] Keep one copy of the color mode lookup table in flash (#20394) --- esphome/components/light/color_mode.h | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index 0750ae250dc..fbce5232ff2 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -2,6 +2,7 @@ #include #include "esphome/core/finite_set_mask.h" +#include "esphome/core/hal.h" namespace esphome::light { @@ -108,8 +109,9 @@ constexpr ColorModeHelper operator|(ColorModeHelper lhs, ColorMode rhs) { using color_mode_bitmask_t = uint16_t; // Lookup table for ColorMode bit mapping -// This array defines the canonical order of color modes (bit 0-9) -constexpr ColorMode COLOR_MODE_LOOKUP[] = { +// This array defines the canonical order of color modes (bit 0-9). One shared copy in flash; read it at run time +// through color_mode_at(). +inline constexpr ColorMode COLOR_MODE_LOOKUP[] PROGMEM = { ColorMode::UNKNOWN, // bit 0 ColorMode::ON_OFF, // bit 1 ColorMode::BRIGHTNESS, // bit 2 @@ -122,6 +124,12 @@ constexpr ColorMode COLOR_MODE_LOOKUP[] = { ColorMode::RGB_COLD_WARM_WHITE, // bit 9 }; +constexpr ColorMode color_mode_at(unsigned bit) { + if (__builtin_is_constant_evaluated()) + return COLOR_MODE_LOOKUP[bit]; + return static_cast(progmem_read_byte(reinterpret_cast(&COLOR_MODE_LOOKUP[bit]))); +} + /// Bit mapping policy for ColorMode /// Uses lookup table for non-contiguous enum values struct ColorModeBitPolicy { @@ -129,17 +137,15 @@ struct ColorModeBitPolicy { static constexpr int MAX_BITS = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); static constexpr unsigned to_bit(ColorMode mode) { - // Linear search through lookup table - // Compiler optimizes this to efficient code since array is constexpr for (int i = 0; i < MAX_BITS; ++i) { - if (COLOR_MODE_LOOKUP[i] == mode) + if (color_mode_at(i) == mode) return i; } return 0; } static constexpr ColorMode from_bit(unsigned bit) { - return (bit < MAX_BITS) ? COLOR_MODE_LOOKUP[bit] : ColorMode::UNKNOWN; + return (bit < MAX_BITS) ? color_mode_at(bit) : ColorMode::UNKNOWN; } }; @@ -157,7 +163,7 @@ constexpr uint16_t compute_capability_bitmask(ColorCapability capability) { // Check each ColorMode to see if it has this capability constexpr int color_mode_count = sizeof(COLOR_MODE_LOOKUP) / sizeof(COLOR_MODE_LOOKUP[0]); for (int bit = 0; bit < color_mode_count; ++bit) { - uint8_t mode_val = static_cast(COLOR_MODE_LOOKUP[bit]); + uint8_t mode_val = static_cast(color_mode_at(bit)); if ((mode_val & cap_bit) != 0) { mask |= (1 << bit); } From 2001f60954ea807ddf29b21a41bf0a2105d61f1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:05 -1000 Subject: [PATCH 050/111] [core] Keep string constants in apply conditions in flash on ESP8266 (#20107) --- esphome/automation.py | 27 +++++++++++++++++++++------ esphome/core/string_ref.h | 19 +++++++++++++++++++ tests/unit_tests/test_automation.py | 22 +++++++++++++++++----- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 393a79422bc..4a2c10cb52d 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -248,6 +248,23 @@ def flash_string(config: ConfigType, value: str) -> str: return str(cg.progmem_string(value)) +def flash_compare(config: ConfigType, value: str) -> str: + """Default renderer for ``std::string`` constants in conditions; compares in flash on ESP8266.""" + if CORE.is_esp8266: + return f"::esphome::ProgmemStringRef{{PSTR({cg.safe_exp(value)}), {len(value.encode('utf-8'))}}}" + return str(cg.safe_exp(value)) + + +def _default_std_string( + members: list[tuple[str, Any, Any]], renderer: Callable[[ConfigType, str], str] +) -> list[tuple[str, Any, Any]]: + """Give ``cg.std_string`` members without a renderer the given default.""" + return [ + (key, t, fn or (renderer if t is cg.std_string else None)) + for key, t, fn in members + ] + + def literal_with_length(config: ConfigType, value: str) -> str: """Renderer for a ``(const char *, size_t)`` target: a plain literal plus its byte length. @@ -491,10 +508,7 @@ def apply_action_builder( statements_spec = [ ( c.target, - [ - (key, t, fn or (flash_string if t is cg.std_string else None)) - for key, t, fn in c.members - ], + _default_std_string(c.members, flash_string), ) for c in (f if isinstance(f, ApplyCall) else f.call() for f in fields) ] @@ -563,11 +577,12 @@ def register_apply_condition( ``check`` is applied to the parent: ``"is_playing()"`` becomes ``parent->is_playing()``; an ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. - String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. + A ``cg.std_string`` constant stays in flash on ESP8266 and supports only ``==`` or ``!=`` + against a ``std::string`` or ``StringRef`` member; other string types are plain literals. Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) - members = call.members + members = _default_std_string(call.members, flash_compare) _check_key_in_schema(name, schema, id_key) for conf_key, _, _ in members: _check_key_in_schema(name, schema, conf_key) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 14275412294..e9e31411e42 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -188,6 +188,25 @@ inline bool operator==(const __FlashStringHelper *lhs, const StringRef &rhs) { r inline bool operator!=(const StringRef &lhs, const __FlashStringHelper *rhs) { return !(lhs == rhs); } inline bool operator!=(const __FlashStringHelper *lhs, const StringRef &rhs) { return !(rhs == lhs); } + +/// Codegen use only: a flash string literal with its byte length, compared without a runtime strlen. +struct ProgmemStringRef { + PGM_P str; + size_t len; +}; + +inline bool operator==(const std::string &lhs, ProgmemStringRef rhs) { + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.data(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const std::string &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } + +inline bool operator==(const StringRef &lhs, ProgmemStringRef rhs) { + // A StringRef may be {nullptr, 0}, and memcmp_P with a null pointer is undefined even for 0 bytes + return lhs.size() == rhs.len && (rhs.len == 0 || memcmp_P(lhs.c_str(), rhs.str, rhs.len) == 0); +} + +inline bool operator!=(const StringRef &lhs, ProgmemStringRef rhs) { return !(lhs == rhs); } #endif // USE_ESP8266 inline bool operator<(const StringRef &lhs, const StringRef &rhs) { diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f2d46280448..fe0b1b02ed1 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1293,13 +1293,25 @@ async def test_apply_condition_compares_config_value( @pytest.mark.asyncio -@pytest.mark.parametrize("platform", ["esp32", "esp8266"]) -async def test_apply_condition_string_constant_is_a_plain_literal( - registries: tuple[Registry, Registry], mock_cg: MockCodegen, platform: str +@pytest.mark.parametrize( + ("platform", "value", "expected"), + [ + ("esp32", "two", '"two"'), + ("esp8266", "two", '::esphome::ProgmemStringRef{PSTR("two"), 3}'), + # The length is in bytes, not characters + ("esp8266", "é", '::esphome::ProgmemStringRef{PSTR("\\303\\251"), 2}'), + ], +) +async def test_apply_condition_string_constant( + registries: tuple[Registry, Registry], + mock_cg: MockCodegen, + platform: str, + value: str, + expected: str, ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) - await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) + await _run_apply_condition(registries, check, {"state": value}, platform=platform) + assert f"return ::{PARENT_OBJ}->state == {expected};" in _apply_definition(mock_cg) @pytest.mark.asyncio From 02287fdf7b0de1d45a908884867fb8d813d44a4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:48:27 -1000 Subject: [PATCH 051/111] [sensor] Share filter_out and throttle_with_priority value tables in flash (#20078) --- esphome/components/sensor/__init__.py | 32 ++++++++---- esphome/components/sensor/filter.cpp | 21 ++++---- esphome/components/sensor/filter.h | 49 ++++++------------ esphome/core/automation.h | 5 +- .../component_tests/sensor/test_value_list.py | 43 ++++++++++++++++ .../sensor/test_value_list.yaml | 51 +++++++++++++++++++ 6 files changed, 146 insertions(+), 55 deletions(-) create mode 100644 tests/component_tests/sensor/test_value_list.py create mode 100644 tests/component_tests/sensor/test_value_list.yaml diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8f345e3e668..79c6785be24 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,7 +113,7 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( SubEntities, @@ -421,16 +421,30 @@ async def multiply_filter_to_code(config, filter_id): return cg.new_Pvariable(filter_id, template_) +TemplatableFloat = cg.esphome_ns.class_("TemplatableFn").template(cg.float_) + + +async def _value_list_table(values: list[Any]) -> MockObj: + """PROGMEM value table, ended by an empty entry so filters store no count.""" + fns = [await cg.templatable(x, [], cg.float_) for x in values] + # Identical lambdas (e.g. a YAML anchor) may keep static state, so only constants share. + return cg.shared_progmem_array( + "sensor_value_list", + TemplatableFloat, + [*fns, TemplatableFloat()], + share=not any(isinstance(v, Lambda) for v in values), + ) + + @FILTER_REGISTRY.register( "filter_out", FilterOutValueFilter, cv.Any(cv.templatable(cv.float_), [cv.templatable(cv.float_)]), ) -async def filter_out_filter_to_code(config, filter_id): +async def filter_out_filter_to_code(config: Any, filter_id: ID) -> MockObj: if not isinstance(config, list): config = [config] - template_ = [await cg.templatable(x, [], cg.float_) for x in config] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_) + return cg.new_Pvariable(filter_id, await _value_list_table(config)) QUANTILE_SCHEMA = cv.All( @@ -704,7 +718,9 @@ def _is_nan_only(values: Any) -> bool: ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA, ) -async def throttle_with_priority_filter_to_code(config, filter_id): +async def throttle_with_priority_filter_to_code( + config: ConfigType, filter_id: ID +) -> MockObj: values = config[CONF_VALUE] if not isinstance(values, list): values = [values] @@ -716,10 +732,8 @@ async def throttle_with_priority_filter_to_code(config, filter_id): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) - template_ = [await cg.templatable(x, [], cg.float_) for x in values] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_ - ) + table = await _value_list_table(values) + return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], table) HEARTBEAT_SCHEMA = cv.Schema( diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index ed48ddebdbc..9b027c4e11f 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -226,14 +226,14 @@ MultiplyFilter::MultiplyFilter(TemplatableFn multiplier) : multiplier_(mu optional MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); } -// ValueListFilter helper (non-template, shared by all ValueListFilter instantiations) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count) { - int8_t accuracy = parent->get_accuracy_decimals(); +// ValueListFilter +bool ValueListFilter::value_matches_any_(float sensor_value) { + int8_t accuracy = this->parent_->get_accuracy_decimals(); float accuracy_mult = pow10_int(accuracy); float rounded_sensor = roundf(accuracy_mult * sensor_value); - for (size_t i = 0; i < count; i++) { - float fv = values[i].value(); + for (const TemplatableFn *v = this->values_; v->has_value(); v++) { + float fv = v->value(); // Handle NaN comparison if (std::isnan(fv)) { @@ -261,13 +261,12 @@ optional ThrottleFilter::new_value(float value) { return {}; } -// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) { +// ThrottleWithPriorityFilter +optional ThrottleWithPriorityFilter::new_value(float value) { const uint32_t now = App.get_loop_component_start_time(); - if (last_input == 0 || now - last_input >= min_time_between_inputs || - value_list_matches_any(parent, value, values, count)) { - last_input = now; + if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || + this->value_matches_any_(value)) { + this->last_input_ = now; return value; } return {}; diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 9b5ef493081..5ff99881dea 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -330,36 +330,27 @@ class MultiplyFilter : public Filter { TemplatableFn multiplier_; }; -/// Non-template helper for value matching (implementation in filter.cpp) -bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableFn *values, size_t count); +// Value tables are read in place from flash, which ESP8266 only allows for whole words. +static_assert(sizeof(TemplatableFn) == sizeof(void *), "TemplatableFn must stay a single pointer"); -/** Base class for filters that compare sensor values against a fixed list of configured values. - * - * Templated on N (the number of values) so the list is stored inline in a std::array, - * avoiding heap allocation and the overhead of FixedVector. - * - * @tparam N Number of values in the filter list, set by code generation to match - * the exact number of values configured in YAML. - */ -template class ValueListFilter : public Filter { +/// Base class for filters that compare sensor values against a fixed list of configured values. +class ValueListFilter : public Filter { protected: - explicit ValueListFilter(std::initializer_list> values) { - init_array_from(this->values_, values); - } + explicit ValueListFilter(const TemplatableFn *values) : values_(values) {} /// Check if sensor value matches any configured value (with accuracy rounding) - bool value_matches_any_(float sensor_value) { - return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N); - } + bool value_matches_any_(float sensor_value); - std::array, N> values_{}; + // Shared PROGMEM table ended by an empty entry, so no count is stored; entries are function + // pointers, so aligned 32-bit loads from flash are safe on ESP8266. + const TemplatableFn *values_; }; /// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`. -template class FilterOutValueFilter : public ValueListFilter { +class FilterOutValueFilter : public ValueListFilter { public: - explicit FilterOutValueFilter(std::initializer_list> values_to_filter_out) - : ValueListFilter(values_to_filter_out) {} + explicit FilterOutValueFilter(const TemplatableFn *values_to_filter_out) + : ValueListFilter(values_to_filter_out) {} optional new_value(float value) override { if (this->value_matches_any_(value)) @@ -379,21 +370,13 @@ class ThrottleFilter : public Filter { uint32_t min_time_between_inputs_; }; -/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp) -optional throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableFn *values, - size_t count, uint32_t &last_input, uint32_t min_time_between_inputs); - /// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`. -template class ThrottleWithPriorityFilter : public ValueListFilter { +class ThrottleWithPriorityFilter : public ValueListFilter { public: - explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, - std::initializer_list> prioritized_values) - : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} + ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const TemplatableFn *prioritized_values) + : ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {} - optional new_value(float value) override { - return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_, - this->min_time_between_inputs_); - } + optional new_value(float value) override; protected: uint32_t last_input_{0}; diff --git a/esphome/core/automation.h b/esphome/core/automation.h index bbb7f08fb8e..e7cf1c1143a 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -19,11 +19,12 @@ namespace esphome { /// Codegen wraps constants in stateless lambdas so only a function pointer is needed. template class TemplatableFn { public: - TemplatableFn() = default; + constexpr TemplatableFn() = default; TemplatableFn(std::nullptr_t) = delete; // Exact return type match — direct function pointer storage - template TemplatableFn(F f) requires std::convertible_to : f_(f) {} + // constexpr so codegen can place arrays of these in flash. + template constexpr TemplatableFn(F f) requires std::convertible_to : f_(f) {} // Convertible return type (e.g., int -> uint8_t) — casting trampoline. // Stateless lambdas are default-constructible in C++20, so F{} recreates the lambda inside diff --git a/tests/component_tests/sensor/test_value_list.py b/tests/component_tests/sensor/test_value_list.py new file mode 100644 index 00000000000..168fee24275 --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.py @@ -0,0 +1,43 @@ +"""Tests for the sensor value list filter codegen.""" + +from collections.abc import Callable +import re + + +def test_value_list_filters_share_progmem_tables( + generate_main: Callable[[str], str], +) -> None: + """Identical value lists share one PROGMEM table; the NaN throttle stays array free.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM", main_cpp + ) + # Every table ends with an empty entry so the filters store no count. + assert main_cpp.count("TemplatableFn()}") == len(tables) + assert ( + len(tables) == 5 + ) # single, shared list, lambda, two anchored stateful lambdas + single, shared, lam = tables[:3] + assert f"sensor::FilterOutValueFilter({single});" in main_cpp + assert main_cpp.count(f"sensor::FilterOutValueFilter({shared});") == 2 + assert f"sensor::FilterOutValueFilter({lam});" in main_cpp + assert f"sensor::ThrottleWithPriorityFilter(1000, {shared});" in main_cpp + assert "sensor::ThrottleWithPriorityNanFilter(1000);" in main_cpp + assert "return 42.0;" in main_cpp + + +def test_value_list_lambda_tables_are_not_shared( + generate_main: Callable[[str], str], +) -> None: + """An anchored lambda may keep static state, so each filter keeps its own table.""" + main_cpp = generate_main("tests/component_tests/sensor/test_value_list.yaml") + + tables = re.findall( + r"static constexpr TemplatableFn (\w+)\[\] PROGMEM = (.*?)TemplatableFn\(\)\}", + main_cpp, + re.DOTALL, + ) + stateful = [name for name, body in tables if "static float n" in body] + assert len(stateful) == 2 + assert stateful[0] != stateful[1] diff --git a/tests/component_tests/sensor/test_value_list.yaml b/tests/component_tests/sensor/test_value_list.yaml new file mode 100644 index 00000000000..60a044f483c --- /dev/null +++ b/tests/component_tests/sensor/test_value_list.yaml @@ -0,0 +1,51 @@ +esphome: + name: test + +esp32: + board: esp32dev + +sensor: + - platform: template + id: fo_single + lambda: return 1.0; + filters: + - filter_out: 85.0 + - platform: template + id: fo_list_a + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_list_b + lambda: return 1.0; + filters: + - filter_out: [0.0, 85.0] + - platform: template + id: fo_lambda + lambda: return 1.0; + filters: + - filter_out: !lambda return 42.0; + - platform: template + id: twp + lambda: return 1.0; + filters: + - throttle_with_priority: + timeout: 1s + value: [0.0, 85.0] + - platform: template + id: twp_nan + lambda: return 1.0; + filters: + - throttle_with_priority: 1s + - platform: template + id: fo_stateful_a + lambda: return 1.0; + filters: + - filter_out: &stateful !lambda |- + static float n = 0; + return ++n; + - platform: template + id: fo_stateful_b + lambda: return 1.0; + filters: + - filter_out: *stateful From d6e5b9b03bd037954e6733c3505d385bc375f7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Tue, 13 Oct 2026 03:50:12 +0200 Subject: [PATCH 052/111] [bq25186] Add the status sensors, buttons, PG/GPO switch and watchdog option (part 2) (#15736) Co-authored-by: J. Nick Koston --- esphome/components/bq25186/__init__.py | 87 ++++++++++++-- esphome/components/bq25186/binary_sensor.py | 73 ++++++++++++ esphome/components/bq25186/bq25186.cpp | 106 +++++++++++++++++- esphome/components/bq25186/bq25186.h | 57 +++++++++- .../bq25186/bq25186_binary_sensor.h | 31 +++++ esphome/components/bq25186/bq25186_button.h | 25 +++++ esphome/components/bq25186/bq25186_switch.h | 21 ++++ .../bq25186/bq25186_text_sensor.cpp | 26 +++++ .../components/bq25186/bq25186_text_sensor.h | 26 +++++ esphome/components/bq25186/button.py | 52 +++++++++ esphome/components/bq25186/switch.py | 27 +++++ esphome/components/bq25186/text_sensor.py | 38 +++++++ esphome/core/defines.h | 3 + .../bq25186/test_pack_registers.py | 50 +++++++++ tests/components/bq25186/common.yaml | 74 ++++++++++++ 15 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 esphome/components/bq25186/binary_sensor.py create mode 100644 esphome/components/bq25186/bq25186_binary_sensor.h create mode 100644 esphome/components/bq25186/bq25186_button.h create mode 100644 esphome/components/bq25186/bq25186_switch.h create mode 100644 esphome/components/bq25186/bq25186_text_sensor.cpp create mode 100644 esphome/components/bq25186/bq25186_text_sensor.h create mode 100644 esphome/components/bq25186/button.py create mode 100644 esphome/components/bq25186/switch.py create mode 100644 esphome/components/bq25186/text_sensor.py diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index e57860786f5..03a100d9297 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -5,8 +5,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID -from esphome.core import CORE, ID, Lambda +from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG +from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@Rapsssito"] @@ -14,6 +14,7 @@ DEPENDENCIES = ["i2c"] DOMAIN = "bq25186" MULTI_CONF = True +CONF_BQ25186_ID = "bq25186_id" CONF_CONFIGURE_ON_BOOT = "configure_on_boot" CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" CONF_FAST_CHARGE_CURRENT = "fast_charge_current" @@ -43,6 +44,7 @@ CONF_WAKE2_TIMER = "wake2_timer" CONF_ENABLE_PUSH_BUTTON = "enable_push_button" CONF_SYSTEM_REGULATION = "system_regulation" CONF_SYS_MODE = "sys_mode" +CONF_WATCHDOG_15S_ENABLE = "watchdog_15s_enable" CONF_DISABLE_VDPPM = "disable_vdppm" CONF_TS_HOT = "ts_hot" CONF_TS_COLD = "ts_cold" @@ -56,7 +58,26 @@ CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" bq25186_ns = cg.esphome_ns.namespace("bq25186") -BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) +BQ25186Component = bq25186_ns.class_( + "BQ25186Component", cg.PollingComponent, i2c.I2CDevice +) + +BQ25186_CHILD_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} +) +_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") +_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") + + +def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_binary_sensor_slot(str(hub)) + return hub.register_binary_sensor(var) + + +def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: + _request_text_sensor_slot(str(hub)) + return hub.register_text_sensor(var) + REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 @@ -69,7 +90,6 @@ REG_SYS_REG = 0x0A REG_TS_CONTROL = 0x0B REG_MASK_ID = 0x0C REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 -WATCHDOG_DISABLED = 0x03 TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} @@ -86,6 +106,22 @@ BATTERY_UVLO_OPTIONS = { VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +# The I2C watchdog restores the power-on registers when the bus stays quiet for its period +WATCHDOG_OPTIONS = { + "160s_software_reset": 0, + "160s_hardware_reset": 1, + "40s_hardware_reset": 2, + "disabled": 3, +} +WATCHDOG_PERIOD_S = { + "160s_software_reset": 160, + "160s_hardware_reset": 160, + "40s_hardware_reset": 40, +} +# SYS_REG watchdog_15s_enable: a hardware reset when the bus stays quiet for 15 s after VIN connects +WATCHDOG_15S_PERIOD_S = 15 +# The chip boots with the 160 s software reset watchdog, which runs until the configuration is applied +POWER_ON_WATCHDOG_PERIOD_S = 160 LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} INPUT_CURRENT_LIMIT_OPTIONS = { @@ -146,8 +182,8 @@ def _on_current_step(amps: float) -> float: # (config key, register, bit shift, validator or enum options, default). -# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are -# driven at runtime, not from configuration. +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; the PG/GPO +# switch drives them. FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( ( CONF_BATTERY_REGULATION_VOLTAGE, @@ -179,6 +215,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_WATCHDOG, REG_IC_CTRL, 0, WATCHDOG_OPTIONS, "disabled"), (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), @@ -195,6 +232,7 @@ FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_WATCHDOG_15S_ENABLE, REG_SYS_REG, 1, cv.boolean, False), (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), @@ -232,9 +270,6 @@ _ENCODERS: dict[str, Callable[[Any], int]] = { def pack_registers(config: ConfigType) -> list[int]: """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" regs = [0] * REGISTER_COUNT - # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running - # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. - regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED for key, register, shift, validator, _ in FIELDS: value = config[key] if isinstance(validator, dict): @@ -259,7 +294,28 @@ def _field_schema(with_defaults: bool) -> cv.Schema: BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) -CONFIG_SCHEMA = ( + +def _check_watchdog(config: ConfigType) -> ConfigType: + """Only the status poll talks to the chip, so it has to come round before any watchdog does.""" + periods = [] + if (period := WATCHDOG_PERIOD_S.get(config[CONF_WATCHDOG])) is not None: + periods.append(period) + if config[CONF_WATCHDOG_15S_ENABLE]: + periods.append(WATCHDOG_15S_PERIOD_S) + if not config.get(CONF_CONFIGURE_ON_BOOT, True): + periods.append(POWER_ON_WATCHDOG_PERIOD_S) + if not periods: + return config + if config[CONF_UPDATE_INTERVAL] >= TimePeriod(seconds=(limit := min(periods))): + raise cv.Invalid( + f"{CONF_UPDATE_INTERVAL} must be shorter than {limit} s, or the " + f"watchdog resets the charger between status polls", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(BQ25186Component), @@ -267,7 +323,9 @@ CONFIG_SCHEMA = ( } ) .extend(BQ25186_CONFIG_SCHEMA) - .extend(i2c.i2c_device_schema(0x6A)) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6A)), + _check_watchdog, ) CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( @@ -305,7 +363,12 @@ async def bq25186_configure_to_code( # Options the action leaves out fall back to the component's configuration path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] parent_config = CORE.config.get_config_for_path(path) - regs = pack_registers({**parent_config, **config}) + merged = {**parent_config, **config} + try: + _check_watchdog(merged) + except cv.Invalid as err: + raise EsphomeError(f"bq25186.apply_configuration: {err.msg}") from err + regs = pack_registers(merged) text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py new file mode 100644 index 00000000000..fb741950e29 --- /dev/null +++ b/esphome/components/bq25186/binary_sensor.py @@ -0,0 +1,73 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_RUNNING, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186BinarySensor = bq25186_ns.class_( + "BQ25186BinarySensor", binary_sensor.BinarySensor +) + +REG_STAT0 = 0 +REG_STAT1 = 1 +REG_FLAG0 = 2 +# Bit set for each field value that reads as on: a single bit is on when it is 1 +_BIT_SET = 0b10 + +# (key, status register, bit shift, field mask, on-values bitmap, device class) +_FIELDS = ( + ("ts", REG_STAT0, 7, 0x01, _BIT_SET, DEVICE_CLASS_OPENING), + ("charging", REG_STAT0, 5, 0x03, 0b0110, DEVICE_CLASS_BATTERY_CHARGING), + ("charge_done", REG_STAT0, 5, 0x03, 0b1000, cv.UNDEFINED), + ("ilim", REG_STAT0, 4, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vdppm", REG_STAT0, 3, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("vindpm", REG_STAT0, 2, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("thermreg", REG_STAT0, 1, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("power_good", REG_STAT0, 0, 0x01, _BIT_SET, DEVICE_CLASS_PLUG), + ("vin_ovp", REG_STAT1, 7, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("battery_uvlo", REG_STAT1, 6, 0x01, _BIT_SET, DEVICE_CLASS_RUNNING), + ("safety_timer_flag", REG_STAT1, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake1_flag", REG_STAT1, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("wake2_flag", REG_STAT1, 0, 0x01, _BIT_SET, cv.UNDEFINED), + ("ts_flag", REG_FLAG0, 7, 0x01, _BIT_SET, cv.UNDEFINED), + ("ilim_flag", REG_FLAG0, 6, 0x01, _BIT_SET, cv.UNDEFINED), + ("vdppm_flag", REG_FLAG0, 5, 0x01, _BIT_SET, cv.UNDEFINED), + ("vindpm_flag", REG_FLAG0, 4, 0x01, _BIT_SET, cv.UNDEFINED), + ("thermreg_flag", REG_FLAG0, 3, 0x01, _BIT_SET, cv.UNDEFINED), + ("vin_ovp_flag", REG_FLAG0, 2, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_uvlo_flag", REG_FLAG0, 1, 0x01, _BIT_SET, cv.UNDEFINED), + ("battery_ocp_flag", REG_FLAG0, 0, 0x01, _BIT_SET, cv.UNDEFINED), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): binary_sensor.binary_sensor_schema( + BQ25186BinarySensor, device_class=device_class + ) + for key, *_, device_class in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + for key, reg, shift, mask, match, _ in _FIELDS: + await sensors( + key, partial(register_binary_sensor, hub), reg, shift, mask, match + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index e660ec63d14..edea50d1115 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,4 +1,10 @@ #include "bq25186.h" +#ifdef USE_BINARY_SENSOR +#include "bq25186_binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "bq25186_text_sensor.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -7,10 +13,10 @@ namespace esphome::bq25186 { ESPHOME_LOG_TAG(TAG, "bq25186"); -// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, -// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +// Bits the configuration owns in each register; the rest are the SYS_REG GPO level, reset +// triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { - 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, }; bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { @@ -31,16 +37,38 @@ bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value return this->write_register_(reg, reg_value); } +uint8_t BQ25186Component::expected_register_(size_t index) const { + uint8_t value = this->regs_[index]; +#ifdef USE_BQ25186_PG_GPO + if (index == 0 && this->pg_gpo_switch_ != nullptr) + value |= BQ25186_VBAT_CTRL_PG_MODE; +#endif + return value; +} + +bool BQ25186Component::config_matches_(const BQ25186StatusRegisters ®s) const { + for (uint8_t reg = BQ25186_REG_VBAT_CTRL; reg <= BQ25186_REG_MASK_ID; reg++) { + const size_t index = reg - BQ25186_REG_VBAT_CTRL; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[index]); + if (((regs[reg] ^ this->expected_register_(index)) & mask) != 0) + return false; + } + return true; +} + bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { // Setup rejects an unknown chip; the action must not program it before that check or after it failed if (!this->is_ready() || this->is_failed()) { ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); return false; } + this->regs_ = regs; + this->configured_ = true; for (size_t i = 0; i < regs.size(); i++) { const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); - const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + const uint8_t value = this->expected_register_(i); + const bool ok = mask == 0xFF ? this->write_register_(reg, value) : this->update_register_(reg, mask, value); if (!ok) { this->status_set_warning(LOG_STR("Configuration partly applied")); return false; @@ -68,12 +96,80 @@ void BQ25186Component::setup() { // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry if (this->configure_on_boot_) - this->apply_config(this->setup_regs_); + this->apply_config(this->regs_); +#ifdef USE_BQ25186_PG_GPO + if (this->pg_gpo_switch_ != nullptr) { + // The poll below puts the pin in output mode; without a restore mode the switch takes its level from it + auto initial = this->pg_gpo_switch_->get_initial_state_with_restore_mode(); + if (initial.has_value()) + this->pg_gpo_switch_->publish_state(*initial); // the first poll writes it to the pin + } +#endif + // The poller first runs after a random share of the interval; the entities get a state now + this->update(); +} + +#ifdef USE_BQ25186_PG_GPO +// The pin mode and level come from the switch, so every poll puts back whatever the chip lost +void BQ25186Component::check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending) { + if (this->pg_gpo_switch_ == nullptr) + return; + if ((status[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE) == 0 && + !this->update_register_(BQ25186_REG_VBAT_CTRL, BQ25186_VBAT_CTRL_PG_MODE, BQ25186_VBAT_CTRL_PG_MODE)) { + this->status_set_warning(LOG_STR("PG/GPO pin not in output mode")); + pending = true; + } + const bool level = (status[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; + if (!this->pg_gpo_switch_->has_state()) { + this->pg_gpo_switch_->publish_state(level); + } else if (level != this->pg_gpo_switch_->state && !this->write_pg_gpo_level(this->pg_gpo_switch_->state)) { + this->status_set_warning(LOG_STR("PG/GPO level write failed")); + pending = true; + } +} +#endif + +// Every poll also serves the I2C watchdog, which wants to see traffic within its period +void BQ25186Component::update() { + BQ25186StatusRegisters status; + if (!this->read_bytes(BQ25186_REG_STAT0, status.data(), status.size())) { + this->status_set_warning(LOG_STR("Status read failed")); + return; + } + // Whatever failed is tried again here, so the warning only stays while something is still wrong + bool pending = false; + // A watchdog or hardware reset puts the power-on values back, and a failed write leaves a register + // behind; either way the configuration is written again + if (this->configured_ && !this->config_matches_(status)) { + ESP_LOGW(TAG, "Configuration registers differ from the values applied, writing them again"); + if (this->apply_config(this->regs_)) { + status[BQ25186_REG_VBAT_CTRL] |= BQ25186_VBAT_CTRL_PG_MODE; // part of the write when a switch is configured + } else { + pending = true; + } + } +#ifdef USE_BQ25186_PG_GPO + this->check_pg_gpo_(status, pending); +#endif + if (!pending) + this->status_clear_warning(); +#ifdef BQ25186_BINARY_SENSOR_COUNT + for (auto *sensor : this->binary_sensors_) + sensor->on_status(status); +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + for (auto *sensor : this->text_sensors_) + sensor->on_status(status); +#endif } void BQ25186Component::dump_config() { ESP_LOGCONFIG(TAG, "BQ25186:"); LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +#ifdef USE_BQ25186_PG_GPO + LOG_SWITCH(" ", "PG/GPO", this->pg_gpo_switch_); +#endif } } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index a6920871475..0a9a45c28b4 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -2,11 +2,19 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BQ25186_PG_GPO +#include "esphome/components/switch/switch.h" +#endif #include namespace esphome::bq25186 { +static constexpr uint8_t BQ25186_REG_STAT0 = 0x00; +static constexpr uint8_t BQ25186_REG_STAT1 = 0x01; +static constexpr uint8_t BQ25186_REG_FLAG0 = 0x02; static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; @@ -18,27 +26,70 @@ static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; +// STAT0 to MASK_ID, read together by every poll: the status for the entities, then the configuration +// registers, which show whether the chip was reset or a write failed +static constexpr size_t BQ25186_STATUS_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_STAT0 + 1; + +// VBAT_CTRL pg_mode: the /PG pin is a general purpose output driven by SYS_REG pg_gpo_level +static constexpr uint8_t BQ25186_VBAT_CTRL_PG_MODE = 0x80; +static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; // Register values for VBAT_CTRL..MASK_ID, packed by code generation using BQ25186ConfigRegisters = std::array; +using BQ25186StatusRegisters = std::array; -class BQ25186Component : public Component, public i2c::I2CDevice { +class BQ25186BinarySensor; +class BQ25186TextSensor; + +class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: void setup() override; + void update() override; void dump_config() override; void set_setup_registers(const BQ25186ConfigRegisters ®s) { - this->setup_regs_ = regs; + this->regs_ = regs; this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); +#ifdef BQ25186_BINARY_SENSOR_COUNT + void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#endif +#ifdef USE_BQ25186_PG_GPO + /// The /PG/GPO pin is put in general purpose output mode when a switch drives it + void set_pg_gpo_switch(switch_::Switch *pg_gpo_switch) { this->pg_gpo_switch_ = pg_gpo_switch; } + bool write_pg_gpo_level(bool level) { + return this->update_register_(BQ25186_REG_SYS_REG, BQ25186_SYS_REG_PG_GPO_LEVEL, + level ? BQ25186_SYS_REG_PG_GPO_LEVEL : 0); + } +#endif + /// Write the masked bits of SHIP_RST + bool write_ship_rst(uint8_t mask, uint8_t value) { return this->update_register_(BQ25186_REG_SHIP_RST, mask, value); } protected: bool write_register_(uint8_t reg, uint8_t value); + uint8_t expected_register_(size_t index) const; + bool config_matches_(const BQ25186StatusRegisters ®s) const; +#ifdef USE_BQ25186_PG_GPO + void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); +#endif bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); - BQ25186ConfigRegisters setup_regs_{}; +#ifdef BQ25186_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef BQ25186_TEXT_SENSOR_COUNT + StaticVector text_sensors_; +#endif +#ifdef USE_BQ25186_PG_GPO + switch_::Switch *pg_gpo_switch_{nullptr}; +#endif + BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; + bool configured_{false}; // regs_ went to the chip at least once }; } // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h new file mode 100644 index 00000000000..265f34a4459 --- /dev/null +++ b/esphome/components/bq25186/bq25186_binary_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR + +#include "bq25186.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome::bq25186 { + +/// One field of a status register; `match` has a bit set for each field value that reads as on +class BQ25186BinarySensor final : public binary_sensor::BinarySensor { + public: + BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) + : reg_(reg), shift_(shift), mask_(mask), match_(match) {} + + void on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; + this->publish_state((this->match_ >> value) & 1); + } + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t mask_; + const uint8_t match_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_button.h b/esphome/components/bq25186/bq25186_button.h new file mode 100644 index 00000000000..e088166ac6c --- /dev/null +++ b/esphome/components/bq25186/bq25186_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BUTTON + +#include "bq25186.h" +#include "esphome/components/button/button.h" + +namespace esphome::bq25186 { + +/// Writes one SHIP_RST field when pressed +class BQ25186Button final : public button::Button, public Parented { + public: + BQ25186Button(uint8_t mask, uint8_t value) : mask_(mask), value_(value) {} + + protected: + void press_action() override { this->parent_->write_ship_rst(this->mask_, this->value_); } + + const uint8_t mask_; + const uint8_t value_; +}; + +} // namespace esphome::bq25186 + +#endif // USE_BUTTON diff --git a/esphome/components/bq25186/bq25186_switch.h b/esphome/components/bq25186/bq25186_switch.h new file mode 100644 index 00000000000..9abe6fec510 --- /dev/null +++ b/esphome/components/bq25186/bq25186_switch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_BQ25186_PG_GPO + +#include "bq25186.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::bq25186 { + +class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented { + protected: + void write_state(bool state) override { + if (this->parent_->write_pg_gpo_level(state)) + this->publish_state(state); + } +}; + +} // namespace esphome::bq25186 + +#endif // USE_BQ25186_PG_GPO diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp new file mode 100644 index 00000000000..660313e6413 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.cpp @@ -0,0 +1,26 @@ +#include "bq25186_text_sensor.h" +#ifdef USE_TEXT_SENSOR + +#include "esphome/core/progmem.h" + +namespace esphome::bq25186 { + +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", + "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); + +void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { + const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; + if (value == this->last_) + return; + this->last_ = value; + const ProgmemStr name = StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX); + char text[32]; + ESPHOME_strncpy_P(text, reinterpret_cast(name), sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h new file mode 100644 index 00000000000..33c8f806fa9 --- /dev/null +++ b/esphome/components/bq25186/bq25186_text_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_TEXT_SENSOR + +#include "bq25186.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::bq25186 { + +/// A two-bit status field; `first` is the string table index of the field's value 0 +class BQ25186TextSensor final : public text_sensor::TextSensor { + public: + BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} + void on_status(const BQ25186StatusRegisters &status); + + protected: + const uint8_t reg_; + const uint8_t shift_; + const uint8_t first_; + uint8_t last_{0xFF}; // the field value last published +}; + +} // namespace esphome::bq25186 + +#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/button.py b/esphome/components/bq25186/button.py new file mode 100644 index 00000000000..25527ff9a27 --- /dev/null +++ b/esphome/components/bq25186/button.py @@ -0,0 +1,52 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ICON_POWER, + ICON_RESTART, + ICON_RESTART_ALERT, +) +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +BQ25186Button = bq25186_ns.class_("BQ25186Button", button.Button) + +# SHIP_RST: bit 7 is the software reset, bits 6:5 the shutdown, ship and hardware reset codes +SOFTWARE_RESET_MASK = 0x80 +MODE_MASK = 0x60 + +# (key, SHIP_RST mask, value, device class, icon) +_BUTTONS = ( + ("software_reset", SOFTWARE_RESET_MASK, 0x80, DEVICE_CLASS_RESTART, ICON_RESTART), + ("shutdown", MODE_MASK, 0x20, cv.UNDEFINED, ICON_POWER), + ("ship_mode", MODE_MASK, 0x40, cv.UNDEFINED, ICON_POWER), + ("hardware_reset", MODE_MASK, 0x60, DEVICE_CLASS_RESTART, ICON_RESTART_ALERT), +) + + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): button.button_schema( + BQ25186Button, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + device_class=device_class, + ) + for key, _, _, device_class, icon in _BUTTONS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _BUTTONS)), +) + + +async def to_code(config: ConfigType) -> None: + for key, mask, value, _, _ in _BUTTONS: + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, mask, value) + await cg.register_parented(var, config[CONF_BQ25186_ID]) diff --git a/esphome/components/bq25186/switch.py b/esphome/components/bq25186/switch.py new file mode 100644 index 00000000000..e5eb2d98a2e --- /dev/null +++ b/esphome/components/bq25186/switch.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns + +DEPENDENCIES = ["bq25186"] + +CONF_PG_GPO = "pg_gpo" + +BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch) + +CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend( + { + cv.Required(CONF_PG_GPO): switch.switch_schema( + BQ25186PgGpoSwitch, default_restore_mode="DISABLED" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_BQ25186_PG_GPO") + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_PG_GPO, hub.set_pg_gpo_switch) diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py new file mode 100644 index 00000000000..b27d2fbcfae --- /dev/null +++ b/esphome/components/bq25186/text_sensor.py @@ -0,0 +1,38 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor + +DEPENDENCIES = ["bq25186"] + +BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) + +REG_STAT0 = 0 +REG_STAT1 = 1 + +# (key, status register, bit shift, index of the field's value 0 in the C++ string table) +_FIELDS = ( + ("charge_status", REG_STAT0, 5, 0), + ("ts_status", REG_STAT1, 3, 4), +) + +CONFIG_SCHEMA = cv.All( + BQ25186_CHILD_SCHEMA.extend( + { + cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) + for key, *_ in _FIELDS + } + ), + cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ25186_ID]) + sensors = text_sensor.sub_text_sensors(config) + for key, reg, shift, first in _FIELDS: + await sensors(key, partial(register_text_sensor, hub), reg, shift, first) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c645428c7c1..072abe83742 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -49,6 +49,9 @@ #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 +#define USE_BQ25186_PG_GPO +#define BQ25186_BINARY_SENSOR_COUNT 1 +#define BQ25186_TEXT_SENSOR_COUNT 1 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/component_tests/bq25186/test_pack_registers.py b/tests/component_tests/bq25186/test_pack_registers.py index 2ebf6087e7b..940bf5822fa 100644 --- a/tests/component_tests/bq25186/test_pack_registers.py +++ b/tests/component_tests/bq25186/test_pack_registers.py @@ -10,12 +10,16 @@ from esphome.components.bq25186 import ( CONF_FAST_CHARGE_CURRENT, CONF_MASK_TS_INTERRUPT, CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + CONF_WATCHDOG_15S_ENABLE, + CONFIG_SCHEMA, REG_ICHG_CTRL, REG_MASK_ID, REG_SHIP_RST, REG_VBAT_CTRL, + _check_watchdog, pack_registers, ) +from esphome.const import CONF_UPDATE_INTERVAL, CONF_WATCHDOG from esphome.types import ConfigType # Datasheet power-on values for VBAT_CTRL..MASK_ID, except that IC_CTRL has the @@ -85,3 +89,49 @@ def test_masked_registers_only_carry_configurable_bits() -> None: def test_values_between_steps_are_rejected(key: str, value: str) -> None: with pytest.raises(cv.Invalid): BQ25186_CONFIG_SCHEMA({key: value}) + + +def _watchdog_config(interval: str, options: ConfigType) -> ConfigType: + config = BQ25186_CONFIG_SCHEMA(options) + config[CONF_UPDATE_INTERVAL] = cv.update_interval(interval) + return config + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "160s_software_reset"}), + ("60s", {CONF_WATCHDOG: "160s_hardware_reset"}), + ("30s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG: "disabled"}), + ("10s", {CONF_WATCHDOG_15S_ENABLE: True}), + ], +) +def test_update_interval_within_watchdog_passes( + interval: str, options: ConfigType +) -> None: + _check_watchdog(_watchdog_config(interval, options)) + + +@pytest.mark.parametrize( + "interval, options", + [ + ("60s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("40s", {CONF_WATCHDOG: "40s_hardware_reset"}), + ("60s", {CONF_WATCHDOG_15S_ENABLE: True}), + ("20s", {CONF_WATCHDOG: "160s_software_reset", CONF_WATCHDOG_15S_ENABLE: True}), + ("never", {CONF_WATCHDOG: "160s_software_reset"}), + ], +) +def test_update_interval_past_watchdog_is_rejected( + interval: str, options: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match=CONF_UPDATE_INTERVAL): + _check_watchdog(_watchdog_config(interval, options)) + + +def test_unconfigured_chip_keeps_its_power_on_watchdog() -> None: + config = {"configure_on_boot": False, "update_interval": "160s"} + with pytest.raises(cv.Invalid, match="shorter than 160 s"): + CONFIG_SCHEMA(config) + CONFIG_SCHEMA({**config, "update_interval": "60s"}) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 9f4b117cd3d..295a68d262b 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -23,3 +23,77 @@ bq25186: address: 0x6B configure_on_boot: true fast_charge_current: 20mA + watchdog: 160s_software_reset + update_interval: 30s + +binary_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + ts: + name: TS Open + charging: + name: Charging + charge_done: + name: Charge Done + ilim: + name: ILIM Active + vdppm: + name: VDPPM Active + vindpm: + name: VINDPM Active + thermreg: + name: Thermal Regulation Active + power_good: + name: VIN Power Good + vin_ovp: + name: VIN OVP Active + battery_uvlo: + name: Battery UVLO Active + safety_timer_flag: + name: Safety Timer Flag + wake1_flag: + name: Wake 1 Flag + wake2_flag: + name: Wake 2 Flag + ts_flag: + name: TS Flag + ilim_flag: + name: ILIM Flag + vdppm_flag: + name: VDPPM Flag + vindpm_flag: + name: VINDPM Flag + thermreg_flag: + name: Thermal Regulation Flag + vin_ovp_flag: + name: VIN OVP Flag + battery_uvlo_flag: + name: Battery UVLO Flag + battery_ocp_flag: + name: Battery OCP Flag + +text_sensor: + - platform: bq25186 + bq25186_id: bq25186_charger + charge_status: + name: Charge Status + ts_status: + name: TS Status + +button: + - platform: bq25186 + bq25186_id: bq25186_charger + software_reset: + name: Software Reset + shutdown: + name: Shutdown + ship_mode: + name: Ship Mode + hardware_reset: + name: Hardware Reset + +switch: + - platform: bq25186 + bq25186_id: bq25186_boot + pg_gpo: + name: PG GPO From 98d3d23ce14d802c8429c14067da77b8b467b474 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 15:59:25 -1000 Subject: [PATCH 053/111] [ecocomfort2] Test the component in isolation (#20613) --- 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 8bbb9ed7f9b..f7680f105ca 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", + "ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "gps": "TinyGPSPlus library declares millis() function that creates ambiguity with ESPHome millis() macro when merged with components using millis() in lambdas", From 753c10e5eace236ad573f0c1b668277a77ecae61 Mon Sep 17 00:00:00 2001 From: Ivan Novak <37712440+NovakIrs@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:06:45 +0200 Subject: [PATCH 054/111] [ld2410s] Add the distance sensor, firmware version and common parameters (Part 2) (#8486) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston --- esphome/components/ld2410s/__init__.py | 2 + esphome/components/ld2410s/ld2410s.cpp | 309 +++++++++++++++--- esphome/components/ld2410s/ld2410s.h | 84 ++++- esphome/components/ld2410s/ld2410s_number.h | 32 ++ esphome/components/ld2410s/ld2410s_select.h | 21 ++ esphome/components/ld2410s/number.py | 108 ++++++ esphome/components/ld2410s/select.py | 31 ++ esphome/components/ld2410s/sensor.py | 32 ++ esphome/components/ld2410s/text_sensor.py | 23 ++ esphome/core/defines.h | 2 + tests/components/ld2410s/common-bare.yaml | 28 ++ tests/components/ld2410s/common.yaml | 28 ++ .../ld2410s/test-bare.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_ld2410s.yaml | 62 ++++ .../fixtures/uart_mock_ld2410s_link_lost.yaml | 19 ++ tests/integration/test_uart_mock_ld2410s.py | 81 ++++- 16 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 esphome/components/ld2410s/ld2410s_number.h create mode 100644 esphome/components/ld2410s/ld2410s_select.h create mode 100644 esphome/components/ld2410s/number.py create mode 100644 esphome/components/ld2410s/select.py create mode 100644 esphome/components/ld2410s/sensor.py create mode 100644 esphome/components/ld2410s/text_sensor.py create mode 100644 tests/components/ld2410s/common-bare.yaml create mode 100644 tests/components/ld2410s/test-bare.esp8266-ard.yaml diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index 1c39c7eab32..ecc90455d1c 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType +AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@NovakIrs"] DEPENDENCIES = ["uart"] DOMAIN = "ld2410s" @@ -13,6 +14,7 @@ CONF_LD2410S_ID = "ld2410s_id" ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) +Parameter = ld2410s_ns.enum("Parameter", is_class=True) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp index 6462236ac93..3f71c77a62b 100644 --- a/esphome/components/ld2410s/ld2410s.cpp +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -1,4 +1,5 @@ #include "ld2410s.h" +#include "ld2410s_number.h" #include #include #include "esphome/core/application.h" @@ -25,18 +26,46 @@ static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t VERSION_READ_CMD = 0x0000; +static constexpr uint16_t PARAMETERS_WRITE_CMD = 0x0070; +static constexpr uint16_t PARAMETERS_READ_CMD = 0x0071; static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; // Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; -static constexpr uint8_t CMD_FRAME_MAX_SIZE = - LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); +// The parameter write carries a 16-bit key and a 32-bit value per parameter, the read only the keys +static constexpr uint8_t PARAMETER_KEY_SIZE = 2; +static constexpr uint8_t PARAMETER_VALUE_SIZE = 4; +static constexpr uint8_t PARAMETERS_WRITE_SIZE = PARAMETER_COUNT * (PARAMETER_KEY_SIZE + PARAMETER_VALUE_SIZE); +static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_WRITE_SIZE + sizeof(CMD_FRAME_FOOTER); +// Keys of the parameters, in Parameter order +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B}; +static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter"); +#endif +// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words +static constexpr uint8_t VERSION_TYPE_SIZE = 4; +static constexpr uint8_t VERSION_SIZE = VERSION_TYPE_SIZE + 3 * 2; -// Everything the module needs at start: enter configuration mode, pick the output mode, leave it -static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, - CONFIG_MODE_END_CMD}; +// Everything the module needs at start: configuration mode on, read the version and parameters when +// an entity shows them, pick the output mode, configuration mode off +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = { + CONFIG_MODE_START_CMD, +#ifdef USE_LD2410S_VERSION + VERSION_READ_CMD, +#endif +#ifdef USE_LD2410S_PARAMETERS + PARAMETERS_READ_CMD, +#endif + OUTPUT_MODE_SWITCH_CMD, CONFIG_MODE_END_CMD, +}; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint16_t WRITE_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, PARAMETERS_WRITE_CMD, CONFIG_MODE_END_CMD}; +#endif static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); -static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } -static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +#ifdef USE_LD2410S_PARAMETERS +static constexpr uint8_t WRITE_SEQUENCE_LENGTH = sizeof(WRITE_SEQUENCE) / sizeof(WRITE_SEQUENCE[0]); +#endif +static constexpr uint8_t STEP_MAX_TIMEOUTS = 2; static constexpr uint32_t ACK_TIMEOUT_MS = 300; static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering @@ -49,6 +78,20 @@ void LD2410SComponent::dump_config() { #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); #endif +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "Distance", this->distance_sensor_); +#endif +#ifdef USE_LD2410S_VERSION + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + for (auto *number : this->numbers_) { + LOG_NUMBER(" ", "Setting", number); + } +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + LOG_SELECT(" ", "Response Speed", this->response_speed_select_); +#endif } void LD2410SComponent::loop() { @@ -67,35 +110,80 @@ void LD2410SComponent::loop() { avail -= len; } const uint32_t now = App.get_loop_component_start_time(); - this->run_init_sequence_(now); - if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + if (this->sequence_ == nullptr) { + if (!this->init_done_) { + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, now); +#ifdef USE_LD2410S_PARAMETERS + } else if (this->parameters_dirty_) { + this->parameters_dirty_ = false; + this->start_sequence_(WRITE_SEQUENCE, WRITE_SEQUENCE_LENGTH, this->next_send_at_); +#endif + } + } + this->run_sequence_(now); + if (this->init_done_ && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, this->dropped_frames_); this->dropped_frames_ = 0; - this->status_set_warning(); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now; + this->restart_init_(now); } } -void LD2410SComponent::run_init_sequence_(uint32_t now) { - if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { +#ifdef USE_LD2410S_PARAMETERS +bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) { + // All six are written together, so the other five must be the module's own values + if (!this->init_done_) { + ESP_LOGW(TAG, "Parameters not read yet, change ignored"); + return false; + } + if (!this->parameters_known_) { + ESP_LOGW(TAG, "The module reported a parameter out of range, changes are disabled"); + return false; + } + this->parameters_[static_cast(parameter)] = value; + this->parameters_dirty_ = true; // written by loop() once no sequence is running + return true; +} +#endif + +void LD2410SComponent::restart_init_(uint32_t at) { + this->init_done_ = false; + this->status_set_warning(); // cleared again when the new init sequence is acknowledged +#ifdef USE_LD2410S_PARAMETERS + // The module's own values count again once it is set up anew + if (this->parameters_dirty_) { + ESP_LOGW(TAG, "Dropping the pending parameter change, the module's values are read back"); + } + this->parameters_dirty_ = false; + this->parameters_known_ = false; // a refused value may sit in parameters_ until the read replaces it +#endif + this->start_sequence_(INIT_SEQUENCE, INIT_SEQUENCE_LENGTH, at); +} + +void LD2410SComponent::start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now) { + this->sequence_ = sequence; + this->sequence_len_ = length; + this->step_ = 0; + this->step_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; +} + +uint16_t LD2410SComponent::current_command_() const { return progmem_read_uint16(&this->sequence_[this->step_]); } + +void LD2410SComponent::run_sequence_(uint32_t now) { + if (this->sequence_ == nullptr || static_cast(now - this->next_send_at_) < 0) { return; } - const uint16_t command = init_command(this->init_step_); + const uint16_t command = this->current_command_(); if (this->awaiting_ack_) { - if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + if (++this->step_timeouts_ > STEP_MAX_TIMEOUTS) { ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, REINIT_PAUSE_MS / 1000); // Leave configuration mode whatever state the module is in, then begin again this->send_command_(CONFIG_MODE_END_CMD); - this->init_step_ = 0; - this->init_timeouts_ = 0; - this->awaiting_ack_ = false; - this->next_send_at_ = now + REINIT_PAUSE_MS; + this->restart_init_(now + REINIT_PAUSE_MS); return; } ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); @@ -120,6 +208,22 @@ void LD2410SComponent::send_command_(uint16_t command) { frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; frame[pos++] = CONFIG_MODE_START_VALUE >> 8; break; +#ifdef USE_LD2410S_PARAMETERS + case PARAMETERS_READ_CMD: + case PARAMETERS_WRITE_CMD: + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint16_t key = progmem_read_uint16(&PARAMETER_KEYS[i]); + frame[pos++] = key & 0xFF; + frame[pos++] = key >> 8; + if (command == PARAMETERS_WRITE_CMD) { + const uint32_t value = this->parameters_[i]; // a 32-bit field on the wire + for (uint8_t shift = 0; shift < 32; shift += 8) { + frame[pos++] = (value >> shift) & 0xFF; + } + } + } + break; +#endif default: break; } @@ -252,27 +356,27 @@ bool LD2410SComponent::handle_frame_() { } bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { - uint8_t state; - if (this->frame_type_ == FrameType::SHORT_DATA) { - // [state][distance low][distance high] - state = payload[0]; - } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { - // [type][state][distance low][distance high]...; the module only sends these before the init - // sequence switches it to minimal output - state = payload[1]; - } else { - if (len >= 1) { - ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + // Short frames carry [state][distance low][distance high]; standard frames put a type byte first, and the + // module only sends them before the init sequence switches it to minimal output + const uint8_t *data = payload; + if (this->frame_type_ == FrameType::STD_DATA) { + if (len < 4 || payload[0] != DATA_TYPE_STANDARD) { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; } - return true; + data = payload + 1; } + const uint8_t state = data[0]; + const uint16_t distance = encode_uint16(data[2], data[1]); // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to // carry the header and footer bytes if (state > 3) { ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); return false; } - this->publish_target_(state > 1); + this->publish_target_(state > 1, distance); return true; } @@ -283,32 +387,147 @@ void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) } const uint16_t command_word = encode_uint16(payload[1], payload[0]); const uint16_t ack = encode_uint16(payload[3], payload[2]); - if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + if (!this->awaiting_ack_) { ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } - if (ack != 0) { - // Leave the command pending so the timeout resends it - ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + const uint16_t command = this->current_command_(); + if (command_word != (command | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); return; } +#ifdef USE_LD2410S_PARAMETERS + if (ack != 0 && command == PARAMETERS_WRITE_CMD) { + // Resending the same values would be rejected again; leave configuration mode and read the + // module's own values back, which republishes them + ESP_LOGW(TAG, "Module rejected the parameters, ack %04X", ack); + this->send_command_(CONFIG_MODE_END_CMD); + this->restart_init_(App.get_loop_component_start_time() + COMMAND_GAP_MS); + return; + } +#endif + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", command, ack); + return; + } + // A short answer leaves the read pending the same way, so init cannot finish without the values +#ifdef USE_LD2410S_PARAMETERS + if (command == PARAMETERS_READ_CMD && !this->handle_parameters_(&payload[4], len - 4)) + return; +#endif +#ifdef USE_LD2410S_VERSION + if (command == VERSION_READ_CMD && !this->handle_version_(&payload[4], len - 4)) + return; +#endif this->awaiting_ack_ = false; - this->init_timeouts_ = 0; - this->init_step_++; - this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; - if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + this->step_timeouts_ = 0; + const uint32_t now = App.get_loop_component_start_time(); + this->next_send_at_ = now + COMMAND_GAP_MS; + if (++this->step_ < this->sequence_len_) { + return; + } + const bool was_init = this->sequence_ == INIT_SEQUENCE; + this->sequence_ = nullptr; + if (was_init) { ESP_LOGD(TAG, "Setup done"); - this->last_frame_at_ = App.get_loop_component_start_time(); + this->init_done_ = true; + this->last_frame_at_ = now; this->status_clear_warning(); } } -void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_LD2410S_PARAMETERS +// The answer lists the parameter values in key order, 32 bits each; returns false when it is cut short +bool LD2410SComponent::handle_parameters_(const uint8_t *values, uint16_t len) { + if (len < PARAMETER_COUNT * PARAMETER_VALUE_SIZE) { + ESP_LOGW(TAG, "Parameter answer too short (%u bytes)", len); + return false; + } + // Every parameter must fit the low byte of its little-endian value; otherwise nothing is stored or + // published and writes stay refused, so a cut-down value is never shown or sent back + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + const uint8_t *v = &values[i * PARAMETER_VALUE_SIZE]; + if ((v[1] | v[2] | v[3]) != 0) { + ESP_LOGW(TAG, "Parameter %u out of range: %" PRIu32 ", changes stay disabled", i, + encode_uint32(v[3], v[2], v[1], v[0])); + this->parameters_known_ = false; +#ifdef USE_NUMBER + // A value a number showed before the module answered is not confirmed + for (auto *number : this->numbers_) { + if (number != nullptr) { + number->publish_state(NAN); + } + } +#endif + return true; + } + } + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + this->parameters_[i] = values[i * PARAMETER_VALUE_SIZE]; + } + this->parameters_known_ = true; + this->publish_parameters_(); + ESP_LOGD(TAG, "Parameters: gates %u to %u, no delay %u s, frequencies %u/%u dHz, response speed %u", + this->parameters_[1], this->parameters_[0], this->parameters_[2], this->parameters_[3], this->parameters_[4], + this->parameters_[5]); + return true; +} +#endif + +#ifdef USE_LD2410S_VERSION +// Returns false when the answer is cut short +bool LD2410SComponent::handle_version_(const uint8_t *values, uint16_t len) { + if (len < VERSION_SIZE) { + ESP_LOGW(TAG, "Version answer too short (%u bytes)", len); + return false; + } + const uint8_t *v = &values[VERSION_TYPE_SIZE]; + char version[18]; + snprintf(version, sizeof(version), "%u.%u.%u", encode_uint16(v[1], v[0]), encode_uint16(v[3], v[2]), + encode_uint16(v[5], v[4])); + ESP_LOGD(TAG, "Firmware %s", version); + if (this->version_text_sensor_ != nullptr) { + this->version_text_sensor_->publish_state(version); + } + return true; +} +#endif + +void LD2410SComponent::publish_target_(bool target, uint16_t distance) { #ifdef USE_BINARY_SENSOR if (this->target_binary_sensor_ != nullptr) { this->target_binary_sensor_->publish_state(target); } #endif +#ifdef USE_SENSOR + // The module keeps reporting the last distance after the target left + SAFE_PUBLISH_SENSOR(this->distance_sensor_, target ? distance : 0); +#endif } +#ifdef USE_LD2410S_PARAMETERS +void LD2410SComponent::publish_parameters_() { +#ifdef USE_NUMBER + for (uint8_t i = 0; i < PARAMETER_COUNT; i++) { + if (this->numbers_[i] != nullptr) { + this->numbers_[i]->publish_raw(this->parameters_[i]); + } + } +#endif +#ifdef USE_SELECT + if (this->response_speed_select_ != nullptr) { + const uint8_t speed = this->parameters_[static_cast(Parameter::PARAMETER_RESPONSE_SPEED)]; + for (uint8_t i = 0; i < RESPONSE_SPEED_COUNT; i++) { + if (progmem_read_byte(&RESPONSE_SPEEDS[i]) == speed) { + this->response_speed_select_->publish_state(i); + return; + } + } + ESP_LOGV(TAG, "Unknown response speed %u", speed); + } +#endif +} +#endif + } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h index 4af809836ce..9a0a2569742 100644 --- a/esphome/components/ld2410s/ld2410s.h +++ b/esphome/components/ld2410s/ld2410s.h @@ -2,10 +2,21 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/ld24xx/ld24xx.h" +#endif +#ifdef USE_LD2410S_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) +#include "esphome/components/select/select.h" +#endif namespace esphome::ld2410s { @@ -13,16 +24,55 @@ namespace esphome::ld2410s { static constexpr size_t RX_BUFFER_SIZE = 96; static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); +/// The module's common parameters, in the order the parameter read answers them +enum class Parameter : uint8_t { + PARAMETER_MAX_DISTANCE, + PARAMETER_MIN_DISTANCE, + PARAMETER_NO_DELAY, + PARAMETER_STATUS_FREQUENCY, + PARAMETER_DISTANCE_FREQUENCY, + PARAMETER_RESPONSE_SPEED, + PARAMETER_COUNT, +}; +static constexpr uint8_t PARAMETER_COUNT = static_cast(Parameter::PARAMETER_COUNT); +// Response speed values, in the order of the select's options (Normal, Fast) +static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10}; +static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS); + +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) +class LD2410SNumber; +#endif + class LD2410SComponent final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(target) #endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(distance, uint16_t) +#endif +#ifdef USE_LD2410S_VERSION + SUB_TEXT_SENSOR(version) +#endif +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + SUB_SELECT(response_speed) +#endif public: void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence void loop() override; void dump_config() override; + /// Store a parameter in the module's units; loop() then writes all of them to the module. + /// Returns false, and changes nothing, until the module's own values have been read. +#ifdef USE_LD2410S_PARAMETERS + bool set_parameter(Parameter parameter, uint8_t value); +#ifdef USE_NUMBER + void set_number(Parameter parameter, LD2410SNumber *number) { + this->numbers_[static_cast(parameter)] = number; + } +#endif +#endif + protected: enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; @@ -32,6 +82,12 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. bool handle_data_frame_(const uint8_t *payload, uint16_t len); void handle_command_ack_(const uint8_t *payload, uint16_t len); +#ifdef USE_LD2410S_PARAMETERS + bool handle_parameters_(const uint8_t *values, uint16_t len); +#endif +#ifdef USE_LD2410S_VERSION + bool handle_version_(const uint8_t *values, uint16_t len); +#endif void count_dropped_frame_() { if (this->dropped_frames_ < UINT8_MAX) { this->dropped_frames_++; @@ -42,21 +98,41 @@ class LD2410SComponent final : public Component, public uart::UARTDevice { this->expected_len_ = 0; this->frame_type_ = FrameType::NONE; } - void run_init_sequence_(uint32_t now); + void start_sequence_(const uint16_t *sequence, uint8_t length, uint32_t now); + void restart_init_(uint32_t at); + void run_sequence_(uint32_t now); + uint16_t current_command_() const; void send_command_(uint16_t command); - void publish_target_(bool target); + void publish_target_(bool target, uint16_t distance); +#ifdef USE_LD2410S_PARAMETERS + void publish_parameters_(); +#endif +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number +#endif + + const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent uint32_t next_send_at_{0}; uint32_t last_frame_at_{0}; +#ifdef USE_LD2410S_PARAMETERS + uint8_t parameters_[PARAMETER_COUNT]{}; // all fit a byte: gates, seconds, tenths of a hertz, speed +#endif uint8_t rx_buffer_[RX_BUFFER_SIZE]; uint8_t rx_len_{0}; uint8_t expected_len_{0}; - uint8_t init_step_{0}; - uint8_t init_timeouts_{0}; + uint8_t sequence_len_{0}; + uint8_t step_{0}; + uint8_t step_timeouts_{0}; uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 FrameType frame_type_{FrameType::NONE}; bool awaiting_ack_{false}; bool rescanning_{false}; + bool init_done_{false}; +#ifdef USE_LD2410S_PARAMETERS + bool parameters_known_{false}; // the module answered the parameter read at least once + bool parameters_dirty_{false}; +#endif }; } // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s_number.h b/esphome/components/ld2410s/ld2410s_number.h new file mode 100644 index 00000000000..bc24e300cae --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_number.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/number/number.h" + +namespace esphome::ld2410s { + +/// Edits one common parameter; scale converts the module's units to the number's. +class LD2410SNumber final : public number::Number, public Parented { + public: + LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {} + + void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); } + + protected: + void control(float value) override { + // Show the step the module got, not a value between two steps + const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte + if (this->parent_->set_parameter(this->parameter_, raw)) + this->publish_raw(raw); + } + + const Parameter parameter_; + const float scale_; +}; + +} // namespace esphome::ld2410s + +#endif // USE_NUMBER && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/ld2410s_select.h b/esphome/components/ld2410s/ld2410s_select.h new file mode 100644 index 00000000000..e2598b917a8 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s_select.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS) + +#include "ld2410s.h" +#include "esphome/components/select/select.h" + +namespace esphome::ld2410s { + +class LD2410SSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override { + if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index]))) + this->publish_state(index); + } +}; + +} // namespace esphome::ld2410s + +#endif // USE_SELECT && USE_LD2410S_PARAMETERS diff --git a/esphome/components/ld2410s/number.py b/esphome/components/ld2410s/number.py new file mode 100644 index 00000000000..912e1397348 --- /dev/null +++ b/esphome/components/ld2410s/number.py @@ -0,0 +1,108 @@ +from functools import partial + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_FREQUENCY, + ENTITY_CATEGORY_CONFIG, + ICON_PULSE, + ICON_TIMELAPSE, + UNIT_HERTZ, + UNIT_METER, + UNIT_SECOND, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_DISTANCE_REPORTING_FREQUENCY = "distance_reporting_frequency" +CONF_MAX_DISTANCE = "max_distance" +CONF_MIN_DISTANCE = "min_distance" +CONF_NO_DELAY = "no_delay" +CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency" + +LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number) + +# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz +GATE_WIDTH_M = 0.7 +FREQUENCY_SCALE = 0.1 +_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M} +_FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5} + + +def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema: + return number.number_schema( + LD2410SNumber, + device_class=device_class, + entity_category=ENTITY_CATEGORY_CONFIG, + unit_of_measurement=unit, + icon=icon, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_MAX_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-right" + ), + cv.Optional(CONF_MIN_DISTANCE): _number_schema( + DEVICE_CLASS_DISTANCE, UNIT_METER, "mdi:arrow-collapse-left" + ), + cv.Optional(CONF_NO_DELAY): _number_schema( + DEVICE_CLASS_DURATION, UNIT_SECOND, ICON_TIMELAPSE + ), + cv.Optional(CONF_STATUS_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), + cv.Optional(CONF_DISTANCE_REPORTING_FREQUENCY): _number_schema( + DEVICE_CLASS_FREQUENCY, UNIT_HERTZ, ICON_PULSE + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_MAX_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE), + Parameter.PARAMETER_MAX_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_MIN_DISTANCE, + partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE), + Parameter.PARAMETER_MIN_DISTANCE, + GATE_WIDTH_M, + **_DISTANCE_RANGE, + ) + await numbers( + CONF_NO_DELAY, + partial(hub.set_number, Parameter.PARAMETER_NO_DELAY), + Parameter.PARAMETER_NO_DELAY, + 1, + min_value=1, + max_value=120, + step=1, + ) + await numbers( + CONF_STATUS_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY), + Parameter.PARAMETER_STATUS_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) + await numbers( + CONF_DISTANCE_REPORTING_FREQUENCY, + partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY), + Parameter.PARAMETER_DISTANCE_FREQUENCY, + FREQUENCY_SCALE, + **_FREQUENCY_RANGE, + ) diff --git a/esphome/components/ld2410s/select.py b/esphome/components/ld2410s/select.py new file mode 100644 index 00000000000..4a1376b9bf2 --- /dev/null +++ b/esphome/components/ld2410s/select.py @@ -0,0 +1,31 @@ +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 . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns + +DEPENDENCIES = ["ld2410s"] + +CONF_RESPONSE_SPEED = "response_speed" + +LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_RESPONSE_SPEED): select.select_schema( + LD2410SSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon="mdi:speedometer", + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_PARAMETERS") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"] + ) diff --git a/esphome/components/ld2410s/sensor.py b/esphome/components/ld2410s/sensor.py new file mode 100644 index 00000000000..96eccc08f41 --- /dev/null +++ b/esphome/components/ld2410s/sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DISTANCE, + DEVICE_CLASS_DISTANCE, + ICON_SIGNAL_DISTANCE_VARIANT, + STATE_CLASS_MEASUREMENT, + UNIT_CENTIMETER, +) +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_DISTANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_CENTIMETER, + icon=ICON_SIGNAL_DISTANCE_VARIANT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) diff --git a/esphome/components/ld2410s/text_sensor.py b/esphome/components/ld2410s/text_sensor.py new file mode 100644 index 00000000000..d017d3f1a6c --- /dev/null +++ b/esphome/components/ld2410s/text_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LD2410S_VERSION") + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 072abe83742..1a5ab82d934 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -97,6 +97,8 @@ #define USE_JSON #define USE_JSON_ARENA #define USE_RADIO_FREQUENCY +#define USE_LD2410S_PARAMETERS +#define USE_LD2410S_VERSION #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT diff --git a/tests/components/ld2410s/common-bare.yaml b/tests/components/ld2410s/common-bare.yaml new file mode 100644 index 00000000000..2f0e569bb98 --- /dev/null +++ b/tests/components/ld2410s/common-bare.yaml @@ -0,0 +1,28 @@ +ld2410s: + id: ld2410s_bare + uart_id: uart_bus + +binary_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_bare + has_target: + name: Has Target + +# Entities of other platforms must not pull the parameter code into a bare hub +number: + - platform: template + name: Unrelated Number + optimistic: true + min_value: 0 + max_value: 10 + step: 1 + +select: + - platform: template + name: Unrelated Select + optimistic: true + options: ["a", "b"] + +text_sensor: + - platform: template + name: Unrelated Text diff --git a/tests/components/ld2410s/common.yaml b/tests/components/ld2410s/common.yaml index 33b213d363c..5ae96497103 100644 --- a/tests/components/ld2410s/common.yaml +++ b/tests/components/ld2410s/common.yaml @@ -5,3 +5,31 @@ binary_sensor: - platform: ld2410s has_target: name: Presence + +sensor: + - platform: ld2410s + distance: + name: Distance + +text_sensor: + - platform: ld2410s + version: + name: Firmware + +number: + - platform: ld2410s + max_distance: + name: Max Distance + min_distance: + name: Min Distance + no_delay: + name: No Delay + status_reporting_frequency: + name: Status Reporting Frequency + distance_reporting_frequency: + name: Distance Reporting Frequency + +select: + - platform: ld2410s + response_speed: + name: Response Speed diff --git a/tests/components/ld2410s/test-bare.esp8266-ard.yaml b/tests/components/ld2410s/test-bare.esp8266-ard.yaml new file mode 100644 index 00000000000..bda2f21bb48 --- /dev/null +++ b/tests/components/ld2410s/test-bare.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + ld2410s: !include common-bare.yaml diff --git a/tests/integration/fixtures/uart_mock_ld2410s.yaml b/tests/integration/fixtures/uart_mock_ld2410s.yaml index 113e0881e6f..858a689b395 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s.yaml @@ -22,16 +22,58 @@ uart_mock: id: mock_uart baud_rate: 115200 auto_start: false + on_tx: + - then: + - lambda: |- + // Parameter read: the first answer stops after one value so the read is resent after the + // 300 ms timeout; the resend gets gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + static const std::vector PARAMETERS_READ = { + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, + 0x06, 0x00, 0x02, 0x00, 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01}; + static int reads = 0; + if (data != PARAMETERS_READ) { + return; + } + if (++reads == 1) { + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01}); + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01}); responses: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # The parameter read is answered by the on_tx lambda below: cut short once, then in full # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] # Config-mode end, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + # Setting the max distance to 7 m writes all six parameters inside a configuration-mode session + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00, + 0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] injections: # Short frame, state 2 (target), distance 300 -> presence on - delay: 100ms @@ -76,6 +118,26 @@ binary_sensor: has_target: name: "Has Target" +sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + distance: + name: "Distance" + +text_sensor: + - platform: ld2410s + ld2410s_id: ld2410s_dev + version: + name: "Version" + +number: + - platform: ld2410s + ld2410s_id: ld2410s_dev + max_distance: + name: "Max Distance" + no_delay: + name: "No Delay" + button: - platform: template name: "Start Scenario" diff --git a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml index 5f06985c7b5..57b66f0d9dd 100644 --- a/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml +++ b/tests/integration/fixtures/uart_mock_ld2410s_link_lost.yaml @@ -26,6 +26,25 @@ uart_mock: # Config-mode start, acknowledged with protocol version 2 and buffer size 0x20 - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x02, 0x00, 0x20, 0x00, 0x04, 0x03, 0x02, 0x01] + # Version read, answered with device type 1 and firmware 1.2.3 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + # Parameter read, answered with gates 0 to 16, no delay 10 s, 8 Hz reporting, normal speed + - expect_tx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x0E, 0x00, 0x71, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x06, 0x00, 0x02, 0x00, + 0x0C, 0x00, 0x0B, 0x00, 0x04, 0x03, 0x02, 0x01, + ] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, 0x1C, 0x00, 0x71, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x05, 0x00, + 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + ] # Output-mode switch to minimal, acknowledged - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x7A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x7A, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01] diff --git a/tests/integration/test_uart_mock_ld2410s.py b/tests/integration/test_uart_mock_ld2410s.py index 8cd9b4b8dd1..44b808cd4c7 100644 --- a/tests/integration/test_uart_mock_ld2410s.py +++ b/tests/integration/test_uart_mock_ld2410s.py @@ -1,9 +1,13 @@ """Integration tests for the LD2410S component with mock UART. test_uart_mock_ld2410s: - 1. Init sequence - config-mode start, output-mode switch and config-mode end go - out in order, each after the previous one is acknowledged, then the - component logs "Setup done" and clears its warning + 1. Init sequence - config-mode start, version read, parameter read, output-mode + switch and config-mode end go out in order, each after the previous one is + acknowledged, then the component logs "Setup done" and clears its warning; + the version and parameter answers are parsed and logged + 1b. Setting the max distance number to 7 m writes all six parameters (gate 10 + plus the values read at init) inside a configuration-mode session, and the + number reports 7 m 2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard data frame 3. Resync - a noise byte or a partial header directly before a short frame @@ -34,15 +38,40 @@ from __future__ import annotations import asyncio import re -from aioesphomeapi import APIClient, ButtonInfo +from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState import pytest from .state_utils import InitialStateHelper, SensorStateCollector, require_entity from .types import APIClientConnectedFactory, RunCompiledFunction CONFIG_START_TX = "FD:FC:FB:FA:04:00:FF:00:01:00:04:03:02:01" +VERSION_READ_TX = "FD:FC:FB:FA:02:00:00:00:04:03:02:01" +PARAMETERS_READ_TX = ( + "FD:FC:FB:FA:0E:00:71:00:05:00:0A:00:06:00:02:00:0C:00:0B:00:04:03:02:01" +) OUTPUT_MODE_TX = "FD:FC:FB:FA:08:00:7A:00:00:00:00:00:00:00:04:03:02:01" CONFIG_END_TX = "FD:FC:FB:FA:02:00:FE:00:04:03:02:01" +INIT_TX = [ + CONFIG_START_TX, + VERSION_READ_TX, + # The fixture cuts the first answer short, so the read goes out again + PARAMETERS_READ_TX, + PARAMETERS_READ_TX, + OUTPUT_MODE_TX, + CONFIG_END_TX, +] +# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5 +PARAMETERS_WRITE_TX = ( + "FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:" + "0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01" +) +WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX] +# Logged once the version and parameter answers are parsed +PARSED_ANSWERS = [ + "Firmware 1.2.3", + "Parameter answer too short (4 bytes)", + "Parameters: gates 0 to 16, no delay 10 s", +] ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") @@ -119,12 +148,17 @@ async def test_uart_mock_ld2410s( setup_done = loop.create_future() warning_cleared = loop.create_future() dropped_states: list[str] = [] + parsed_answers: list[str] = [] + write_done = loop.create_future() def line_callback(line: str) -> None: if (frame := _tx_frame(line)) is not None: tx_frames.append(frame) + if len(tx_frames) >= 9 and not write_done.done(): + write_done.set_result(True) if "[ld2410s" in line and "Setup done" in line and not setup_done.done(): setup_done.set_result(True) + parsed_answers.extend(answer for answer in PARSED_ANSWERS if answer in line) if "ld2410s cleared Warning flag" in line and not warning_cleared.done(): warning_cleared.set_result(True) if "Dropping frame with impossible state " in line: @@ -133,8 +167,15 @@ async def test_uart_mock_ld2410s( ) collector = SensorStateCollector( - sensor_names=[], binary_sensor_names=["has_target"] + sensor_names=["distance"], binary_sensor_names=["has_target"] ) + number_states: list[float] = [] + + def on_state(state: EntityState) -> None: + collector.on_state(state) + if isinstance(state, NumberState) and state.key == max_distance_key: + number_states.append(state.state) + all_presence = collector.add_waiter( lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE) ) @@ -148,17 +189,41 @@ async def test_uart_mock_ld2410s( except TimeoutError: pytest.fail(f"Init sequence did not finish. TX frames: {tx_frames}") - assert tx_frames[:3] == [CONFIG_START_TX, OUTPUT_MODE_TX, CONFIG_END_TX] + assert tx_frames[:6] == INIT_TX + assert parsed_answers == PARSED_ANSWERS - await _start_scenario(client, collector) + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + max_distance_key = require_entity(entities, "max_distance", NumberInfo).key + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + # The values read from the module: gate 16 and 10 s + no_delay_key = require_entity(entities, "no_delay", NumberInfo).key + initial = initial_state_helper.initial_states + assert initial[max_distance_key].state == pytest.approx(11.2) + assert initial[no_delay_key].state == pytest.approx(10) + + client.number_command(max_distance_key, 7.0) + try: + await asyncio.wait_for(write_done, 5.0) + except TimeoutError: + pytest.fail(f"Parameter write not sent. TX frames: {tx_frames}") + assert tx_frames[6:9] == WRITE_TX, tx_frames + assert number_states == [pytest.approx(7.0)] + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) await _wait_for_presence(collector, all_presence) # Let any unexpected extra update arrive before comparing await asyncio.sleep(0.3) assert collector.binary_states["has_target"] == EXPECTED_PRESENCE assert dropped_states == EXPECTED_DROPPED_STATES + # A target 300 cm away, then none: the distance follows the presence + assert collector.sensor_states["distance"][:2] == [300, 0] # Data frames must not restart the init sequence - assert len(tx_frames) == 3, tx_frames + assert len(tx_frames) == 9, tx_frames @pytest.mark.asyncio From 74e96b7da6b3986ea9d20a8b2338f9ca836ea405 Mon Sep 17 00:00:00 2001 From: rwalker777 <49888088+rwalker777@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:21:00 -0500 Subject: [PATCH 055/111] [INA3221] Add optional flags to expose all functionality (#16309) 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/components/ina3221/ina3221.cpp | 136 ++++++++--- esphome/components/ina3221/ina3221.h | 25 +- esphome/components/ina3221/sensor.py | 224 +++++++++++++++--- esphome/core/defines.h | 2 + tests/components/ina3221/common.yaml | 29 +++ tests/components/ina3221/test.esp32-idf.yaml | 3 +- .../components/ina3221/test.esp8266-ard.yaml | 3 +- tests/components/ina3221/test.rp2040-ard.yaml | 3 +- 8 files changed, 352 insertions(+), 73 deletions(-) diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index f3647c4d1ca..e66e2456767 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -13,6 +13,24 @@ static const uint8_t INA3221_REGISTER_CHANNEL2_SHUNT_VOLTAGE = 0x03; static const uint8_t INA3221_REGISTER_CHANNEL2_BUS_VOLTAGE = 0x04; static const uint8_t INA3221_REGISTER_CHANNEL3_SHUNT_VOLTAGE = 0x05; static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; +#ifdef USE_INA3221_ALERT_LIMITS +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT = 0x07; +static constexpr uint8_t INA3221_REGISTER_CHANNEL1_WARNING_ALERT = 0x0A; +#endif +#ifdef USE_INA3221_SUMMATION +static constexpr uint8_t INA3221_REGISTER_SHUNT_VOLTAGE_SUM = 0x0D; +static constexpr uint8_t INA3221_REGISTER_MASK_ENABLE = 0x0F; +#endif + +// Configuration register: bit 15 resets the chip, bits 14..12 enable channels 1..3 (the same bit +// positions select channels for summation in the mask/enable register) and bits 2..0 set the mode. +// Code generation fills in the averaging and conversion time fields. +static constexpr uint16_t INA3221_CONFIG_RESET = 0x8000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL1_ENABLE = 0x4000; +static constexpr uint16_t INA3221_CONFIG_CHANNEL_MASK = 0x7000; +static constexpr uint16_t INA3221_CONFIG_MODE_MASK = 0x0007; + +static constexpr uint32_t READ_TIMEOUT_ID = 0; // Addresses: // A0 = GND -> 0x40 @@ -21,37 +39,38 @@ static const uint8_t INA3221_REGISTER_CHANNEL3_BUS_VOLTAGE = 0x06; // A0 = SCL -> 0x43 void INA3221Component::setup() { - // Config Register - // 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset) - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, 0x8000)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) { this->mark_failed(); return; } delay(1); - uint16_t config = 0; - // 0b0xxx000000000000 << 12 Channel Enables (1 -> ON) - if (this->channels_[0].exists()) { - config |= 0b0100000000000000; - } - if (this->channels_[1].exists()) { - config |= 0b0010000000000000; - } - if (this->channels_[2].exists()) { - config |= 0b0001000000000000; - } - // 0b0000xxx000000000 << 9 Averaging Mode (0 -> 1 sample, 111 -> 1024 samples) - config |= 0b0000000000000000; - // 0b0000000xxx000000 << 6 Bus Voltage Conversion time (100 -> 1.1ms, 111 -> 8.244 ms) - config |= 0b0000000111000000; - // 0b0000000000xxx000 << 3 Shunt Voltage Conversion time (same as above) - config |= 0b0000000000111000; - // 0b0000000000000xxx << 0 Operating mode (111 -> Shunt and bus, continuous) - config |= 0b0000000000000111; - if (!this->write_byte_16(INA3221_REGISTER_CONFIG, config)) { + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { this->mark_failed(); return; } + +#ifdef USE_INA3221_ALERT_LIMITS + for (uint8_t i = 0; i < 3; i++) { + const INA3221Channel &channel = this->channels_[i]; + if ((channel.critical_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_CRITICAL_ALERT + i, channel.critical_limit_)) || + (channel.warning_limit_ != 0 && + !this->write_byte_16(INA3221_REGISTER_CHANNEL1_WARNING_ALERT + i, channel.warning_limit_))) { + this->mark_failed(); + return; + } + } +#endif + +#ifdef USE_INA3221_SUMMATION + // Sum the shunt voltages of every enabled channel + if (this->sum_shunt_voltage_sensor_ != nullptr && + !this->write_byte_16(INA3221_REGISTER_MASK_ENABLE, this->config_ & INA3221_CONFIG_CHANNEL_MASK)) { + this->mark_failed(); + return; + } +#endif } void INA3221Component::dump_config() { @@ -61,6 +80,10 @@ void INA3221Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, " Config register: 0x%04X", this->config_); + if (this->single_shot_wait_ms_ != 0) { + ESP_LOGCONFIG(TAG, " Single-shot conversion: %u ms", this->single_shot_wait_ms_); + } LOG_SENSOR(" ", "Bus Voltage #1", this->channels_[0].bus_voltage_sensor_); LOG_SENSOR(" ", "Shunt Voltage #1", this->channels_[0].shunt_voltage_sensor_); @@ -74,6 +97,11 @@ void INA3221Component::dump_config() { LOG_SENSOR(" ", "Shunt Voltage #3", this->channels_[2].shunt_voltage_sensor_); LOG_SENSOR(" ", "Current #3", this->channels_[2].current_sensor_); LOG_SENSOR(" ", "Power #3", this->channels_[2].power_sensor_); +#ifdef USE_INA3221_SUMMATION + LOG_SENSOR(" ", "Sum Shunt Voltage", this->sum_shunt_voltage_sensor_); + LOG_SENSOR(" ", "Sum Current", this->sum_current_sensor_); + LOG_SENSOR(" ", "Sum Power", this->sum_power_sensor_); +#endif } inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel * 2; } @@ -81,11 +109,36 @@ inline uint8_t ina3221_bus_voltage_register(int channel) { return 0x02 + channel inline uint8_t ina3221_shunt_voltage_register(int channel) { return 0x01 + channel * 2; } void INA3221Component::update() { + if (this->single_shot_wait_ms_ == 0) { + this->read_data_(); + return; + } + // In single-shot mode, writing the configuration register starts one conversion of every channel + if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TIMEOUT_ID, this->single_shot_wait_ms_, [this]() { this->read_data_(); }); +} + +void INA3221Component::read_data_() { +#ifdef USE_INA3221_SUMMATION + // The software sums need every enabled channel's readings, not only the ones with their own sensors + const bool sum_all = this->sum_current_sensor_ != nullptr || this->sum_power_sensor_ != nullptr; + float total_current_a = 0.0f; + float total_power_w = 0.0f; +#else + constexpr bool sum_all = false; +#endif + for (int i = 0; i < 3; i++) { + if ((this->config_ & (INA3221_CONFIG_CHANNEL1_ENABLE >> i)) == 0) { + continue; + } INA3221Channel &channel = this->channels_[i]; float bus_voltage_v = NAN, current_a = NAN; uint16_t raw; - if (channel.should_measure_bus_voltage()) { + if (sum_all || channel.should_measure_bus_voltage()) { if (!this->read_byte_16(ina3221_bus_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -94,7 +147,7 @@ void INA3221Component::update() { if (channel.bus_voltage_sensor_ != nullptr) channel.bus_voltage_sensor_->publish_state(bus_voltage_v); } - if (channel.should_measure_shunt_voltage()) { + if (sum_all || channel.should_measure_shunt_voltage()) { if (!this->read_byte_16(ina3221_shunt_voltage_register(i), &raw)) { this->status_set_warning(); return; @@ -109,17 +162,42 @@ void INA3221Component::update() { if (channel.power_sensor_ != nullptr) { channel.power_sensor_->publish_state(bus_voltage_v * current_a); } +#ifdef USE_INA3221_SUMMATION + if (sum_all) { + total_current_a += current_a; + total_power_w += bus_voltage_v * current_a; + } +#endif } + +#ifdef USE_INA3221_SUMMATION + if (this->sum_shunt_voltage_sensor_ != nullptr) { + uint16_t raw; + if (!this->read_byte_16(INA3221_REGISTER_SHUNT_VOLTAGE_SUM, &raw)) { + this->status_set_warning(); + return; + } + // 40 uV per step, held in bits 15..1 + this->sum_shunt_voltage_sensor_->publish_state(int16_t(raw) * 40.0f / 2.0f / 1000000.0f); + } + if (this->sum_current_sensor_ != nullptr) { + this->sum_current_sensor_->publish_state(total_current_a); + } + if (this->sum_power_sensor_ != nullptr) { + this->sum_power_sensor_->publish_state(total_power_w); + } +#endif +} + +void INA3221Component::on_powerdown() { + // Mode 0 powers the chip down; setup() resets it on the next boot + this->write_byte_16(INA3221_REGISTER_CONFIG, static_cast(this->config_ & ~INA3221_CONFIG_MODE_MASK)); } void INA3221Component::set_shunt_resistance(int channel, float resistance_ohm) { this->channels_[channel].shunt_resistance_ = resistance_ohm; } -bool INA3221Component::INA3221Channel::exists() { - return this->bus_voltage_sensor_ != nullptr || this->shunt_voltage_sensor_ != nullptr || - this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; -} bool INA3221Component::INA3221Channel::should_measure_shunt_voltage() { return this->shunt_voltage_sensor_ != nullptr || this->current_sensor_ != nullptr || this->power_sensor_ != nullptr; } diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 48226c743a9..759dd6c13da 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -7,10 +7,17 @@ namespace esphome::ina3221 { class INA3221Component final : public PollingComponent, public i2c::I2CDevice { +#ifdef USE_INA3221_SUMMATION + SUB_SENSOR(sum_shunt_voltage) + SUB_SENSOR(sum_current) + SUB_SENSOR(sum_power) +#endif + public: void setup() override; void dump_config() override; void update() override; + void on_powerdown() override; void set_bus_voltage_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].bus_voltage_sensor_ = obj; } void set_shunt_voltage_sensor(int channel, sensor::Sensor *obj) { @@ -19,19 +26,35 @@ class INA3221Component final : public PollingComponent, public i2c::I2CDevice { void set_current_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].current_sensor_ = obj; } void set_power_sensor(int channel, sensor::Sensor *obj) { this->channels_[channel].power_sensor_ = obj; } void set_shunt_resistance(int channel, float resistance_ohm); + /// Configuration register value built by code generation: channel enables, averaging, conversion times, mode + void set_config_register(uint16_t config) { this->config_ = config; } + /// Time one single-shot conversion of every enabled channel takes; zero means continuous mode + void set_single_shot_wait_ms(uint16_t wait_ms) { this->single_shot_wait_ms_ = wait_ms; } +#ifdef USE_INA3221_ALERT_LIMITS + void set_warning_limit(int channel, uint16_t reg) { this->channels_[channel].warning_limit_ = reg; } + void set_critical_limit(int channel, uint16_t reg) { this->channels_[channel].critical_limit_ = reg; } +#endif protected: + void read_data_(); + struct INA3221Channel { float shunt_resistance_{0.1f}; sensor::Sensor *bus_voltage_sensor_{nullptr}; sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; +#ifdef USE_INA3221_ALERT_LIMITS + // Alert limit register values; zero keeps the chip default, which never trips + uint16_t warning_limit_{0}; + uint16_t critical_limit_{0}; +#endif - bool exists(); bool should_measure_shunt_voltage(); bool should_measure_bus_voltage(); } channels_[3]; + uint16_t config_{0}; + uint16_t single_shot_wait_ms_{0}; }; } // namespace esphome::ina3221 diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index db8dad2f542..e872b73b09f 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -1,5 +1,8 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -8,6 +11,7 @@ from esphome.const import ( CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, + CONF_UPDATE_INTERVAL, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, DEVICE_CLASS_VOLTAGE, @@ -23,69 +27,195 @@ DEPENDENCIES = ["i2c"] CONF_CHANNEL_1 = "channel_1" CONF_CHANNEL_2 = "channel_2" CONF_CHANNEL_3 = "channel_3" +CONF_CONTINUOUS_MODE = "continuous_mode" +CONF_WARNING_CURRENT_LIMIT = "warning_current_limit" +CONF_CRITICAL_CURRENT_LIMIT = "critical_current_limit" +CONF_SUM_SHUNT_VOLTAGE = "sum_shunt_voltage" +CONF_SUM_CURRENT = "sum_current" +CONF_SUM_POWER = "sum_power" + +CHANNELS = (CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3) + +# Configuration register layout: channel enables in bits 14..12, averaging in 11..9, +# bus conversion time in 8..6, shunt conversion time in 5..3, operating mode in 2..0 +CHANNEL_ENABLE_BITS = (1 << 14, 1 << 13, 1 << 12) +ADC_AVERAGING_SHIFT = 9 +ADC_TIME_BUS_SHIFT = 6 +ADC_TIME_SHUNT_SHIFT = 3 +MODE_SINGLE_SHOT = 0b011 +MODE_CONTINUOUS = 0b111 + +# Register field value by sample count and by conversion time in microseconds +ADC_AVERAGING = {1: 0, 4: 1, 16: 2, 64: 3, 128: 4, 256: 5, 512: 6, 1024: 7} +ADC_TIMES_US = {140: 0, 204: 1, 332: 2, 588: 3, 1100: 4, 2116: 5, 4156: 6, 8244: 7} + +# Alert limits are shunt voltages in 40 uV steps, held in bits 15..3 +ALERT_LIMIT_LSB_V = 40e-6 +ALERT_LIMIT_MAX_V = 0x0FFF * ALERT_LIMIT_LSB_V +# Headroom on top of the computed conversion time before the results are read +SINGLE_SHOT_MARGIN_MS = 5 ina3221_ns = cg.esphome_ns.namespace("ina3221") INA3221Component = ina3221_ns.class_( "INA3221Component", cg.PollingComponent, i2c.I2CDevice ) -INA3221_CHANNEL_SCHEMA = cv.Schema( - { - cv.Optional(CONF_BUS_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_POWER): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=2, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( - cv.resistance, cv.Range(min=0.0, max=32.0) - ), - } +VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +CURRENT_LIMIT_SCHEMA = cv.All(cv.current, cv.Range(min=0.0, min_included=False)) + + +def validate_adc_time(value: Any) -> int: + value = cv.positive_time_period_microseconds(value).total_microseconds + return cv.one_of(*ADC_TIMES_US, int=True)(value) + + +def validate_alert_limits(config: ConfigType) -> ConfigType: + shunt = config[CONF_SHUNT_RESISTANCE] + for key in (CONF_WARNING_CURRENT_LIMIT, CONF_CRITICAL_CURRENT_LIMIT): + if (limit := config.get(key)) is None: + continue + if limit * shunt > ALERT_LIMIT_MAX_V: + raise cv.Invalid( + f"{key} times shunt_resistance exceeds the " + f"{ALERT_LIMIT_MAX_V * 1000:.1f} mV alert range", + path=[key], + ) + if alert_limit_register(limit, shunt) == 0: + raise cv.Invalid( + f"{key} times shunt_resistance is below the " + f"{ALERT_LIMIT_LSB_V * 1e6:.0f} µV alert resolution", + path=[key], + ) + return config + + +INA3221_CHANNEL_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_BUS_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_POWER): POWER_SCHEMA, + cv.Optional(CONF_SHUNT_RESISTANCE, default=0.1): cv.All( + cv.resistance, cv.Range(min=0.0, max=32.0) + ), + cv.Optional(CONF_WARNING_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + cv.Optional(CONF_CRITICAL_CURRENT_LIMIT): CURRENT_LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key( + CONF_BUS_VOLTAGE, + CONF_SHUNT_VOLTAGE, + CONF_CURRENT, + CONF_POWER, + CONF_WARNING_CURRENT_LIMIT, + CONF_CRITICAL_CURRENT_LIMIT, + ), + validate_alert_limits, ) -CONFIG_SCHEMA = ( + +def adc_times_us(config: ConfigType) -> tuple[int, int]: + """Return the bus and shunt conversion times in microseconds.""" + adc_time = config[CONF_ADC_TIME] + if isinstance(adc_time, dict): + return adc_time[CONF_BUS_VOLTAGE], adc_time[CONF_SHUNT_VOLTAGE] + return adc_time, adc_time + + +def single_shot_wait_ms(config: ConfigType) -> int: + """Time one conversion of every enabled channel takes, plus headroom.""" + bus_us, shunt_us = adc_times_us(config) + channels = sum(1 for channel in CHANNELS if channel in config) + total_us = (bus_us + shunt_us) * config[CONF_ADC_AVERAGING] * channels + return -(-total_us // 1000) + SINGLE_SHOT_MARGIN_MS + + +def validate_single_shot_interval(config: ConfigType) -> ConfigType: + if config[CONF_CONTINUOUS_MODE]: + return config + wait_ms = single_shot_wait_ms(config) + if config[CONF_UPDATE_INTERVAL].total_milliseconds <= wait_ms: + raise cv.Invalid( + f"A single-shot conversion of all channels takes {wait_ms} ms, which does " + f"not fit in the update_interval; lower {CONF_ADC_AVERAGING} or " + f"{CONF_ADC_TIME}, or raise the interval", + path=[CONF_UPDATE_INTERVAL], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(INA3221Component), cv.Optional(CONF_CHANNEL_1): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_2): INA3221_CHANNEL_SCHEMA, cv.Optional(CONF_CHANNEL_3): INA3221_CHANNEL_SCHEMA, + cv.Optional(CONF_CONTINUOUS_MODE, default=True): cv.boolean, + cv.Optional(CONF_ADC_AVERAGING, default=1): cv.one_of( + *ADC_AVERAGING, int=True + ), + cv.Optional(CONF_ADC_TIME, default="8244us"): cv.Any( + validate_adc_time, + cv.Schema( + { + cv.Required(CONF_BUS_VOLTAGE): validate_adc_time, + cv.Required(CONF_SHUNT_VOLTAGE): validate_adc_time, + } + ), + ), + cv.Optional(CONF_SUM_SHUNT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_SUM_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_SUM_POWER): POWER_SCHEMA, } ) .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_least_one_key(*CHANNELS), + validate_single_shot_interval, ) +def alert_limit_register(current_a: float, shunt_ohm: float) -> int: + return round(current_a * shunt_ohm / ALERT_LIMIT_LSB_V) << 3 + + 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) - for i, channel in enumerate([CONF_CHANNEL_1, CONF_CHANNEL_2, CONF_CHANNEL_3]): - if channel not in config: + bus_us, shunt_us = adc_times_us(config) + register = ( + ADC_AVERAGING[config[CONF_ADC_AVERAGING]] << ADC_AVERAGING_SHIFT + | ADC_TIMES_US[bus_us] << ADC_TIME_BUS_SHIFT + | ADC_TIMES_US[shunt_us] << ADC_TIME_SHUNT_SHIFT + | (MODE_CONTINUOUS if config[CONF_CONTINUOUS_MODE] else MODE_SINGLE_SHOT) + ) + for i, channel in enumerate(CHANNELS): + if (conf := config.get(channel)) is None: continue - conf = config[channel] - if CONF_SHUNT_RESISTANCE in conf: - cg.add(var.set_shunt_resistance(i, conf[CONF_SHUNT_RESISTANCE])) + register |= CHANNEL_ENABLE_BITS[i] + shunt = conf[CONF_SHUNT_RESISTANCE] + cg.add(var.set_shunt_resistance(i, shunt)) if CONF_BUS_VOLTAGE in conf: sens = await sensor.new_sensor(conf[CONF_BUS_VOLTAGE]) cg.add(var.set_bus_voltage_sensor(i, sens)) @@ -98,3 +228,23 @@ async def to_code(config: ConfigType) -> None: if CONF_POWER in conf: sens = await sensor.new_sensor(conf[CONF_POWER]) cg.add(var.set_power_sensor(i, sens)) + for key, setter in ( + (CONF_WARNING_CURRENT_LIMIT, var.set_warning_limit), + (CONF_CRITICAL_CURRENT_LIMIT, var.set_critical_limit), + ): + if (limit := conf.get(key)) is not None: + cg.add_define("USE_INA3221_ALERT_LIMITS") + cg.add(setter(i, alert_limit_register(limit, shunt))) + cg.add(var.set_config_register(register)) + if not config[CONF_CONTINUOUS_MODE]: + cg.add(var.set_single_shot_wait_ms(single_shot_wait_ms(config))) + + if any( + key in config + for key in (CONF_SUM_SHUNT_VOLTAGE, CONF_SUM_CURRENT, CONF_SUM_POWER) + ): + cg.add_define("USE_INA3221_SUMMATION") + sensors = sensor.sub_sensors(config) + await sensors(CONF_SUM_SHUNT_VOLTAGE, var.set_sum_shunt_voltage_sensor) + await sensors(CONF_SUM_CURRENT, var.set_sum_current_sensor) + await sensors(CONF_SUM_POWER, var.set_sum_power_sensor) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1a5ab82d934..6f2a499730c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -91,6 +91,8 @@ #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_BLE_STATE_CALLBACK +#define USE_INA3221_ALERT_LIMITS +#define USE_INA3221_SUMMATION #define USE_INFRARED #define USE_IR_RF #define USE_IR_RF_TRANSMIT_COMPLETE diff --git a/tests/components/ina3221/common.yaml b/tests/components/ina3221/common.yaml index 570d1b0a12e..b836333ad87 100644 --- a/tests/components/ina3221/common.yaml +++ b/tests/components/ina3221/common.yaml @@ -13,3 +13,32 @@ sensor: shunt_voltage: name: INA3221 Channel 1 Shunt Voltage update_interval: 15s + - platform: ina3221 + i2c_id: i2c_bus + address: 0x41 + continuous_mode: false + adc_averaging: 4 + adc_time: + bus_voltage: 2116us + shunt_voltage: 1.1ms + channel_1: + shunt_resistance: 0.05 ohm + current: + name: INA3221 B Channel 1 Current + warning_current_limit: 1A + critical_current_limit: 2A + channel_2: + shunt_resistance: 0.05 ohm + bus_voltage: + name: INA3221 B Channel 2 Bus Voltage + channel_3: + shunt_resistance: 0.05 ohm + power: + name: INA3221 B Channel 3 Power + sum_shunt_voltage: + name: INA3221 B Sum Shunt Voltage + sum_current: + name: INA3221 B Sum Current + sum_power: + name: INA3221 B Sum Power + update_interval: 15s diff --git a/tests/components/ina3221/test.esp32-idf.yaml b/tests/components/ina3221/test.esp32-idf.yaml index b47e39c3898..9ebc2589968 100644 --- a/tests/components/ina3221/test.esp32-idf.yaml +++ b/tests/components/ina3221/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.esp8266-ard.yaml b/tests/components/ina3221/test.esp8266-ard.yaml index 4a98b9388ab..c0607404540 100644 --- a/tests/components/ina3221/test.esp8266-ard.yaml +++ b/tests/components/ina3221/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml diff --git a/tests/components/ina3221/test.rp2040-ard.yaml b/tests/components/ina3221/test.rp2040-ard.yaml index 319a7c71a65..257e90c0e62 100644 --- a/tests/components/ina3221/test.rp2040-ard.yaml +++ b/tests/components/ina3221/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + ina3221: !include common.yaml From f829447b2e711f1c8b0d6991521542eefd211407 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:25:49 -0400 Subject: [PATCH 056/111] [tmp102] Add number entities for runtime thermostat thresholds (#20596) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tmp102/__init__.py | 41 +++- esphome/components/tmp102/number.py | 87 ++++++++ esphome/components/tmp102/sensor.py | 15 +- esphome/components/tmp102/tmp102.cpp | 119 ++++++++++- esphome/components/tmp102/tmp102.h | 43 ++++ esphome/core/defines.h | 1 + .../tmp102/config/duplicate_numbers.yaml | 25 +++ .../tmp102/config/numbers.yaml | 24 +++ .../tmp102/config/numbers_inherit.yaml | 25 +++ .../tmp102/config/numbers_initial_value.yaml | 22 ++ tests/component_tests/tmp102/test_config.py | 55 +++++ tests/components/tmp102/__init__.py | 10 +- tests/components/tmp102/common.yaml | 10 + tests/components/tmp102/test_tmp102.cpp | 190 +++++++++++++++++- 14 files changed, 653 insertions(+), 14 deletions(-) create mode 100644 esphome/components/tmp102/number.py create mode 100644 tests/component_tests/tmp102/config/duplicate_numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_inherit.yaml create mode 100644 tests/component_tests/tmp102/config/numbers_initial_value.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 077dd03ccc4..9b4cf6680a4 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,10 +1,14 @@ """Shared TMP102 types and configuration validation.""" +from dataclasses import dataclass, field +import math + import esphome.codegen as cg from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import CONF_PLATFORM +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -16,6 +20,7 @@ tmp102_ns = cg.esphome_ns.namespace("tmp102") TMP102Component = tmp102_ns.class_( "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor ) +TMP102LimitType = tmp102_ns.enum("TMP102LimitType") CONF_EXTENDED_MODE = "extended_mode" CONF_ONE_SHOT_MODE = "one_shot_mode" @@ -34,10 +39,40 @@ TMP102_CONVERSION_FACTOR = 0.0625 TMP102_DEFAULT_THIGH = 80.0 TMP102_DEFAULT_TLOW = 75.0 +TMP102_MIN_TEMPERATURE = -55.0 +TMP102_MAX_TEMPERATURE = 127.9375 +TMP102_MAX_TEMPERATURE_EXTENDED = 150.0 + + +def max_temperature(extended: bool) -> float: + return TMP102_MAX_TEMPERATURE_EXTENDED if extended else TMP102_MAX_TEMPERATURE + + +@dataclass +class TMP102Data: + # Extended mode of each sensor that has threshold numbers, keyed by sensor ID. + number_parents: dict[str, bool] = field(default_factory=dict) + + +def _get_data() -> TMP102Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TMP102Data() + return CORE.data[DOMAIN] + + +def add_number_parent(parent_id: object, extended: bool) -> None: + _get_data().number_parents[str(parent_id)] = extended + + +def number_parent_extended(parent_id: object) -> bool | None: + """Return the extended mode of a sensor with threshold numbers, or None without numbers.""" + return _get_data().number_parents.get(str(parent_id)) + def encode_temperature(temperature: float, extended: bool) -> int: """Encode a temperature as a TMP102 limit-register word.""" - steps = round(temperature / TMP102_CONVERSION_FACTOR) + scaled = temperature / TMP102_CONVERSION_FACTOR + steps = int(scaled + math.copysign(0.5, scaled)) return (steps << (3 if extended else 4)) & 0xFFFF @@ -56,8 +91,8 @@ def build_configuration(config: ConfigType) -> int: def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: extended = config.get(CONF_EXTENDED_MODE, False) - t_min = -55.0 - t_max = 150.0 if extended else 127.9375 + t_min = TMP102_MIN_TEMPERATURE + t_max = max_temperature(extended) high = config.get(CONF_TEMPERATURE_HIGH) low = config.get(CONF_TEMPERATURE_LOW) for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): diff --git a/esphome/components/tmp102/number.py b/esphome/components/tmp102/number.py new file mode 100644 index 00000000000..e353a8d1183 --- /dev/null +++ b/esphome/components/tmp102/number.py @@ -0,0 +1,87 @@ +"""Adjustable TMP102 thermostat thresholds.""" + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_RESTORE_VALUE, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_CONFIG, + UNIT_CELSIUS, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( + CONF_EXTENDED_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_TMP102_ID, + TMP102_CONVERSION_FACTOR, + TMP102_MIN_TEMPERATURE, + TMP102Component, + TMP102LimitType, + add_number_parent, + max_temperature, + number_parent_extended, + tmp102_ns, + validate_unique_child, +) + +TMP102LimitNumber = tmp102_ns.class_( + "TMP102LimitNumber", number.Number, cg.Parented.template(TMP102Component) +) + +_LIMIT_SCHEMA = number.number_schema( + TMP102LimitNumber, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend({cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean}) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + cv.Optional(CONF_TEMPERATURE_HIGH): _LIMIT_SCHEMA, + cv.Optional(CONF_TEMPERATURE_LOW): _LIMIT_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE_HIGH, CONF_TEMPERATURE_LOW), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + validate_unique_child(config, "number") + full = fv.full_config.get() + parent = full.get_config_for_path(full.get_path_for_id(config[CONF_TMP102_ID])[:-1]) + add_number_parent(config[CONF_TMP102_ID], parent.get(CONF_EXTENDED_MODE, False)) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_NUMBER") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + limits = { + "min_value": TMP102_MIN_TEMPERATURE, + "max_value": max_temperature(number_parent_extended(config[CONF_TMP102_ID])), + "step": TMP102_CONVERSION_FACTOR, + } + numbers = number.sub_numbers(config, parent=parent) + if var := await numbers( + CONF_TEMPERATURE_HIGH, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_HIGH, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_HIGH][CONF_RESTORE_VALUE])) + if var := await numbers( + CONF_TEMPERATURE_LOW, + parent.set_limit_control, + TMP102LimitType.TMP102_LIMIT_LOW, + **limits, + ): + cg.add(var.set_restore_value(config[CONF_TEMPERATURE_LOW][CONF_RESTORE_VALUE])) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index fbfed7444d6..c1eda11815a 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -5,6 +5,7 @@ from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( + CONF_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -28,6 +29,7 @@ from . import ( TMP102Component, build_configuration, encode_temperature, + number_parent_extended, validate_tmp102_thresholds, ) @@ -66,17 +68,20 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if not any(key in config for key in _ADVANCED_SCHEMA): + has_numbers = number_parent_extended(config[CONF_ID]) is not None + if not has_numbers and not any(key in config for key in _ADVANCED_SCHEMA): return extended = config.get(CONF_EXTENDED_MODE, False) - high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) - low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) cg.add_define("USE_TMP102_CONFIGURE") cg.add( var.set_configuration( build_configuration(config), - encode_temperature(high, extended), - encode_temperature(low, extended), + encode_temperature( + config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH), extended + ), + encode_temperature( + config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW), extended + ), ) ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 09b29dd7ef4..bb82975dbc3 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + namespace esphome::tmp102 { ESPHOME_LOG_TAG(TAG, "tmp102"); @@ -18,6 +20,9 @@ static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; #ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +#ifdef USE_TMP102_NUMBER +static_assert(TMP102_REGISTER_LOW_LIMIT + TMP102_LIMIT_HIGH == TMP102_REGISTER_HIGH_LIMIT); +#endif static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; @@ -31,11 +36,39 @@ static float decode_temperature(uint16_t raw, bool extended) { return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; } +#ifdef USE_TMP102_NUMBER +static uint16_t encode_temperature(float temperature, bool extended) { + const int16_t steps = static_cast(std::lround(temperature / TMP102_CONVERSION_FACTOR)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); +} +#endif + void TMP102Component::setup() { if (!this->configure_) return; - if (!this->write_configuration_()) +#ifdef USE_TMP102_NUMBER + float restored[2]{}; + this->prepare_number_limits_(restored); +#endif + if (!this->write_configuration_()) { this->mark_failed(); + return; + } +#ifdef USE_TMP102_NUMBER + bool saved = true; + for (uint8_t limit = 0; limit < 2; ++limit) { + TMP102LimitNumber *control = this->limit_controls_[limit]; + if (control == nullptr) + continue; + const float value = this->get_limit_temperature_(static_cast(limit)); + if (value != restored[limit]) + saved &= control->save_value_(value); + control->publish_state(value); + } + if (!saved) { + ESP_LOGW(TAG, "Failed to save threshold preferences"); + } +#endif } #endif @@ -75,6 +108,10 @@ void TMP102Component::dump_config() { this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), decode_temperature(this->low_limit_, extended)); +#ifdef USE_TMP102_NUMBER + LOG_NUMBER(" ", "High Limit", this->limit_controls_[TMP102_LIMIT_HIGH]); + LOG_NUMBER(" ", "Low Limit", this->limit_controls_[TMP102_LIMIT_LOW]); +#endif if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); } @@ -185,6 +222,55 @@ void TMP102Component::read_temperature_() { #endif this->status_clear_warning(); } + +#ifdef USE_TMP102_NUMBER +void TMP102Component::set_limit_control(TMP102LimitNumber *number) { + this->limit_controls_[number->limit_type_] = number; +} + +void TMP102Component::prepare_number_limits_(float *restored) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + 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)); + 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; + } + this->low_limit_ = raw[TMP102_LIMIT_LOW]; + this->high_limit_ = raw[TMP102_LIMIT_HIGH]; +} + +float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { + const uint16_t raw = limit == TMP102_LIMIT_HIGH ? this->high_limit_ : this->low_limit_; + return decode_temperature(raw, this->config_ & TMP102_EXTENDED_MODE_BIT); +} + +bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temperature) { + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + const uint16_t raw = encode_temperature(temperature, extended); + const bool high = limit == TMP102_LIMIT_HIGH; + const uint16_t other = high ? this->low_limit_ : this->high_limit_; + if (high ? static_cast(raw) < static_cast(other) + : static_cast(raw) > static_cast(other)) { + 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)); + return false; + } + if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { + this->status_set_warning(); + return false; + } + (high ? this->high_limit_ : this->low_limit_) = raw; + return true; +} +#endif #endif #ifdef USE_TMP102_BINARY_SENSOR @@ -207,4 +293,35 @@ bool TMP102Component::read_alert_state_() { } #endif +#ifdef USE_TMP102_NUMBER +float TMP102LimitNumber::load_value_(float fallback) { + float value = fallback; + if (this->restore_value_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&value); + } + 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; + } + return value; +} + +bool TMP102LimitNumber::save_value_(float value) { return !this->restore_value_ || this->pref_.save(&value); } + +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"); + return; + } + const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); + value = this->parent_->get_limit_temperature_(this->limit_type_); + this->publish_state(value); + if (accepted && !this->save_value_(value)) { + ESP_LOGW(TAG, "Failed to save threshold preference"); + } +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index caf75e19be7..d88dde78344 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,12 +6,26 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif #include "esphome/components/i2c/i2c.h" +#ifdef USE_TMP102_NUMBER +#include "esphome/components/number/number.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#endif #include "esphome/components/sensor/sensor.h" #include namespace esphome::tmp102 { +#ifdef USE_TMP102_NUMBER +enum TMP102LimitType : uint8_t { + TMP102_LIMIT_LOW = 0, + TMP102_LIMIT_HIGH = 1, +}; + +class TMP102LimitNumber; +#endif + class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: #ifdef USE_TMP102_BINARY_SENSOR @@ -25,6 +39,9 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu this->low_limit_ = low_limit; this->configure_ = true; } +#ifdef USE_TMP102_NUMBER + void set_limit_control(TMP102LimitNumber *number); +#endif #endif void dump_config() override; void update() override; @@ -46,6 +63,14 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu bool check_configuration_(); void read_temperature_(); void update_configured_(); +#ifdef USE_TMP102_NUMBER + friend class TMP102LimitNumber; + bool set_limit_temperature_(TMP102LimitType limit, float temperature); + float get_limit_temperature_(TMP102LimitType limit) const; + void prepare_number_limits_(float *restored); + + TMP102LimitNumber *limit_controls_[2]{}; +#endif uint16_t config_{0}; uint16_t high_limit_{0}; @@ -54,4 +79,22 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #endif }; +#ifdef USE_TMP102_NUMBER +class TMP102LimitNumber final : public number::Number, public Parented { + public: + explicit TMP102LimitNumber(TMP102LimitType limit_type) : limit_type_(limit_type) {} + void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + + protected: + friend class TMP102Component; + float load_value_(float fallback); + bool save_value_(float value); + void control(float value) override; + + ESPPreferenceObject pref_; + TMP102LimitType limit_type_; + bool restore_value_{true}; +}; +#endif + } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6f2a499730c..fc6dd82ed98 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_TIME_TRIGGERS #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE +#define USE_TMP102_NUMBER #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/config/duplicate_numbers.yaml b/tests/component_tests/tmp102/config/duplicate_numbers.yaml new file mode 100644 index 00000000000..04f634741fd --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_numbers.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High One + - platform: tmp102 + tmp102_id: chip + temperature_low: + name: Low Two diff --git a/tests/component_tests/tmp102/config/numbers.yaml b/tests/component_tests/tmp102/config/numbers.yaml new file mode 100644 index 00000000000..272bd0dfe66 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers.yaml @@ -0,0 +1,24 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + temperature_low: + name: Low + restore_value: false diff --git a/tests/component_tests/tmp102/config/numbers_inherit.yaml b/tests/component_tests/tmp102/config/numbers_inherit.yaml new file mode 100644 index 00000000000..cbd4280f034 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_inherit.yaml @@ -0,0 +1,25 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: true + temperature_high: 140 + temperature_low: 120 + +number: + - platform: tmp102 + temperature_high: + name: High + temperature_low: + name: Low diff --git a/tests/component_tests/tmp102/config/numbers_initial_value.yaml b/tests/component_tests/tmp102/config/numbers_initial_value.yaml new file mode 100644 index 00000000000..60d25577014 --- /dev/null +++ b/tests/component_tests/tmp102/config/numbers_initial_value.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +number: + - platform: tmp102 + tmp102_id: chip + temperature_high: + name: High + initial_value: 30 diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 49f2629baf8..b933db1d9bc 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -58,6 +58,54 @@ def test_duplicate_alert_entities_are_rejected( ) +def test_threshold_numbers_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(128, 20480, 19200)" in source + assert source.count("set_limit_control(") == 2 + assert "tmp102::TMP102_LIMIT_HIGH" in source + assert "tmp102::TMP102_LIMIT_LOW" in source + assert "set_restore_value(false)" in source + assert "set_max_value(127.9375f)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_NUMBER" in defines + assert "USE_TMP102_CONFIGURE" in defines + + +def test_threshold_numbers_use_sensor_thresholds( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("numbers_inherit.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(144, 17920, 15360)" in source + assert "set_max_value(150.0f)" in source + + +def test_threshold_number_initial_value_is_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("numbers_initial_value.yaml") + assert read_config({}) is None + assert "[initial_value] is an invalid option" in capsys.readouterr().out + + +def test_duplicate_numbers_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_numbers.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 number may reference each temperature sensor" + in capsys.readouterr().out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ @@ -117,5 +165,12 @@ def test_precomputed_register_values() -> None: assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 +def test_threshold_encoding_rounds_half_steps_away_from_zero() -> None: + assert component.encode_temperature(30.03125, False) == 481 << 4 + assert component.encode_temperature(-30.03125, False) == (-481 << 4) & 0xFFFF + assert component.encode_temperature(30.03125, True) == 481 << 3 + assert component.encode_temperature(-30.03125, True) == (-481 << 3) & 0xFFFF + + def test_defaults_match_chip_reset() -> None: assert component.build_configuration({}) == 0x0080 diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index f86177c0f92..346efac83ec 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,10 +6,16 @@ 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"): + for platform in ("sensor", "binary_sensor", "number"): 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") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] + manifest.dependencies = manifest.dependencies + [ + "i2c", + "sensor", + "binary_sensor", + "number", + ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 1e5f1f456ce..dfc2e2e2245 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -16,6 +16,16 @@ sensor: temperature_high: 140°C temperature_low: 120°C +number: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + temperature_high: + name: TMP102 High Threshold + restore_value: true + temperature_low: + name: TMP102 Low Threshold + restore_value: false + binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index cd06d8a83d7..6c294f4cdc6 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -3,12 +3,23 @@ #include #include +#include +#include +#include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" namespace esphome::tmp102::testing { +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + class RegisterBus final : public i2c::I2CBus { public: std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; @@ -48,21 +59,32 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; + TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; void SetUp() override { new (&App) Application; - char name[] = "tmp102-regression-tests"; - char friendly_name[] = "TMP102 tests"; - App.pre_setup(name, 23, friendly_name, 12); + App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); + host::setup_preferences(); + host::host_preferences->reset(); this->chip_.set_i2c_bus(&this->bus_); this->chip_.set_i2c_address(0x48); this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + EntityIdentity::configure(this->high_, "High", 0x102001); + EntityIdentity::configure(this->low_, "Low", 0x102002); + for (auto *number : {&this->high_, &this->low_}) { + number->set_parent(&this->chip_); + number->traits.set_min_value(-55); + number->traits.set_max_value(127.9375f); + number->traits.set_step(0.0625f); + } } void TearDown() override { App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); App.scheduler.call(millis()); + host::host_preferences->reset(); App.~Application(); } @@ -75,6 +97,31 @@ class TMP102Test : public ::testing::Test { void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { this->chip_.set_configuration(config, high, low); } + + static uint16_t encode(float temperature, bool extended = false) { + const int16_t steps = static_cast(std::lround(temperature / 0.0625f)); + return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); + } + + void numbers_(float initial_high = 30, float initial_low = 25, bool extended = false) { + this->configure(extended ? 0x0090 : 0x0080, encode(initial_high, extended), encode(initial_low, extended)); + if (extended) { + this->high_.traits.set_max_value(150); + this->low_.traits.set_max_value(150); + } + this->chip_.set_limit_control(&this->high_); + this->chip_.set_limit_control(&this->low_); + } + + static float saved(TMP102LimitNumber &number) { + float value = NAN; + EXPECT_TRUE(number.make_entity_preference().load(&value)); + return value; + } + + static void save(TMP102LimitNumber &number, float value) { + ASSERT_TRUE(number.make_entity_preference().save(&value)); + } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -190,6 +237,62 @@ TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { EXPECT_FALSE(this->chip_.status_has_warning()); } +TEST_F(TMP102Test, QuantizedRuntimeValuesMatchRegistersAndPreferences) { + this->numbers_((10.0f - 32) * 5 / 9, (9.0f - 32) * 5 / 9); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, -12.25f); + EXPECT_FLOAT_EQ(this->low_.state, -12.75f); + EXPECT_EQ(this->bus_.registers[3], 0xF3C0); + for (auto [requested, expected] : + {std::pair{12.22f, 12.25f}, {-12.22f, -12.25f}, {12.03125f, 12.0625f}, {-12.03125f, -12.0625f}}) { + this->high_.make_call().set_value(requested).perform(); + EXPECT_FLOAT_EQ(this->high_.state, expected); + EXPECT_FLOAT_EQ(saved(this->high_), expected); + EXPECT_FLOAT_EQ(static_cast(this->bus_.registers[3]) / 256.0f, expected); + } +} + +TEST_F(TMP102Test, ExtendedModeRuntimeValueMatchesRegister) { + this->numbers_(140, 120, true); + this->chip_.setup(); + this->high_.make_call().set_value(145.0625f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 145.0625f); + EXPECT_EQ(this->bus_.registers[3], encode(145.0625f, true)); + EXPECT_FLOAT_EQ(saved(this->high_), 145.0625f); +} + +TEST_F(TMP102Test, HighOnlyInvalidRestoreFallsBackAgainstStaticLow) { + this->configure(0x0080, encode(30), encode(25)); + this->chip_.set_limit_control(&this->high_); + save(this->high_, 20); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_EQ(this->bus_.registers[3], encode(30)); + EXPECT_EQ(this->bus_.registers[2], encode(25)); + EXPECT_FLOAT_EQ(saved(this->high_), 30); +} + +TEST_F(TMP102Test, OrderingUsesAcceptedQuantizedThreshold) { + this->numbers_(30.02f, 30.01f); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 30); + this->high_.make_call().set_value(29.96f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(29.99f).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 30); +} + +TEST_F(TMP102Test, FailedThresholdWriteWarningClearsAfterSuccessfulRead) { + this->numbers_(); + this->chip_.setup(); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(90).perform(); + EXPECT_TRUE(this->chip_.status_has_warning()); + this->chip_.update(); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -304,6 +407,87 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } } +TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + 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); +} + +TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + 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); +} + +TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + 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_FALSE(this->high_.has_state()); + EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_FLOAT_EQ(saved(this->low_), 85); +} + +TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->numbers_(); + save(this->high_, 90); + this->bus_.fail_write = 3; + this->chip_.setup(); + ASSERT_TRUE(this->chip_.is_failed()); + const unsigned writes = this->bus_.writes; + this->high_.make_call().set_value(60).perform(); + EXPECT_FALSE(this->high_.has_state()); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { + this->numbers_(); + save(this->high_, 35); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->bus_.registers[3], encode(35)); + float value; + EXPECT_FALSE(this->low_.make_entity_preference().load(&value)); +} + +TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->numbers_(); + save(this->high_, 90); + save(this->low_, 85); + this->chip_.setup(); + unsigned publications = 0; + this->high_.add_on_state_callback([&publications](float) { ++publications; }); + this->bus_.fail_write = 3; + this->high_.make_call().set_value(100).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 90); + EXPECT_EQ(publications, 1); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + +TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { + this->numbers_(); + save(this->high_, 90); + this->high_.set_restore_value(false); + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + this->high_.make_call().set_value(40).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 40); + EXPECT_FLOAT_EQ(saved(this->high_), 90); +} + } // namespace esphome::tmp102::testing #endif From 8acf885241946a80673d3687f09f9a80d9f6e692 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Tue, 13 Oct 2026 04:43:51 +0200 Subject: [PATCH 057/111] [bq76972] Add BQ76972 BMS chip monitoring component (#17720) 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 --- CODEOWNERS | 1 + esphome/components/bq76972/__init__.py | 111 +++++++ esphome/components/bq76972/bq76972.cpp | 298 ++++++++++++++++++ esphome/components/bq76972/bq76972.h | 51 +++ tests/components/bq76972/common.yaml | 59 ++++ tests/components/bq76972/test.esp32-idf.yaml | 3 + .../components/bq76972/test.esp8266-ard.yaml | 3 + 7 files changed, 526 insertions(+) create mode 100644 esphome/components/bq76972/__init__.py create mode 100644 esphome/components/bq76972/bq76972.cpp create mode 100644 esphome/components/bq76972/bq76972.h create mode 100644 tests/components/bq76972/common.yaml create mode 100644 tests/components/bq76972/test.esp32-idf.yaml create mode 100644 tests/components/bq76972/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 075b8113d88..f068a3a2215 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -106,6 +106,7 @@ esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bq25186/* @Rapsssito esphome/components/bq27220/* @rggammon +esphome/components/bq76972/* @limpkin esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py new file mode 100644 index 00000000000..5ccf043c250 --- /dev/null +++ b/esphome/components/bq76972/__init__.py @@ -0,0 +1,111 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@limpkin"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq76972" +MULTI_CONF = True + +bq76972_ns = cg.esphome_ns.namespace("bq76972") +BQ76972Component = bq76972_ns.class_( + "BQ76972Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REG_DISABLED = "reg_disabled" +CONF_CRC_ENABLED = "crc_enabled" +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ76972Component), + cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } + ) + .extend(i2c.i2c_device_schema(0x08)) + .extend(cv.polling_component_schema("10s")) +) + + +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) + + cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) + cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(var.set_cell_sensor(sens, idx)) + + +BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(BQ76972Component), + cv.Required(CONF_ADDRESS): cv.templatable(cv.i2c_address), + }, + key=CONF_ADDRESS, +) + +automation.register_apply_action( + "bq76972.program_i2c_address", + BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA, + automation.ApplyField(CONF_ADDRESS, "program_i2c_address", cg.uint8), +) diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp new file mode 100644 index 00000000000..ea229307d5c --- /dev/null +++ b/esphome/components/bq76972/bq76972.cpp @@ -0,0 +1,298 @@ +#include "bq76972.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bq76972 { + +ESPHOME_LOG_TAG(TAG, "bq76972"); + +static constexpr uint8_t BQ76972_REG_SUBCOMMAND = 0x3E; // 0x3E low byte, 0x3F high byte +static constexpr uint8_t BQ76972_REG_TRANSFER_BUFFER = 0x40; +static constexpr uint8_t BQ76972_REG_CHECKSUM = 0x60; // 0x60 checksum, 0x61 length +static constexpr uint8_t BQ76972_CMD_BAT_STATUS = 0x12; +static constexpr uint8_t BQ76972_CMD_CELL_START = 0x14; +static constexpr uint8_t BQ76972_CMD_STACK_VOLTAGE = 0x34; +static constexpr uint8_t BQ76972_CMD_TEMP_START = 0x68; +static constexpr uint16_t BQ76972_SUBCMD_ENTER_CFG_UPDATE = 0x0090; +static constexpr uint16_t BQ76972_SUBCMD_EXIT_CFG_UPDATE = 0x0092; +static constexpr uint16_t BQ76972_MEM_REG12_CONFIG = 0x9236; +static constexpr uint16_t BQ76972_MEM_REG0_CONFIG = 0x9237; +static constexpr uint16_t BQ76972_MEM_I2C_ADDRESS = 0x923A; +static constexpr uint16_t BQ76972_MEM_SWAP_COMM_MODE = 0x29BC; +// Pin config of temperature reading N (1 = CFETOFF ... 9 = DDSG) is at this base + N +static constexpr uint16_t BQ76972_MEM_TEMP_PIN_CONFIG_BASE = 0x92F9; +static constexpr size_t BQ76972_TS1_INDEX = 4; // a thermistor input by default +static constexpr uint8_t BQ76972_PIN_CONFIG_THERMISTOR = 0x07; + +// 16 words with a CRC byte after every data byte +static constexpr size_t BQ76972_MAX_TRANSFER = 64; +static constexpr uint8_t BQ76972_MAX_POLLS = 50; + +static float raw_to_celsius(uint16_t raw) { return static_cast(raw) * 0.1f - 273.15f; } + +bool BQ76972Component::read_block_(uint8_t start_register, uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->read_bytes(start_register, data, len); + + // With CRC enabled, every data byte is followed by a CRC byte + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + if (!this->read_bytes(start_register, buffer.data(), len * 2)) + return false; + + // The first CRC also covers the write address, register and read address + const uint8_t crc0_input[4] = {static_cast(this->address_ << 1), start_register, + static_cast((this->address_ << 1) | 1), buffer[0]}; + if (crc8(crc0_input, 4, 0x00, 0x07, true) != buffer[1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte 0", start_register); + return false; + } + data[0] = buffer[0]; + for (size_t i = 1; i < len; ++i) { + if (crc8(&buffer[i * 2], 1, 0x00, 0x07, true) != buffer[i * 2 + 1]) { + ESP_LOGD(TAG, "CRC mismatch at register 0x%02X byte %u", start_register, static_cast(i)); + return false; + } + data[i] = buffer[i * 2]; + } + return true; +} + +bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, size_t len) { + if (!this->crc_mode_) + return this->write_bytes(start_register, data, len); + + std::array buffer; + if (len == 0 || len * 2 > buffer.size()) + return false; + + // The first CRC also covers the write address and register + const uint8_t crc0_input[3] = {static_cast(this->address_ << 1), start_register, data[0]}; + buffer[0] = data[0]; + buffer[1] = crc8(crc0_input, 3, 0x00, 0x07, true); + for (size_t i = 1; i < len; ++i) { + buffer[i * 2] = data[i]; + buffer[i * 2 + 1] = crc8(&data[i], 1, 0x00, 0x07, true); + } + return this->write_bytes(start_register, buffer.data(), len * 2); +} + +bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) { + std::array bytes; + if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2)) + return false; + for (size_t i = 0; i < num_words; i++) + data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]); + return true; +} + +bool BQ76972Component::send_subcommand_(uint16_t subcommand) { + const uint8_t bytes[2] = {static_cast(subcommand & 0xFF), static_cast(subcommand >> 8)}; + return this->write_block_(BQ76972_REG_SUBCOMMAND, bytes, 2); +} + +bool BQ76972Component::wait_for_subcommand_() { + uint8_t bytes[2]; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_block_(BQ76972_REG_SUBCOMMAND, bytes, 2)) + return false; + if (bytes[0] != 0xFF && bytes[1] != 0xFF) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::wait_for_cfgupdate_() { + uint16_t status; + for (uint8_t i = 0; i < BQ76972_MAX_POLLS; i++) { + if (!this->read_words_(BQ76972_CMD_BAT_STATUS, &status, 1)) + return false; + if (status & 0x0001) + return true; + delay(1); + } + return false; +} + +bool BQ76972Component::read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len) { + return this->send_subcommand_(subcommand) && this->wait_for_subcommand_() && + this->read_block_(BQ76972_REG_TRANSFER_BUFFER, data, len); +} + +bool BQ76972Component::write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len) { + if (!this->send_subcommand_(subcommand)) + return false; + if (len == 0) + return true; + if (!this->write_block_(BQ76972_REG_TRANSFER_BUFFER, data, len)) + return false; + + // Inverted 8-bit sum of 0x3E, 0x3F and the data; length counts 0x3E, 0x3F, 0x60, 0x61 and the data + uint8_t sum = static_cast(subcommand & 0xFF) + static_cast(subcommand >> 8); + for (size_t i = 0; i < len; ++i) + sum += data[i]; + const uint8_t tail[2] = {static_cast(~sum), static_cast(4 + len)}; + return this->write_block_(BQ76972_REG_CHECKSUM, tail, 2); +} + +bool BQ76972Component::enter_cfg_update_() { + if (!this->write_subcommand_(BQ76972_SUBCMD_ENTER_CFG_UPDATE, nullptr, 0)) + return false; + delay(10); + return this->wait_for_cfgupdate_(); +} + +bool BQ76972Component::exit_cfg_update_() { + return this->write_subcommand_(BQ76972_SUBCMD_EXIT_CFG_UPDATE, nullptr, 0); +} + +bool BQ76972Component::regs_disabled_(bool &out) { + uint8_t reg12; + uint8_t reg0; + if (!this->read_subcommand_(BQ76972_MEM_REG12_CONFIG, ®12, 1) || + !this->read_subcommand_(BQ76972_MEM_REG0_CONFIG, ®0, 1)) + return false; + out = reg12 == 0 && (reg0 & 0x01) == 0; + return true; +} + +const LogString *BQ76972Component::configure_() { + uint16_t internal_temp; + if (!this->read_words_(BQ76972_CMD_TEMP_START, &internal_temp, 1)) + return LOG_STR(ESP_LOG_MSG_COMM_FAIL); + + // Only write the regulator config when it differs, see + // https://e2e.ti.com/support/power-management-group/power-management/f/power-management-forum/1583718/bq76972-undocumented-4-minute-config_update-auto-exit-timeout + bool regs_disabled = true; + if (this->reg_disable_ && !this->regs_disabled_(regs_disabled)) + return LOG_STR("Reading regulator config failed"); + const bool write_regs = !regs_disabled; + + bool write_thermistors = false; + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) + write_thermistors = true; + } + + if (write_regs || write_thermistors) { + if (!this->enter_cfg_update_()) + return LOG_STR("Entering config update mode failed"); + const LogString *error = this->write_config_(write_regs); + // The chip stops measuring in config update mode, so leave it even after a failed write + if (!this->exit_cfg_update_() && error == nullptr) + error = LOG_STR("Exiting config update mode failed"); + if (error != nullptr) + return error; + if (write_regs && (!this->regs_disabled_(regs_disabled) || !regs_disabled)) + return LOG_STR("Disabling regulators failed"); + } + + ESP_LOGD(TAG, "Internal temperature: %.1f°C", raw_to_celsius(internal_temp)); + return nullptr; +} + +const LogString *BQ76972Component::write_config_(bool write_regs) { + if (write_regs) { + const uint8_t zero = 0; + if (!this->write_subcommand_(BQ76972_MEM_REG12_CONFIG, &zero, 1) || + !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) + return LOG_STR("Writing regulator config failed"); + } + for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { + if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) + continue; + if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) + return LOG_STR("Writing thermistor pin config failed"); + } + return nullptr; +} + +void BQ76972Component::setup() { + if (const LogString *error = this->configure_(); error != nullptr) + this->mark_failed(error); +} + +void BQ76972Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ76972:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " CRC Mode: %s\n" + " Regulators disabled: %s", + this->crc_mode_ ? LOG_STR_LITERAL("Enabled") : LOG_STR_LITERAL("Disabled"), YESNO(this->reg_disable_)); +} + +void BQ76972Component::program_i2c_address(uint8_t address) { + if (this->is_failed()) + return; + const uint8_t shifted = address << 1; + if (!this->enter_cfg_update_()) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + // The new address only takes effect with the swap, so after a failed write the chip still answers at the + // old one and must still be taken out of config update mode + const bool written = this->write_subcommand_(BQ76972_MEM_I2C_ADDRESS, &shifted, 1) && + this->write_subcommand_(BQ76972_MEM_SWAP_COMM_MODE, nullptr, 0); + if (written) { + delay(10); + this->set_i2c_address(address); + } + if (!this->exit_cfg_update_() || !written) { + this->status_set_warning(LOG_STR("Programming I2C address failed")); + return; + } + ESP_LOGI(TAG, "I2C address set to 0x%02X", address); +} + +template static bool any_set(const std::array &sensors) { + for (auto *sensor : sensors) { + if (sensor != nullptr) + return true; + } + return false; +} + +void BQ76972Component::update() { + // Each group publishes as soon as its read succeeds, so one failed read does not hold back the others + bool ok = true; + uint16_t words[BQ76972_NUM_CELLS]; + if (this->stack_voltage_sensor_ != nullptr) { + if (this->read_words_(BQ76972_CMD_STACK_VOLTAGE, words, 1)) { + if (words[0] != 0) + this->stack_voltage_sensor_->publish_state(words[0] * 0.01f); // 10 mV units + } else { + ok = false; + } + } + if (any_set(this->cell_sensors_)) { + if (this->read_words_(BQ76972_CMD_CELL_START, words, BQ76972_NUM_CELLS)) { + for (size_t i = 0; i < BQ76972_NUM_CELLS; i++) { + if (this->cell_sensors_[i] != nullptr && words[i] != 0) + this->cell_sensors_[i]->publish_state(words[i] * 0.001f); // mV + } + } else { + ok = false; + } + } + if (any_set(this->temperature_sensors_)) { + if (this->read_words_(BQ76972_CMD_TEMP_START, words, BQ76972_NUM_TEMPERATURES)) { + for (size_t i = 0; i < BQ76972_NUM_TEMPERATURES; i++) { + if (this->temperature_sensors_[i] != nullptr) + this->temperature_sensors_[i]->publish_state(raw_to_celsius(words[i])); + } + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +} // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h new file mode 100644 index 00000000000..d27a4ce96f4 --- /dev/null +++ b/esphome/components/bq76972/bq76972.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq76972 { + +static constexpr size_t BQ76972_NUM_CELLS = 16; +static constexpr size_t BQ76972_NUM_TEMPERATURES = 10; + +class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } + void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } + void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } + void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } + void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } + + void program_i2c_address(uint8_t address); + + protected: + bool read_block_(uint8_t start_register, uint8_t *data, size_t len); + bool write_block_(uint8_t start_register, const uint8_t *data, size_t len); + bool read_words_(uint8_t start_register, uint16_t *data, size_t num_words); + bool send_subcommand_(uint16_t subcommand); + bool read_subcommand_(uint16_t subcommand, uint8_t *data, size_t len); + bool write_subcommand_(uint16_t subcommand, const uint8_t *data, size_t len); + bool wait_for_subcommand_(); + bool wait_for_cfgupdate_(); + bool enter_cfg_update_(); + bool exit_cfg_update_(); + bool regs_disabled_(bool &out); + const LogString *configure_(); + const LogString *write_config_(bool write_regs); + + bool reg_disable_{false}; + bool crc_mode_{false}; + + sensor::Sensor *stack_voltage_sensor_{nullptr}; + std::array cell_sensors_{}; + std::array temperature_sensors_{}; +}; + +} // namespace esphome::bq76972 diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml new file mode 100644 index 00000000000..42a9f633aff --- /dev/null +++ b/tests/components/bq76972/common.yaml @@ -0,0 +1,59 @@ +bq76972: + id: bq76972_id + address: 0x08 + crc_enabled: true + reg_disabled: true + update_interval: 10s + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" + +button: + - platform: template + name: "Program BQ I2C Address" + on_press: + - bq76972.program_i2c_address: + id: bq76972_id + address: 0x09 + - bq76972.program_i2c_address: !lambda "return 0x08;" diff --git a/tests/components/bq76972/test.esp32-idf.yaml b/tests/components/bq76972/test.esp32-idf.yaml new file mode 100644 index 00000000000..b0842c47a5c --- /dev/null +++ b/tests/components/bq76972/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + bq76972: !include common.yaml diff --git a/tests/components/bq76972/test.esp8266-ard.yaml b/tests/components/bq76972/test.esp8266-ard.yaml new file mode 100644 index 00000000000..504777ab5ba --- /dev/null +++ b/tests/components/bq76972/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bq76972: !include common.yaml From 501716580043034bbf8fbd81507ecc4fbbc1cacf Mon Sep 17 00:00:00 2001 From: Sourabh Jaiswal Date: Tue, 13 Oct 2026 08:28:25 +0530 Subject: [PATCH 058/111] [selec_meter] Add EM4M (3-phase) support via model option (#17782) Co-authored-by: Claude Sonnet 5 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 --- .../components/selec_meter/selec_meter.cpp | 399 ++++++++++++++++-- esphome/components/selec_meter/selec_meter.h | 135 ++++++ .../selec_meter/selec_meter_registers.h | 133 +++++- esphome/components/selec_meter/sensor.py | 262 +++++++++++- esphome/core/defines.h | 1 + tests/components/selec_meter/common-em4m.yaml | 129 ++++++ .../selec_meter/test-em4m.esp32-idf.yaml | 8 + .../selec_meter/test-em4m.esp8266-ard.yaml | 8 + .../selec_meter/test.esp32-idf.yaml | 3 +- .../selec_meter/test.esp8266-ard.yaml | 3 +- .../selec_meter/test.rp2040-ard.yaml | 3 +- .../components/selec_meter/__init__.py | 0 .../components/selec_meter/test_sensor.py | 103 +++++ 13 files changed, 1105 insertions(+), 82 deletions(-) create mode 100644 tests/components/selec_meter/common-em4m.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp32-idf.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/selec_meter/__init__.py create mode 100644 tests/unit_tests/components/selec_meter/test_sensor.py diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index a11cc6a008a..9a895b0e0e9 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,54 +1,358 @@ #include "selec_meter.h" #include "selec_meter_registers.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" +#include +#include +#include + namespace esphome::selec_meter { namespace helpers = modbus::helpers; ESPHOME_LOG_TAG(TAG, "selec_meter"); -static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers +static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers + +// Durable refusals an optional read tolerates before it is given up on +static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5; + +static const LogString *read_state_name(ReadState state) { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return LOG_STR("main block"); + case ReadState::READ_STATE_SERIAL_NUMBER: + return LOG_STR("serial number"); + case ReadState::READ_STATE_DG_SENSING: + return LOG_STR("DG sensing"); + case ReadState::READ_STATE_IDLE: + default: + return LOG_STR("idle"); + } +} + +// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when +// `reg` is not wholly inside `registers` +static float read_float(std::span registers, uint16_t start_address, uint16_t reg, bool word_swapped) { + auto value = word_swapped ? helpers::value_at(registers, start_address, reg) + : helpers::value_at(registers, start_address, reg); + return value.value_or(NAN); +} + +// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded +// phase's power factor; a non-finite field is skipped on its own so the others still publish. +static void publish_float(sensor::Sensor *sens, std::span registers, uint16_t reg, bool kilo, + bool word_swapped) { + float value = read_float(registers, 0, reg, word_swapped); + if (!std::isfinite(value)) { + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + return; + } + sens->publish_state(kilo ? value * 1000.0f : value); +} + +#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR)) +// Counts one failure of an optional read; callers only run while `failures` is below the limit +static void note_optional_read_failure(uint8_t &failures, const LogString *name) { + if (++failures == MAX_OPTIONAL_READ_FAILURES) { + ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures); + } +} +#endif + +SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) + return {0, EM4M_REGISTER_COUNT}; +#endif + return {0, EM2M_REGISTER_COUNT}; + case ReadState::READ_STATE_SERIAL_NUMBER: + return {EM4M_SERIAL_NUMBER, 2}; + case ReadState::READ_STATE_DG_SENSING: + return {EM4M_DG_SENSING, 2}; + case ReadState::READ_STATE_IDLE: + default: + return {0, 0}; + } +} + +bool SelecMeter::wanted_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return true; +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif + default: + return false; + } +} + +ReadState SelecMeter::next_read_state_after_(ReadState current) const { + for (auto next = static_cast(current) + 1; next < static_cast(ReadState::READ_STATE_IDLE); next++) { + if (this->wanted_(static_cast(next))) + return static_cast(next); + } + return ReadState::READ_STATE_IDLE; +} void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) { - if (!modbus::succeeded(status)) - return; // the hub already logs exception responses - - // Publish a sensor if both of its registers are in this response; skipping absent registers keeps - // this correct for any read range, so the poll may be split into multiple requests. - // Values are 32-bit floats, low word first. - auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { - if (sensor == nullptr) - return; - if (auto value = helpers::value_at(registers, start_address, reg)) - sensor->publish_state(*value * unit); + ReadState current = this->read_state_; + if (current == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected response while idle, dropping"); + return; + } + // start_address comes from the exact request this answers, so a stale reply cannot match another read + if (start_address != this->read_range_(current).start) { + this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false); + return; + } + if (status.has_value()) { + const auto code = *status; + ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast(code), + LOG_STR_ARG(read_state_name(current))); + // Illegal function/address are durable; busy, acknowledge and the rest are transient + const bool durable = + code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + this->fail_current_read_(nullptr, durable); + return; + } + switch (current) { + case ReadState::READ_STATE_MAIN_BLOCK: { +#ifdef USE_SELEC_METER_EM4M + bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers); +#else + bool ok = this->decode_em2m_(registers); +#endif + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(); + } + break; + } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + this->decode_serial_number_(registers); + break; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + this->decode_dg_sensing_(registers); + break; +#endif + default: + break; + } + // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally. + this->start_read_(this->next_read_state_after_(current)); +} + +void SelecMeter::fail_current_read_(const LogString *reason, bool durable) { + if (this->read_state_ == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected failure while idle, dropping"); + return; + } + ReadState failed_state = this->read_state_; + if (reason != nullptr) { + ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state))); + } + // Only the main block drives component health; an unsupported optional read is not a fault + if (failed_state == ReadState::READ_STATE_MAIN_BLOCK) + this->status_set_warning(); +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable) + note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state)); +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + if (failed_state == ReadState::READ_STATE_DG_SENSING && durable) + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state)); +#endif + this->start_read_(this->next_read_state_after_(failed_state)); +} + +bool SelecMeter::on_no_response(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("No Modbus response")); + return false; +} + +void SelecMeter::on_not_sent(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("Modbus request not sent")); +} + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) +void SelecMeter::decode_serial_number_(std::span registers) { + auto serial = + this->word_swap_ + ? helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER) + : helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER); + if (!serial) { + ESP_LOGW(TAG, "Unexpected response size for serial number"); + note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER)); + return; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08" PRIX32, *serial); + this->serial_number_sensor_->publish_state(buf); + // The serial number never changes, so stop reading it + this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES; +} +#endif + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) +void SelecMeter::decode_dg_sensing_(std::span registers) { + float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_); + if (!std::isfinite(value)) { + ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring"); + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING)); + return; + } + this->dg_sensing_sensor_->publish_state(value != 0); + this->dg_sensing_failures_ = 0; +} +#endif + +bool SelecMeter::publish_fields_(std::span registers, std::span fields, + uint8_t min_count) { + if (registers.size() < min_count) { + ESP_LOGW(TAG, "Short response: %u registers", static_cast(registers.size())); + return false; + } + for (const auto &entry : fields) { + FloatField field; + progmem_memcpy(&field, &entry, sizeof(field)); + sensor::Sensor *sens = this->*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg, field.kilo, this->word_swap_); + } + return true; +} + +bool SelecMeter::decode_em2m_(std::span registers) { + static constexpr FloatField EM2M_FIELDS[] PROGMEM = { + {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false}, + {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false}, + {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true}, + {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false}, + {&SelecMeter::current_sensor_, SELEC_CURRENT, false}, + {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true}, }; + return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT); +} + +#ifdef USE_SELEC_METER_EM4M +bool SelecMeter::decode_em4m_(std::span registers) { + static constexpr FloatField EM4M_FIELDS[] PROGMEM = { + // Common quantities, shared sensor keys with EM2M + {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false}, + {&SelecMeter::current_sensor_, EM4M_CURRENT, false}, + {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true}, + {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true}, + {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false}, + // Line-to-line and net (mains/DG) quantities have no per-phase equivalent + {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false}, + {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false}, + {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false}, + {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false}, + {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false}, + {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false}, + }; + + // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart + struct PhaseField { + sensor::Sensor *Phase::*sensor; + uint16_t reg_l1; + bool kilo; + }; + static constexpr PhaseField PHASE_FIELDS[] PROGMEM = { + {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false}, + {&Phase::current_sensor_, EM4M_CURRENT_L1, false}, + {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true}, + {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true}, + {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true}, + {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false}, + {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false}, + }; + + if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT)) + return false; + for (const auto &entry : PHASE_FIELDS) { + PhaseField field; + progmem_memcpy(&field, &entry, sizeof(field)); + for (uint8_t phase = 0; phase < 3; phase++) { + sensor::Sensor *sens = this->phases_[phase].*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_); + } + } + return true; +} +#endif + +void SelecMeter::update() { + if (this->read_state_ != ReadState::READ_STATE_IDLE) { + ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress", + LOG_STR_ARG(read_state_name(this->read_state_))); + return; + } + this->start_read_(ReadState::READ_STATE_MAIN_BLOCK); +} - publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT); - publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT); - publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT); - publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT); - publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT); - publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); +void SelecMeter::start_read_(ReadState state) { + this->read_state_ = state; + if (state == ReadState::READ_STATE_IDLE) + return; + const ReadRange range = this->read_range_(state); + // A refused send gets no callback, so unwind here like the async failure paths + if (!this->read_input_registers(range.start, range.count)) + this->fail_current_read_(LOG_STR("Modbus request refused")); } -void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" + " Model: %s\n" + " Byte Order: %s\n" " Address: 0x%02X", + this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"), + this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"), this->address_); LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_); @@ -67,6 +371,41 @@ void SelecMeter::dump_config() { LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_); +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) { + LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_); + LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_); + LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_); + LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_); + for (uint8_t phase = 0; phase < 3; phase++) { + const auto &p = this->phases_[phase]; + ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase); + LOG_SENSOR(" ", "Voltage", p.voltage_sensor_); + LOG_SENSOR(" ", "Current", p.current_sensor_); + LOG_SENSOR(" ", "Active Power", p.active_power_sensor_); + LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_); + LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_); + LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_); + LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_); + LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_); + LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_); + LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_); + } + LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_); + } +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_); +#endif +#endif } } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 470242c9184..98fea2987e9 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include @@ -15,6 +22,24 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } +// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C) +#define SELEC_METER_PHASE_SENSOR(name) \ + void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; } + +enum class Model : uint8_t { + MODEL_EM2M, + MODEL_EM4M, +}; + +// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside +// the main block and get their own transaction. +enum class ReadState : uint8_t { + READ_STATE_MAIN_BLOCK, + READ_STATE_SERIAL_NUMBER, + READ_STATE_DG_SENSING, + READ_STATE_IDLE, +}; + class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) @@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev SELEC_METER_SENSOR(maximum_demand_reactive_power) SELEC_METER_SENSOR(maximum_demand_apparent_power) +#ifdef USE_SELEC_METER_EM4M + // EM4M per-phase sensors, indexed by phase (see Phase below) + SELEC_METER_PHASE_SENSOR(voltage) + SELEC_METER_PHASE_SENSOR(current) + SELEC_METER_PHASE_SENSOR(active_power) + SELEC_METER_PHASE_SENSOR(reactive_power) + SELEC_METER_PHASE_SENSOR(apparent_power) + SELEC_METER_PHASE_SENSOR(power_factor) + SELEC_METER_PHASE_SENSOR(import_active_energy) + SELEC_METER_PHASE_SENSOR(export_active_energy) + SELEC_METER_PHASE_SENSOR(import_reactive_energy) + SELEC_METER_PHASE_SENSOR(export_reactive_energy) + SELEC_METER_PHASE_SENSOR(apparent_energy) + + // Line-to-line and aggregate sensors, not tied to one phase + SELEC_METER_SENSOR(voltage_l12) + SELEC_METER_SENSOR(voltage_l23) + SELEC_METER_SENSOR(voltage_l31) + SELEC_METER_SENSOR(average_voltage_ll) + SELEC_METER_SENSOR(net_active_energy_mains) + SELEC_METER_SENSOR(net_reactive_energy_mains) + SELEC_METER_SENSOR(net_apparent_energy_mains) + SELEC_METER_SENSOR(net_active_energy_dg) + SELEC_METER_SENSOR(net_reactive_energy_dg) + SELEC_METER_SENSOR(net_apparent_energy_dg) +#endif + + void set_model(Model model) { this->model_ = model; } + // true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order) + // false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order) + void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; } +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; } +#endif + void update() override; void on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override; + bool on_no_response(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; void dump_config() override; + + protected: + // One float field of the main block: target sensor, register and whether it is published x1000 + struct FloatField { + sensor::Sensor *SelecMeter::*sensor; + uint16_t reg; + bool kilo; + }; + + // Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than + // `min_count`, and the caller sets or clears the warning from the result + bool publish_fields_(std::span registers, std::span fields, uint8_t min_count); + bool decode_em2m_(std::span registers); +#ifdef USE_SELEC_METER_EM4M + bool decode_em4m_(std::span registers); +#ifdef USE_TEXT_SENSOR + void decode_serial_number_(std::span registers); +#endif +#ifdef USE_BINARY_SENSOR + void decode_dg_sensing_(std::span registers); +#endif +#endif + // First register and register count of the read for `state` + struct ReadRange { + uint16_t start; + uint8_t count; + }; + ReadRange read_range_(ReadState state) const; + // Whether the read cycle should run `state` on this pass + bool wanted_(ReadState state) const; + // Next wanted step after `current`, so one failed range does not cancel the other reads + ReadState next_read_state_after_(ReadState current) const; + // Sends the read for `state`; safe from a callback per the modbus callback contract + void start_read_(ReadState state); + // Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and + // only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged + void fail_current_read_(const LogString *reason, bool durable = false); + +#ifdef USE_SELEC_METER_EM4M + // phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart + struct Phase { + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *active_power_sensor_{nullptr}; + sensor::Sensor *reactive_power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + sensor::Sensor *import_active_energy_sensor_{nullptr}; + sensor::Sensor *export_active_energy_sensor_{nullptr}; + sensor::Sensor *import_reactive_energy_sensor_{nullptr}; + sensor::Sensor *export_reactive_energy_sensor_{nullptr}; + sensor::Sensor *apparent_energy_sensor_{nullptr}; + }; + Phase phases_[3]; +#endif + + Model model_{Model::MODEL_EM2M}; + bool word_swap_{false}; + ReadState read_state_{ReadState::READ_STATE_IDLE}; + + // An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + text_sensor::TextSensor *serial_number_sensor_{nullptr}; + // Also set to the limit once the serial number is published, since it never changes + uint8_t serial_number_failures_{0}; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr}; + uint8_t dg_sensing_failures_{0}; +#endif }; } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index d299560aaba..e7678ccabee 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,30 +1,117 @@ #pragma once +#include + namespace esphome::selec_meter { -static const float TWO_DEC_UNIT = 0.01; -static const float ONE_DEC_UNIT = 0.1; -static const float NO_DEC_UNIT = 1; -static const float MULTIPLY_TEN_UNIT = 10; -static const float MULTIPLY_THOUSAND_UNIT = 1000; - /* PHASE STATUS REGISTERS */ -static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; -static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; -static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; -static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; -static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; -static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; -static const uint16_t SELEC_APPARENT_ENERGY = 0x000C; -static const uint16_t SELEC_ACTIVE_POWER = 0x000E; -static const uint16_t SELEC_REACTIVE_POWER = 0x0010; -static const uint16_t SELEC_APPARENT_POWER = 0x0012; -static const uint16_t SELEC_VOLTAGE = 0x0014; -static const uint16_t SELEC_CURRENT = 0x0016; -static const uint16_t SELEC_POWER_FACTOR = 0x0018; -static const uint16_t SELEC_FREQUENCY = 0x001A; -static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; -static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; -static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; +static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; +static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; +static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; +static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; +static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; +static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; +static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C; +static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E; +static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010; +static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012; +static constexpr uint16_t SELEC_VOLTAGE = 0x0014; +static constexpr uint16_t SELEC_CURRENT = 0x0016; +static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018; +static constexpr uint16_t SELEC_FREQUENCY = 0x001A; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; + +/* EM4M REGISTERS -- offsets are register indices from input register 30000 */ +static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers + +/* EM4M -- common quantities shared with the EM2M sensor keys */ +static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN +static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current +static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW +static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr +static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA +static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF +static constexpr uint16_t EM4M_FREQUENCY = 0x0038; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export +static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL + +// Net (import - export) energy totals, split by source. Only meaningful when the +// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs. +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS) +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG) + +/* EM4M -- per-phase quantities, no EM2M equivalent */ +static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N +static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N +static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N +static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12 +static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23 +static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31 +static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010; +static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012; +static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014; +static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1 +static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2 +static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3 +static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1 +static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2 +static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3 +static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1 +static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2 +static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3 +static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030; +static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032; +static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export +static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3 + +// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above +// document the datasheet and are checked against that stride here. +static constexpr uint16_t EM4M_PHASE_STRIDE = 2; +#define SELEC_EM4M_CHECK_STRIDE(name) \ + static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \ + EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \ + #name " per-phase registers must be EM4M_PHASE_STRIDE apart"); +SELEC_EM4M_CHECK_STRIDE(VOLTAGE) +SELEC_EM4M_CHECK_STRIDE(CURRENT) +SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER) +SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR) +SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY) +#undef SELEC_EM4M_CHECK_STRIDE + +// Far outside the main 0-113 register block; read via a separate Modbus transaction. +static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684 +static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710 } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 04aef00d46b..5e6709ec97d 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg -from esphome.components import modbus, sensor +from esphome.components import binary_sensor, modbus, sensor, text_sensor +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_SERIAL_NUMBER, +) import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -11,6 +17,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_MODEL, + CONF_PHASE_A, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, @@ -24,6 +34,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, @@ -37,9 +48,22 @@ from esphome.const import ( ) from esphome.types import ConfigType -AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] +CONF_DG_SENSING = "dg_sensing" + + +def AUTO_LOAD(config: ConfigType) -> list[str]: + return ["modbus"] + [ + component + for key, component in ( + (CONF_SERIAL_NUMBER, "text_sensor"), + (CONF_DG_SENSING, "binary_sensor"), + ) + if not config or key in config + ] + + CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" CONF_APPARENT_ENERGY = "apparent_energy" @@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" +CONF_VOLTAGE_L12 = "voltage_l12" +CONF_VOLTAGE_L23 = "voltage_l23" +CONF_VOLTAGE_L31 = "voltage_l31" + +CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll" +CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains" +CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains" +CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains" +CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg" +CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg" +CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) +SelecMeterModel = selec_meter_ns.enum("Model", is_class=True) -SENSORS = { +MODEL_EM2M = "em2m" +MODEL_EM4M = "em4m" +MODELS = { + MODEL_EM2M: SelecMeterModel.MODEL_EM2M, + MODEL_EM4M: SelecMeterModel.MODEL_EM4M, +} + +# Register word order of the FP32 values: big_endian sends the high word first (ABCD), +# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own +# register 40070 (Endianness Selection) menu names. +BYTE_ORDER_MSRF = "msrf" +BYTE_ORDER_LSRF = "lsrf" +# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side. +BYTE_ORDER_WORD_SWAP = { + BYTE_ORDER_BIG: False, + BYTE_ORDER_LITTLE: True, + BYTE_ORDER_MSRF: False, + BYTE_ORDER_LSRF: True, +} +validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True) + +# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M. +EM2M_SENSORS = { CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_APPARENT_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + +# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate +# (Total/Average) registers rather than a single-phase measurement. +COMMON_SENSORS = { CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, @@ -72,11 +145,6 @@ SENSORS = { device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=2, @@ -87,11 +155,6 @@ SENSORS = { accuracy_decimals=2, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_APPARENT_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=3, @@ -154,13 +217,115 @@ SENSORS = { ), } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}) - .extend( - {cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()} + +def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]: + """One identical sensor_schema() per key.""" + return {key: sensor.sensor_schema(**schema_kwargs) for key in keys} + + +# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like +# sdm_meter/atm90e32 do for 3-phase meters. +PHASE_SENSORS = { + key: {**COMMON_SENSORS, **EM2M_SENSORS}[key] + for key in ( + CONF_VOLTAGE, + CONF_CURRENT, + CONF_ACTIVE_POWER, + CONF_REACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_IMPORT_ACTIVE_ENERGY, + CONF_EXPORT_ACTIVE_ENERGY, + CONF_IMPORT_REACTIVE_ENERGY, + CONF_EXPORT_REACTIVE_ENERGY, + CONF_APPARENT_ENERGY, ) - .extend(cv.polling_component_schema("10s")) - .extend(modbus.modbus_device_schema(0x01)) +} + +PHASE_SCHEMA = cv.Schema( + {cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()} +) + +# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they +# stay flat rather than living under phase_a/phase_b/phase_c. +EM4M_SENSORS = { + **_sensor_group( + CONF_VOLTAGE_L12, + CONF_VOLTAGE_L23, + CONF_VOLTAGE_L31, + CONF_AVERAGE_VOLTAGE_LL, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + # Net (import - export) energy totals, split by source. Only meaningful when the meter's + # "Dual Source" setting is Yes; DG registers read 0 on single-source installs. + **_sensor_group( + CONF_NET_ACTIVE_ENERGY_MAINS, + CONF_NET_ACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_REACTIVE_ENERGY_MAINS, + CONF_NET_REACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_APPARENT_ENERGY_MAINS, + CONF_NET_APPARENT_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), +} + +PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + + +def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]: + return {cv.Optional(key): schema for key, schema in sensors.items()} + + +_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend( + cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01) +) + +# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory +# default (MSRF / Big Endian), confirmed against real hardware. +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_EM2M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM2M_SENSORS), + } + ), + MODEL_EM4M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_BIG + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM4M_SENSORS), + **{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES}, + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + default_type=MODEL_EM2M, + enum=MODELS, + lower=True, ) @@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for name in SENSORS: - if name in config: - sens = await sensor.new_sensor(config[name]) - cg.add(getattr(var, f"set_{name}_sensor")(sens)) + cg.add(var.set_model(config[CONF_MODEL])) + cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) + if config[CONF_MODEL] == MODEL_EM4M: + cg.add_define("USE_SELEC_METER_EM4M") + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) + await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors( + CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor + ) + await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor) + await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor) + await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor) + await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor) + await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor) + await sensors( + CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor + ) + await sensors( + CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor + ) + await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor) + await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor) + await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor) + for phase_index, phase in enumerate(PHASES): + if (phase_config := config.get(phase)) is None: + continue + for name in PHASE_SENSORS: + if name in phase_config: + sens = await sensor.new_sensor(phase_config[name]) + cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens)) + await text_sensor.sub_text_sensors(config)( + CONF_SERIAL_NUMBER, var.set_serial_number_sensor + ) + await binary_sensor.sub_binary_sensors(config)( + CONF_DG_SENSING, var.set_dg_sensing_sensor + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fc6dd82ed98..a7d2084a540 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -251,6 +251,7 @@ #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT +#define USE_SELEC_METER_EM4M #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/selec_meter/common-em4m.yaml b/tests/components/selec_meter/common-em4m.yaml new file mode 100644 index 00000000000..793e3c30a6c --- /dev/null +++ b/tests/components/selec_meter/common-em4m.yaml @@ -0,0 +1,129 @@ +sensor: + - platform: selec_meter + modbus_id: modbus_bus + model: em4m + byte_order: little_endian + phase_a: + voltage: + name: SelecEM4M Voltage L1 + current: + name: SelecEM4M Current L1 + active_power: + name: SelecEM4M Active Power L1 + reactive_power: + name: SelecEM4M Reactive Power L1 + apparent_power: + name: SelecEM4M Apparent Power L1 + power_factor: + name: SelecEM4M Power Factor L1 + import_active_energy: + name: SelecEM4M Import Active Energy L1 + export_active_energy: + name: SelecEM4M Export Active Energy L1 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L1 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L1 + apparent_energy: + name: SelecEM4M Apparent Energy L1 + phase_b: + voltage: + name: SelecEM4M Voltage L2 + current: + name: SelecEM4M Current L2 + active_power: + name: SelecEM4M Active Power L2 + reactive_power: + name: SelecEM4M Reactive Power L2 + apparent_power: + name: SelecEM4M Apparent Power L2 + power_factor: + name: SelecEM4M Power Factor L2 + import_active_energy: + name: SelecEM4M Import Active Energy L2 + export_active_energy: + name: SelecEM4M Export Active Energy L2 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L2 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L2 + apparent_energy: + name: SelecEM4M Apparent Energy L2 + phase_c: + voltage: + name: SelecEM4M Voltage L3 + current: + name: SelecEM4M Current L3 + active_power: + name: SelecEM4M Active Power L3 + reactive_power: + name: SelecEM4M Reactive Power L3 + apparent_power: + name: SelecEM4M Apparent Power L3 + power_factor: + name: SelecEM4M Power Factor L3 + import_active_energy: + name: SelecEM4M Import Active Energy L3 + export_active_energy: + name: SelecEM4M Export Active Energy L3 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L3 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L3 + apparent_energy: + name: SelecEM4M Apparent Energy L3 + voltage_l12: + name: SelecEM4M Voltage L12 + voltage_l23: + name: SelecEM4M Voltage L23 + voltage_l31: + name: SelecEM4M Voltage L31 + average_voltage_ll: + name: SelecEM4M Average Voltage LL + active_power: + name: SelecEM4M Active Power + reactive_power: + name: SelecEM4M Reactive Power + apparent_power: + name: SelecEM4M Apparent Power + voltage: + name: SelecEM4M Average Voltage + current: + name: SelecEM4M Average Current + power_factor: + name: SelecEM4M Average Power Factor + frequency: + name: SelecEM4M Frequency + import_active_energy: + name: SelecEM4M Total Import Active Energy + export_active_energy: + name: SelecEM4M Total Export Active Energy + import_reactive_energy: + name: SelecEM4M Total Import Reactive Energy + export_reactive_energy: + name: SelecEM4M Total Export Reactive Energy + net_active_energy_mains: + name: SelecEM4M Net Active Energy Mains + net_reactive_energy_mains: + name: SelecEM4M Net Reactive Energy Mains + net_apparent_energy_mains: + name: SelecEM4M Net Apparent Energy Mains + net_active_energy_dg: + name: SelecEM4M Net Active Energy DG + net_reactive_energy_dg: + name: SelecEM4M Net Reactive Energy DG + net_apparent_energy_dg: + name: SelecEM4M Net Apparent Energy DG + maximum_demand_active_power: + name: SelecEM4M Maximum Demand Active Power + disabled_by_default: true + maximum_demand_reactive_power: + name: SelecEM4M Maximum Demand Reactive Power + disabled_by_default: true + maximum_demand_apparent_power: + name: SelecEM4M Maximum Demand Apparent Power + disabled_by_default: true + serial_number: + name: SelecEM4M Serial Number + dg_sensing: + name: SelecEM4M DG Sensing diff --git a/tests/components/selec_meter/test-em4m.esp32-idf.yaml b/tests/components/selec_meter/test-em4m.esp32-idf.yaml new file mode 100644 index 00000000000..56c7c38bf93 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO26 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test-em4m.esp8266-ard.yaml b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml new file mode 100644 index 00000000000..7cdabad7d66 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + flow_control_pin: GPIO15 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test.esp32-idf.yaml b/tests/components/selec_meter/test.esp32-idf.yaml index 4df9f5863e1..3c588d0afcd 100644 --- a/tests/components/selec_meter/test.esp32-idf.yaml +++ b/tests/components/selec_meter/test.esp32-idf.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO26 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 6daa08c22bd..6f4125b47b4 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO15 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.rp2040-ard.yaml b/tests/components/selec_meter/test.rp2040-ard.yaml index a65500ccc38..687622481ea 100644 --- a/tests/components/selec_meter/test.rp2040-ard.yaml +++ b/tests/components/selec_meter/test.rp2040-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO6 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml - -<<: !include common.yaml diff --git a/tests/unit_tests/components/selec_meter/__init__.py b/tests/unit_tests/components/selec_meter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/selec_meter/test_sensor.py b/tests/unit_tests/components/selec_meter/test_sensor.py new file mode 100644 index 00000000000..b4b14cd7e1f --- /dev/null +++ b/tests/unit_tests/components/selec_meter/test_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the selec_meter configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.selec_meter import sensor as selec_meter +import esphome.config_validation as cv +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return selec_meter.CONFIG_SCHEMA(config) + + +def test_model_defaults_to_em2m() -> None: + config = _validate({"voltage": {"name": "Voltage"}}) + assert config["model"] == selec_meter.MODEL_EM2M + + +def test_model_is_case_insensitive() -> None: + config = _validate({"model": "EM4M"}) + assert config["model"] == selec_meter.MODEL_EM4M + + +def test_model_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"): + _validate({"model": "em9m"}) + + +@pytest.mark.parametrize( + ("model", "key", "value"), + [ + ("em2m", "voltage_l12", {"name": "Test"}), + ("em2m", "serial_number", {"name": "Test"}), + ("em2m", "dg_sensing", {"name": "Test"}), + ("em2m", "phase_a", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_b", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_c", {"voltage": {"name": "Voltage"}}), + ("em4m", "total_active_energy", {"name": "Test"}), + ("em4m", "total_reactive_energy", {"name": "Test"}), + ("em4m", "apparent_energy", {"name": "Test"}), + ], +) +def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed") as err: + _validate({"model": model, key: value}) + assert err.value.path == [key] + + +def test_em4m_only_key_rejected_on_default_model() -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed"): + _validate({"voltage_l12": {"name": "Test"}}) + + +def test_em4m_with_phases_accepted() -> None: + config = _validate( + { + "model": "em4m", + "phase_a": {"voltage": {"name": "Voltage L1"}}, + "phase_c": {"power_factor": {"name": "PF L3"}}, + } + ) + assert "voltage" in config["phase_a"] + assert "power_factor" in config["phase_c"] + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "little_endian", True), + ("em4m", "big_endian", False), + ], +) +def test_byte_order_default_per_model( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model}) + assert config["byte_order"] == byte_order + assert config["byte_order"].enum_value is word_swap + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "big_endian", False), + ("em4m", "LITTLE_ENDIAN", True), + ("em4m", "lsrf", True), + ("em2m", "msrf", False), + ], +) +def test_byte_order_explicit_override( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model, "byte_order": byte_order}) + assert config["byte_order"].enum_value is word_swap + + +def test_byte_order_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + _validate({"byte_order": "middle_endian"}) From e289a944516584213c1adf8a7b3e5eb92a1181ce Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 23:11:21 -0400 Subject: [PATCH 059/111] [friedrich] New Climate IR component (#9178) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/friedrich/__init__.py | 1 + esphome/components/friedrich/climate.py | 32 +++ esphome/components/friedrich/friedrich.cpp | 234 ++++++++++++++++++ esphome/components/friedrich/friedrich.h | 47 ++++ tests/components/friedrich/__init__.py | 15 ++ tests/components/friedrich/common.yaml | 5 + tests/components/friedrich/friedrich_test.cpp | 215 ++++++++++++++++ .../components/friedrich/test.esp32-idf.yaml | 4 + .../friedrich/test.esp8266-ard.yaml | 4 + 10 files changed, 558 insertions(+) create mode 100644 esphome/components/friedrich/__init__.py create mode 100644 esphome/components/friedrich/climate.py create mode 100644 esphome/components/friedrich/friedrich.cpp create mode 100644 esphome/components/friedrich/friedrich.h create mode 100644 tests/components/friedrich/__init__.py create mode 100644 tests/components/friedrich/common.yaml create mode 100644 tests/components/friedrich/friedrich_test.cpp create mode 100644 tests/components/friedrich/test.esp32-idf.yaml create mode 100644 tests/components/friedrich/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f068a3a2215..fc14cb33d09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core +esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio diff --git a/esphome/components/friedrich/__init__.py b/esphome/components/friedrich/__init__.py new file mode 100644 index 00000000000..09f1d1df4fe --- /dev/null +++ b/esphome/components/friedrich/__init__.py @@ -0,0 +1 @@ +DOMAIN = "friedrich" diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py new file mode 100644 index 00000000000..52f1f35708d --- /dev/null +++ b/esphome/components/friedrich/climate.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_SENSOR +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@rwrozelle"] + +friedrich_ns = cg.esphome_ns.namespace("friedrich") +FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) + +Model = friedrich_ns.enum("Model", is_class=True) +MODELS = { + "MW12Y3H": Model.MODEL_MW12Y3H, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( + { + cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True), + # The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it + cv.Optional(CONF_SENSOR): cv.invalid( + "'sensor' is not supported by the Friedrich climate component" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/friedrich/friedrich.cpp b/esphome/components/friedrich/friedrich.cpp new file mode 100644 index 00000000000..b1fcfe06150 --- /dev/null +++ b/esphome/components/friedrich/friedrich.cpp @@ -0,0 +1,234 @@ +#include "friedrich.h" + +#include + +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::friedrich { +// clang-format off +/* +* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation. +* AEHA +* Byte0 Fixed = 0x00 +* Byte1 Fixed = 0x08 +* Byte2 Fixed = 0x08 +* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F +* Byte4 Checksum when command, else Fixed = 0x90 +* Byte5 N/A when command, else Fixed = 0x0C +* Byte6 N/A when command, else Temp(F), add 0x80 for On +* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed) +* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing +* Byte9 N/A when command, else Fixed = 0x00 // not interested +* Byte10 N/A when command, else Fixed = 0x00 // not interested +* Byte11 N/A when command, else Fixed = 0x00 // not interested +* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04) +* Byte13 N/A when command, else CheckSum +*/ +// clang-format on +ESPHOME_LOG_TAG(TAG, "friedrich"); + +static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64; +static constexpr uint8_t TEMP_DEFAULT = 72; +static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5; +static constexpr uint16_t CARRIER_ADDRESS = 0x28C6; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; + +static constexpr uint8_t BYTE3_POWER_OFF = 0x40; +static constexpr uint8_t BYTE6_POWER_ON = 0x80; +static constexpr uint8_t BYTE7_MODE_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_SWING = 0x08; + +// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state +static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = { + 0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00}; +// The off command, including its checksum (255 - byte 3) +static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF, + 255 - BYTE3_POWER_OFF}; + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO}, + {climate::CLIMATE_MODE_COOL, 0x80}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0xC0}, + {climate::CLIMATE_MODE_HEAT, 0x20}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80}, + {climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0}, + {climate::CLIMATE_FAN_QUIET, 0x20}, +}; + +// The entry whose value (or code) matches key. Entries are copied out of PROGMEM. +template static optional find_entry(const Code (&table)[N], uint8_t key, bool match_code) { + for (const Code &slot : table) { + Code entry; + progmem_memcpy(&entry, &slot, sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + return {}; +} + +// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse. +static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; } + +static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) { + for (uint8_t i = from; i <= to; i++) { + if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i])) + return false; + } + return true; +} + +/* + * https://gist.github.com/GeorgeDewar/11171561 + * 1. Reverse (flip) bytes 6 - 13) + * 2. Sum those bytes + * 3. (208 - sum) % 256 + * 4. Reverse (flip) bytes of result + */ +static uint8_t checksum_state(const uint8_t *data) { + uint8_t sum = 0; + for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) { + sum += reverse_bits(data[i]); + } + return reverse_bits(static_cast(208 - sum)); +} + +static const LogString *model_to_string(Model model) { + switch (model) { + case Model::MODEL_MW12Y3H: + default: + return LOG_STR("MW12Y3H"); + } +} + +climate::ClimateTraits FriedrichClimate::traits() { + auto traits = ClimateIR::traits(); + traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT); + return traits; +} + +void FriedrichClimate::setup() { + ClimateIR::setup(); + // The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version. + if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) { + this->target_temperature = TEMP_DEFAULT; + } + this->target_temperature = std::min(this->target_temperature, TEMP_MAX); +} + +void FriedrichClimate::dump_config() { + // ClimateIR::dump_config() is not called: it prints the temperature range in Celsius + LOG_CLIMATE("", "Friedrich Climate", this); + ESP_LOGCONFIG(TAG, + " Temperature range: %u to %u °F\n" + " Model: %s", + TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_))); +} + +void FriedrichClimate::transmit_state() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_off_(); + return; + } + + ESP_LOGV(TAG, "Transmit state"); + + // Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it, + // so the state published after this call matches the frame sent. + const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT; + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, effective_min, TEMP_MAX)); + temperature += temperature % 2; + this->target_temperature = temperature; + + const auto mode = find_entry(MODES, this->mode, false); + const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false); + + uint8_t frame[STATE_MESSAGE_LENGTH]; + progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame)); + frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP); + frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO; + frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO; + if (this->swing_mode != climate::CLIMATE_SWING_OFF) + frame[8] |= BYTE8_FAN_SWING; + frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame); + + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_off_() { + ESP_LOGV(TAG, "Transmit off"); + + uint8_t frame[STATE_MESSAGE_LENGTH_UTIL]; + progmem_memcpy(frame, OFF_FRAME, sizeof(frame)); + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) { + ESP_LOGV(TAG, "Transmit message"); + + auto transmit = this->transmitter_->transmit(); + auto *dst = transmit.get_data(); + dst->set_carrier_frequency(CARRIER_FREQUENCY); + remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len); + transmit.perform(); +} + +bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) { + ESP_LOGV(TAG, "Received message"); + const auto odata = remote_base::AEHAProtocol().decode(src); + if (!odata.has_value()) + return false; + const auto &d = odata->data; + // Other vendors also use AEHA; only accept frames with our address and fixed header bytes. + if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2)) + return false; + + if (d.size() == STATE_MESSAGE_LENGTH_UTIL) { + // Not looking for other types of messages + if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3]) + return false; + ESP_LOGV(TAG, "Received off message"); + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + + if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) || + !matches_template(d.data(), 3, 5)) + return false; + + // Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY) + const auto mode = find_entry(MODES, d[7], true); + if (!mode.has_value()) { + ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]); + return false; + } + this->mode = static_cast(mode->value); + + // State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is + // stripped rather than acted on + const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON; + const uint8_t temp_index = reverse_nibble(temp_code); + if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP) + this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP; + + this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true); + this->fan_mode = fan.has_value() ? static_cast(fan->value) : climate::CLIMATE_FAN_AUTO; + + this->publish_state(); + return true; +} + +} // namespace esphome::friedrich diff --git a/esphome/components/friedrich/friedrich.h b/esphome/components/friedrich/friedrich.h new file mode 100644 index 00000000000..3a0be00f00a --- /dev/null +++ b/esphome/components/friedrich/friedrich.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::friedrich { + +// Extension point for other units: add new models here and branch on model_ where they differ. +// The default must stay MW12Y3H so existing configurations do not change. +enum class Model : uint8_t { + MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit +}; + +class FriedrichClimate : public climate_ir::ClimateIR { + public: + // The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values. + static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating + static constexpr uint8_t TEMP_MAX = 88; + static constexpr uint8_t TEMP_STEP = 2; + static constexpr uint8_t STATE_MESSAGE_LENGTH = 14; + + FriedrichClimate() + : ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + void set_model(Model model) { this->model_ = model; } + + protected: + void setup() override; + void dump_config() override; + climate::ClimateTraits traits() override; + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Transmit via IR power off command. + void transmit_off_(); + + /// Parse incoming message + bool on_receive(remote_base::RemoteReceiveData src) override; + + /// Transmit message as IR pulses + void transmit_(const uint8_t *data, uint8_t len); + + Model model_{Model::MODEL_MW12Y3H}; +}; + +} // namespace esphome::friedrich diff --git a/tests/components/friedrich/__init__.py b/tests/components/friedrich/__init__.py new file mode 100644 index 00000000000..3028881357b --- /dev/null +++ b/tests/components/friedrich/__init__.py @@ -0,0 +1,15 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The AEHA source is compiled only behind its define; climate.py requests it in the real build. + request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/friedrich/common.yaml b/tests/components/friedrich/common.yaml new file mode 100644 index 00000000000..e56be394c59 --- /dev/null +++ b/tests/components/friedrich/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: friedrich + name: Friedrich Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp new file mode 100644 index 00000000000..610eca9c091 --- /dev/null +++ b/tests/components/friedrich/friedrich_test.cpp @@ -0,0 +1,215 @@ +#include +#include + +#include "esphome/components/friedrich/friedrich.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::friedrich::testing { + +using remote_base::AEHAData; +using remote_base::AEHAProtocol; + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +class TestFriedrich : public FriedrichClimate { + public: + using FriedrichClimate::on_receive; + using FriedrichClimate::setup; + using FriedrichClimate::traits; + using FriedrichClimate::transmit_state; +}; + +constexpr uint16_t ADDRESS = 0x28C6; +constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan +constexpr uint8_t TEMP_72_CODE = 0x06; + +struct Fixture { + TestFriedrich sut; + CapturingTransmitter transmitter; + + Fixture() { this->sut.set_transmitter(&this->transmitter); } + + // The last transmission decoded, or an empty frame if it could not be decoded + AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } + + bool receive(const AEHAData &frame) { + remote_base::RemoteTransmitData tx; + AEHAProtocol().encode(&tx, frame); + return this->sut.on_receive(rx_of(tx.get_data())); + } + + static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) { + return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + } +}; + +// Independent implementation of the 14 byte frame checksum +static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) { + AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}}; + uint8_t sum = 0; + for (int i = 6; i < 13; i++) + sum += reverse_bits(f.data[i]); + f.data[13] = reverse_bits(static_cast(208 - sum)); + return f; +} + +static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; } + +TEST(FriedrichTests, TraitsReportFahrenheit) { + Fixture f; + auto traits = f.sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f); +} + +TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_OFF; + f.sut.transmit_state(); + ASSERT_EQ(f.transmitter.sends, 1); + EXPECT_EQ(f.sent(), off_frame()); +} + +TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 72; + f.sut.fan_mode = climate::CLIMATE_FAN_AUTO; + f.sut.swing_mode = climate::CLIMATE_SWING_OFF; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80)); +} + +TEST(FriedrichTests, OddTargetStepsUpToEven) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 71; + f.sut.transmit_state(); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent +} + +// Codes captured from the remote +TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) { + static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, + 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07}; + for (size_t i = 0; i < sizeof(CODES); i++) { + const auto fahrenheit = static_cast(60 + i * 2); + SCOPED_TRACE(fahrenheit); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = fahrenheit; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20)); + + Fixture g; + EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20))); + EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit); + } +} + +TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F + + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f); +} + +TEST(FriedrichTests, ReceivesStateFrame) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80))); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH); +} + +TEST(FriedrichTests, ReceivesOffFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + EXPECT_TRUE(f.receive(off_frame())); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 80; + EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f); +} + +TEST(FriedrichTests, IgnoresCorruptedFrames) { + struct Case { + const char *name; + AEHAData frame; + std::function corrupt; + }; + const Case cases[] = { + {"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }}, + {"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }}, + {"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }}, + {"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }}, + }; + for (const auto &c : cases) { + SCOPED_TRACE(c.name); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + AEHAData frame = c.frame; + c.corrupt(frame); + EXPECT_FALSE(f.receive(frame)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + } +} + +TEST(FriedrichTests, OwnTransmissionIsAccepted) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY; + f.sut.target_temperature = 76; + f.sut.fan_mode = climate::CLIMATE_FAN_LOW; + f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL; + f.sut.transmit_state(); + + Fixture g; + EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last))); + EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f); + EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) { + Fixture f; + f.sut.setup(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +} // namespace esphome::friedrich::testing diff --git a/tests/components/friedrich/test.esp32-idf.yaml b/tests/components/friedrich/test.esp32-idf.yaml new file mode 100644 index 00000000000..bee19625978 --- /dev/null +++ b/tests/components/friedrich/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + friedrich: !include common.yaml diff --git a/tests/components/friedrich/test.esp8266-ard.yaml b/tests/components/friedrich/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d58d8014944 --- /dev/null +++ b/tests/components/friedrich/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + friedrich: !include common.yaml From 7881dfc5d88bd028f61fce78b46f668db1dfcd1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:29:42 -0500 Subject: [PATCH 060/111] [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/deep_sleep/__init__.py | 67 +++++++++++-------- .../deep_sleep/test_deep_sleep.py | 63 +++++++++++++++++ 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 2b820c9be6c..5f340c60efe 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -143,6 +143,8 @@ def validate_wakeup_pin( processed_pins: list[ConfigType] = [{CONF_PIN: value}] else: processed_pins = list(value) + if not processed_pins: + raise cv.Invalid("'wakeup_pin' needs at least one entry") for i, pin_config in enumerate(processed_pins): # now validate each item @@ -154,29 +156,34 @@ def validate_wakeup_pin( def validate_config(config: ConfigType) -> ConfigType: - # right now only BK72XX supports the list format for wakeup pins - if CORE.is_bk72xx: - if CONF_WAKEUP_PIN_MODE in config: - wakeup_pins = config.get(CONF_WAKEUP_PIN, []) - if len(wakeup_pins) > 1: - raise cv.Invalid( - "You need to remove the global wakeup_pin_mode and define it per pin" - ) - if wakeup_pins: - if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: - raise cv.Invalid( - "Specify wakeup_pin_mode either at the top level under deep_sleep " - "or under the pin entry, not both" - ) - wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) - elif ( - isinstance(config.get(CONF_WAKEUP_PIN), list) - and len(config[CONF_WAKEUP_PIN]) > 1 - ): + wakeup_pins = config.get(CONF_WAKEUP_PIN) + has_pin_mode = CONF_WAKEUP_PIN_MODE in config + if has_pin_mode and wakeup_pins is None: raise cv.Invalid( - "Your platform does not support providing multiple entries in wakeup_pin" + "'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE] ) - + multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1 + # wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list + if not CORE.is_bk72xx: + if multiple_pins: + raise cv.Invalid( + "ESP32 does not support multiple entries for 'wakeup_pin'", + path=[CONF_WAKEUP_PIN], + ) + elif has_pin_mode: + if multiple_pins: + raise cv.Invalid( + "Remove the global 'wakeup_pin_mode' and define it per pin", + path=[CONF_WAKEUP_PIN_MODE], + ) + if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + raise cv.Invalid( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both", + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # Move the global mode into the single pin entry, which to_code() reads + pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) return config @@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: if CORE.is_bk72xx: max_duration = core.TimePeriod(hours=36) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX") elif CORE.using_zephyr: max_duration = core.TimePeriod(days=49) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") + raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr") return value @@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, _validate_sleep_duration, ), - cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, + cv.Optional(CONF_WAKEUP_PIN): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + validate_wakeup_pin, + ), cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), cv.enum(WAKEUP_PIN_MODES, upper=True), @@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None: if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) - if CONF_WAKEUP_PIN in config: - pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins(len(pins_as_list))) - for item in pins_as_list: + cg.add(var.init_wakeup_pins(len(wakeup_pins))) + for item in wakeup_pins: cg.add( var.add_wakeup_pin( await cg.gpio_pin_expression(item[CONF_PIN]), @@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e68b1d17cc1..e05109f246a 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( assert ( result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" ) + + +def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None: + """An empty wakeup_pin list is rejected at validation instead of crashing codegen.""" + with pytest.raises(cv.Invalid, match="at least one entry"): + deep_sleep.validate_wakeup_pin([]) + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32 the same mode-only config is rejected too, instead of being silently dead.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +@pytest.mark.parametrize( + "platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR] +) +def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + """wakeup_pin is rejected where the C++ component has no wakeup pin support.""" + set_core_config(platform) + with pytest.raises(cv.Invalid, match="only available on"): + deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"}) + + +def test_deep_sleep_esp32_multiple_wakeup_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """ESP32 accepts only one wakeup_pin entry.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]} + with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN] + + +def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE", + } + with pytest.raises(cv.Invalid, match="define it per pin") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] From 25843c5242cfabe600f3e7262f869734cc5a3a43 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 10:16:44 +0200 Subject: [PATCH 061/111] [image] Implement RGB111 format (#20114) Co-authored-by: J. Nick Koston --- esphome/components/image/__init__.py | 92 +++++++++++++++---- esphome/components/image/image.cpp | 35 +++++++ esphome/components/image/image.h | 2 + esphome/components/lvgl/__init__.py | 17 ++++ .../runtime_image/runtime_image.cpp | 16 ++++ tests/component_tests/image/test_init.py | 90 ++++++++++++++++++ tests/component_tests/lvgl/test_validation.py | 54 ++++++++++- tests/components/image/common.yaml | 16 ++++ tests/components/online_image/common.yaml | 11 +++ 9 files changed, 313 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index eaee31a1c7b..668a78f3cbf 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -145,13 +145,42 @@ def is_alpha_only(image: Image): return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) -class ImageBinary(ImageEncoder): +class BitPackedEncoder(ImageEncoder): + """Packs pixels MSB first, with every row padded to a whole byte.""" + + def __init__( + self, + bpp: int, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + self.bpp = bpp + super().__init__( + (width * bpp + 7) // 8, height, transparency, dither, invert_alpha + ) + self.bitno = 0 + + def add_bit(self, bit: bool) -> None: + if bit: + self.data[self.index] |= 0x80 >> self.bitno + self.bitno += 1 + if self.bitno == 8: + self.end_row() + + def end_row(self) -> None: + if self.bitno != 0: + self.bitno = 0 + self.index += 1 + + +class ImageBinary(BitPackedEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): - self.width8 = (width + 7) // 8 - super().__init__(self.width8, height, transparency, dither, invert_alpha) - self.bitno = 0 + super().__init__(1, width, height, transparency, dither, invert_alpha) def convert(self, image, path): if is_alpha_only(image): @@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder): return image.convert("1", dither=self.dither) def encode(self, pixel): - if self.invert_alpha: - pixel = not pixel - if pixel: - self.data[self.index] |= 0x80 >> (self.bitno % 8) - self.bitno += 1 - if self.bitno == 8: - self.bitno = 0 - self.index += 1 + self.add_bit(bool(pixel) != self.invert_alpha) - def end_row(self): - """ - Pad rows to a byte boundary - """ - if self.bitno != 0: - self.bitno = 0 - self.index += 1 + +# The eight colours one bit per channel can express, for dithering. +_RGB111_PALETTE = Image.new("P", (1, 1)) +_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"]) + + +class ImageRGB111(BitPackedEncoder): + allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL} + + def __init__( + self, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3 + super().__init__(bpp, width, height, transparency, dither, invert_alpha) + + def convert(self, image: Image, path: str): + image = image.convert("RGBA") + if self.dither != Image.Dither.FLOYDSTEINBERG: + return image + alpha = image.getchannel("A") + image = image.convert("RGB").quantize( + palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG + ) + image = image.convert("RGB") + image.putalpha(alpha) + return image + + def encode(self, pixel: tuple[int, int, int, int]): + r, g, b, a = pixel + if self.invert_alpha: + a ^= 0xFF + for channel in (r, g, b, a)[: self.bpp]: + self.add_bit(channel > 127) class ImageGrayscale(ImageEncoder): @@ -315,6 +368,7 @@ class ReplaceWith: IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, + "RGB111": ImageRGB111, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index bfe311be284..d27d110248c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,6 +3,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +ESPHOME_LOG_TAG(TAG, "image"); + namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { @@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color } break; } + case IMAGE_TYPE_RGB111: { + for (int img_x = img_x0; img_x < w; img_x++) { + for (int img_y = img_y0; img_y < h; img_y++) { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = img_x0; img_x < w; img_x++) { for (int img_y = img_y0; img_y < h; img_y++) { @@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off if (this->get_binary_pixel_(x, y)) return color_on; return color_off; + case IMAGE_TYPE_RGB111: + return this->get_rgb111_pixel_(x, y); case IMAGE_TYPE_GRAYSCALE: return this->get_grayscale_pixel_(x, y); case IMAGE_TYPE_RGB565: @@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; + case IMAGE_TYPE_RGB111: + // Rejected by the lvgl config validation + ESP_LOGE(TAG, "RGB111 images are not supported in LVGL."); + break; case IMAGE_TYPE_RGB: switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: @@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const { const uint32_t pos = x + y * width_8; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Image::get_rgb111_pixel_(int x, int y) const { + const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_; + const uint8_t *byte = this->data_start_ + bitpos / 8; + const uint8_t shift = bitpos % 8; + // A pixel can straddle two bytes; read the second one only when it does. + uint16_t window = progmem_read_byte(byte) << 8; + if (shift + this->bpp_ > 8) + window |= progmem_read_byte(byte + 1); + const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1); + const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits; + const bool opaque = this->bpp_ == 3 || (bits & 1); + return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); +} Color Image::get_rgb_pixel_(int x, int y) const { const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), @@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T case IMAGE_TYPE_BINARY: this->bpp_ = 1; break; + case IMAGE_TYPE_RGB111: + this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3; + break; case IMAGE_TYPE_GRAYSCALE: this->bpp_ = 8; break; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index fd9e92c21d3..a94f8137f41 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -13,6 +13,7 @@ enum ImageType { IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB = 2, IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper }; enum Transparency { @@ -44,6 +45,7 @@ class Image : public display::BaseImage { #endif protected: bool get_binary_pixel_(int x, int y) const; + Color get_rgb111_pixel_(int x, int y) const; Color get_rgb_pixel_(int x, int y) const; Color get_rgb565_pixel_(int x, int y) const; Color get_grayscale_pixel_(int x, int y) const; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9b2f41e0635..6e3087ba9f0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -25,6 +25,7 @@ from esphome.components.image import ( ImageBinary, ImageGrayscale, ImageRGB, + ImageRGB111, ImageRGB565, get_image_metadata, ) @@ -43,6 +44,7 @@ from esphome.const import ( CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -293,6 +295,21 @@ def final_validation(config_list): if len(config_list) != 1: multi_conf_validate(config_list) + for img in get_lv_images_used(): + path = global_config.get_path_for_id(img.id)[:-1] + # An image nested in another platform (sendspin's current_image) takes its type from the parent + while path: + image_conf = global_config.get_config_for_path(path) + if isinstance(image_conf, dict) and CONF_TYPE in image_conf: + break + path = path[:-1] + else: + continue + if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111: + raise cv.FinalExternalInvalid( + f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead", + path + [CONF_TYPE], + ) for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 6c101945746..79d2627a11c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { } break; } + case image::IMAGE_TYPE_RGB111: { + uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7); + if (this->bpp_ == 4) + bits = bits << 1 | (color.w >> 7); + const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u; + const uint32_t bitpos = y * stride * 8 + x * this->bpp_; + uint8_t *byte = this->buffer_ + bitpos / 8; + // Place the pixel in a 16-bit window so it may straddle two bytes. + const uint8_t shift = 16 - this->bpp_ - bitpos % 8; + const uint16_t mask = ((1u << this->bpp_) - 1) << shift; + const uint16_t value = bits << shift; + byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8); + if (mask & 0xFF) + byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF); + break; + } case image::IMAGE_TYPE_GRAYSCALE: { uint32_t pos = this->get_position_(x, y); auto gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index fad8b7df093..4d62bb52a19 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -102,6 +102,16 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb111", + "transparency": "chroma_key", + }, + "Image format 'RGB111' cannot have transparency: chroma_key", + id="rgb111_with_chroma_key_transparency", + ), ], ) def test_file_platform_configuration_errors( @@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame( assert all(a == 0x00 for a in frame1_alpha), ( f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "mode", + "transparency", + "frame0_color", + "frame1_color", + "frame0_row", + "frame1_row", + ), + [ + pytest.param( + "RGB", + CONF_OPAQUE, + (255, 0, 0), + (0, 0, 255), + [0x92, 0x48], + [0x24, 0x92], + id="rgb111", + ), + pytest.param( + "RGBA", + CONF_ALPHA_CHANNEL, + (255, 0, 0, 255), + (0, 0, 255, 0), + [0x99, 0x99, 0x90], + [0x22, 0x22, 0x20], + id="rgb111_alpha", + ), + ], +) +async def test_rgb111_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, + mode: str, + transparency: str, + frame0_color: tuple[int, ...], + frame1_color: tuple[int, ...], + frame0_row: list[int], + frame1_row: list[int], +) -> None: + """RGB111 packs one bit per channel MSB first and pads every row to a byte, + so each frame is a self-contained block of row_stride * height bytes. + """ + # A width that is not a multiple of 8 leaves padding bits in every row. + width = 5 + height = 3 + frame0 = PILImage.new(mode, (width, height), frame0_color) + frame1 = PILImage.new(mode, (width, height), frame1_color) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB111", + CONF_TRANSPARENCY: transparency, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + frame_size = len(frame0_row) * height + assert data == frame0_row * height + frame1_row * height, ( + f"Expected {frame_size} row-padded bytes per frame, got {data}" + ) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py index 9a767c0dae5..31cbccb9ff9 100644 --- a/tests/component_tests/lvgl/test_validation.py +++ b/tests/component_tests/lvgl/test_validation.py @@ -2,14 +2,23 @@ from __future__ import annotations +from contextlib import nullcontext + import pytest from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER from esphome.components.display import add_metadata from esphome.components.lvgl import final_validation +from esphome.components.lvgl.defines import get_lv_images_used from esphome.config import Config from esphome.config_validation import Invalid -from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ID, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, ID from esphome.final_validate import full_config @@ -175,3 +184,46 @@ class TestDrawRoundingMerge: configs = [_make_lvgl_config(["my_disp"])] final_validation(configs) assert configs[0]["draw_rounding"] == 2 + + +class TestImageTypeValidation: + """Test the image types LVGL accepts.""" + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_image_type(self, image_type: str, expectation) -> None: + image_id = ID("my_image", True) + fc = full_config.get() + fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}] + fc.declare_ids.append((image_id, ["image", 0, CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None: + """A sendspin artwork image declares its ID below the slot that holds the type.""" + image_id = ID("album_art", True) + fc = full_config.get() + fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}] + fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 5a8f9383198..6b4978fc548 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -10,6 +10,22 @@ image: type: BINARY transparency: chroma_key + - platform: file + id: rgb111_image + file: ../../pnglogo.png + type: RGB111 + - platform: file + id: rgb111_alpha_image + file: ../../pnglogo.png + type: RGB111 + transparency: alpha_channel + - platform: file + id: rgb111_dither_alpha_image + file: ../../pnglogo.png + type: RGB111 + dither: FloydSteinberg + transparency: alpha_channel + - platform: file id: rgba_image file: ../../pnglogo.png diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index df8f85596f8..2a20fa25373 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -37,6 +37,17 @@ image: format: PNG type: RGB transparency: chroma_key + - platform: online_image + id: online_rgb111_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + format: png + - platform: online_image + id: online_rgb111_transparent_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + transparency: alpha_channel + format: png - platform: online_image id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp From 77c9fa2fa4557c332ea2aebd9097482a1a9fa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:43:02 +0200 Subject: [PATCH 062/111] [emontx] Read the emonTx firmware version (#20619) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 71 ++++++++++- esphome/components/emontx/emontx.h | 13 ++ .../components/emontx/text_sensor/__init__.py | 23 ++++ esphome/core/defines.h | 1 + tests/components/emontx/common.yaml | 6 + .../components/emontx/text_sensor/__init__.py | 11 ++ .../text_sensor/emontx_version_test.cpp | 116 ++++++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 esphome/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/emontx_version_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 14dc89e56ae..9c8436c0070 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -1,19 +1,32 @@ #include "emontx.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/json/json_util.h" namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +#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; +// 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:"; +#endif + void EmonTx::setup() { this->buffer_pos_ = 0; - if (!this->lock_on_reboot_) - return; - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); - this->write_byte('\n'); - this->send_command("emonunlock"); + if (this->lock_on_reboot_) { + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); + } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } void EmonTx::on_shutdown() { @@ -48,6 +61,11 @@ void EmonTx::loop() { uint8_t dropped; while (this->available() > 0 && this->read_byte(&dropped)) 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) + this->request_version_(); +#endif } // Read all available data to prevent UART buffer overflow @@ -77,6 +95,10 @@ void EmonTx::loop() { ESP_LOGV(TAG, "Line is JSON, parsing..."); this->parse_json_(this->buffer_.data(), len); } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->parse_version_line_(this->buffer_.data(), len); +#endif } } else if (received == '\r' || this->discarding_line_) { // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause @@ -102,6 +124,16 @@ 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; @@ -112,6 +144,30 @@ 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_() { + if (this->is_paused()) + return; + this->write_byte('\n'); + this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); +} + +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) + return; + const char *version = line + prefix_len; + const char *end = line + len; + while (version < end && *version == ' ') + version++; + 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); +} +#endif + /** * @brief Logs the EmonTx component configuration details. */ @@ -127,6 +183,9 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif +#ifdef USE_EMONTX_VERSION + 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 bcec7ce171d..8f6a2424c94 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,6 +13,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_EMONTX_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::emontx { @@ -27,6 +30,9 @@ 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 + SUB_TEXT_SENSOR(version) +#endif public: EmonTx() = default; @@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); +#ifdef USE_EMONTX_VERSION + void request_version_(); + void parse_version_line_(const char *line, size_t len); +#endif #ifdef USE_SENSOR FixedVector> sensors_{}; @@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice { bool was_paused_{false}; bool lock_on_reboot_{false}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line +#ifdef USE_EMONTX_VERSION + 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 new file mode 100644 index 00000000000..9b3065f0f86 --- /dev/null +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, EmonTx + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + 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") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a7d2084a540..d9b12fdddb1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -68,6 +68,7 @@ #define USE_DEVICES #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 +#define USE_EMONTX_VERSION #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index e609a217e0e..4a5e347053a 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -24,3 +24,9 @@ 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/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..60a67b5a0b6 --- /dev/null +++ b/tests/components/emontx/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +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") + + 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 new file mode 100644 index 00000000000..fe905a0e958 --- /dev/null +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#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 { + +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; + 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()); + } + + // 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()); + } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + + MockUARTComponent uart_; + EmonTx sut_; + text_sensor::TextSensor version_; +}; +} // namespace + +// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command +TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n"); +} + +TEST_F(EmonTxVersionTest, VersionLinePublished) { + this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n"); + this->sut_.loop(); + + ASSERT_TRUE(this->version_.has_state()); + EXPECT_EQ(this->version_.get_state(), "v1.1.1"); +} + +TEST_F(EmonTxVersionTest, OtherLinesNotPublished) { + this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n"); + this->sut_.loop(); + + EXPECT_FALSE(this->version_.has_state()); +} + +// MSG restarts at 1 after a reboot, e.g. a firmware update +TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { + this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->uart_.push_rx("{\"MSG\":1}\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); + this->sut_.loop(); + this->sut_.set_paused(false); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) { + this->sut_.setup(); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n"); + + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n\nv\n"); +} + +} // namespace esphome::emontx::testing From 192ae31e454a022d858aed017dc646d7737cf5bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:19:10 -1000 Subject: [PATCH 063/111] [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) --- tests/unit_tests/test_core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 94458109753..ff36f1da18e 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -4,7 +4,7 @@ import subprocess import sys from unittest.mock import patch -from hypothesis import given +from hypothesis import given, settings import pytest from esphome import const, core @@ -31,6 +31,7 @@ class TestHexInt: class TestMACAddress: + @settings(deadline=None) @given(value=mac_addr_strings()) def test_init__valid(self, value): core.MACAddress(*value.split(":")) @@ -40,6 +41,7 @@ class TestMACAddress: with pytest.raises(ValueError, match="MAC Address must consist of 6 items"): core.MACAddress(*value.split(":")) + @settings(deadline=None) @given(value=mac_addr_strings()) def test_str(self, value): target = core.MACAddress(*(int(v, 16) for v in value.split(":"))) From fd541bca2cfd7755f036f9f8b824a5408fec5ccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:20:32 -1000 Subject: [PATCH 064/111] [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5031c4a457f..e00888b5f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting * **C++ Preprocessor Directives:** * **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums. + * **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope + and inside functions; `static const` is only for a value computed at run time. A function-local + `static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too. * **Use `#define` only for:** - Conditional compilation (`#ifdef`, `#ifndef`) - Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`) @@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting listener base (a second vptr per entity). * **A global feature define** says the code is compiled in, not that this instance uses it: keep the per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. + * **Model-specific code** sits behind a define the Python side emits only for configs that use that model + (`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check + stays inside the `#ifdef`. * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a @@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** + * **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...}, + key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator + that rejects keys and fills defaults afterwards. * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs on. A variant per board for platform-identical code only adds CI time. + * **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option + that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to + `ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-` file. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ From 29a668af0443ab80e29fa29753a56160bbd57db3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:17 +0000 Subject: [PATCH 065/111] [esp32_camera] Fix compile warning (#20628) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32_camera/esp32_camera.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index ef29fc9b25d..a36fc763a68 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -282,7 +282,7 @@ void ESP32Camera::loop() { } this->frame_count_++; if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) { - ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); + ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); this->last_log_time_ = now; this->frame_count_ = 0; } From c1c1d2b2ae7d199b7579c99e93091f7d9fb8107f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 07:28:32 -1000 Subject: [PATCH 066/111] [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) --- esphome/__main__.py | 34 +- esphome/espota2.py | 346 +++++++++++--- tests/unit_tests/test_espota2.py | 4 +- tests/unit_tests/test_espota2_noise.py | 629 ++++++++++++++++++++++++- 4 files changed, 918 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 2b094e67a14..82ef585e1a9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -483,12 +483,9 @@ def has_ota() -> bool: def has_native_ota() -> bool: """Check if native API OTA upload is available (``platform: esphome``).""" - if CONF_OTA not in CORE.config: - return False - return any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - for ota_item in CORE.config[CONF_OTA] - ) + from esphome.espota2 import esphome_ota_items + + return bool(esphome_ota_items(CORE.config)) def has_web_server_ota() -> bool: @@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: return CONF_WEB_SERVER +def _esphome_ota_conf(config: ConfigType) -> ConfigType | None: + """The validated esphome OTA platform block, if configured.""" + from esphome.espota2 import esphome_ota_items + + return next(iter(esphome_ota_items(config)), None) + + def _upload_via_native_api( config: ConfigType, network_devices: list[str], args: ArgsProtocol ) -> tuple[int, str | None]: - ota_conf: ConfigType = {} - for ota_item in config.get(CONF_OTA, []): - if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME: - ota_conf = ota_item - break + ota_conf = _esphome_ota_conf(config) or {} from esphome import espota2 from esphome.components.noise import static_encryption_key @@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None: `upload` may send a stale image that still carries the previous key.""" from esphome import espota2 - for item in config.get(CONF_OTA, []): - if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( - item.get(CONF_ENCRYPTION) or {} - ).get(espota2.CONF_OLD_KEY): + for item in espota2.esphome_ota_items(config): + if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY): _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) @@ -1468,12 +1466,12 @@ def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: from esphome import web_server_ota + from esphome.espota2 import esphome_ota_items from esphome.web_server_helpers import get_web_server_connection if any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - and ota_item.get(CONF_ENCRYPTION) is not None - for ota_item in config.get(CONF_OTA, []) + ota_item.get(CONF_ENCRYPTION) is not None + for ota_item in esphome_ota_items(config) ): _LOGGER.warning( "This config has OTA encryption, but the web_server OTA path sends " diff --git a/esphome/espota2.py b/esphome/espota2.py index d8a2ff9000d..14e163676d1 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable import contextlib +import enum import gzip import hashlib import io @@ -12,8 +13,10 @@ import socket import time from typing import Any +from esphome.const import CONF_ESPHOME, CONF_OTA, CONF_PLATFORM from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.types import ConfigType OTA_TYPE_UPDATE_APP = 0x00 OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 @@ -118,6 +121,7 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +_AUTH_ANSWERS = (*_AUTH_METHODS, RESPONSE_AUTH_OK) # Error response code -> human-readable message (without the "Error: " prefix; check_error() # prepends it uniformly). Looked up by check_error() to translate a single byte from the device @@ -224,10 +228,52 @@ class OTAKeyRejected(OTAError): """The Noise handshake failed on the key; another key may succeed.""" +class OTAEncryptionNotOffered(OTAError): + """A key is configured but the device cannot encrypt.""" + + +class ProbeResult(enum.Enum): + """What probe_ota_key found; only ACCEPTED means the device runs the key.""" + + ACCEPTED = "accepted" + REJECTED = "rejected" # the device runs another key + NOT_OFFERED = "not offered" # the device cannot encrypt + DEVICE_ERROR = "device error" # answered, but not about the key + UNREACHABLE = "unreachable" # no answer at all + + def __bool__(self) -> bool: + return self is ProbeResult.ACCEPTED + + +_PROBE_ANSWERS = ( + (OTAKeyRejected, ProbeResult.REJECTED), + (OTAEncryptionNotOffered, ProbeResult.NOT_OFFERED), +) + + +def esphome_ota_items(config: ConfigType) -> list[ConfigType]: + """The esphome ota entries; a raw ``ota:`` may be a mapping, not a list.""" + ota = config.get(CONF_OTA) or [] + items = [ota] if isinstance(ota, dict) else ota + return [ + item + for item in items + if isinstance(item, dict) and item.get(CONF_PLATFORM) == CONF_ESPHOME + ] + + # Uploader side options under `ota: encryption:`; the ota component imports # the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" CONF_OLD_KEY = "old_key" +ENCRYPTION_NOT_OFFERED_ADVICE = ( + "Refusing to send the image in plaintext. The running firmware predates " + "ESPHome 2026.9.0 or has no 'api: encryption: key'. With an api key, install " + "once without the 'ota: encryption:' block (that build offers encryption), " + f"then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' under " + "'ota: encryption:' for this one install, or flash by serial or the " + "web_server OTA platform." +) ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -429,6 +475,22 @@ def send_check( raise OTANetworkError(f"sending {msg}: {err}") from err +def noise_handshake(psk: str, prologue: bytes) -> Any: + """A fresh NNpsk0 initiator; a bad key or a missing noise stack raises here.""" + # Lazy: the noise stack is only imported for an encrypted upload + try: + from aioesphomeapi.noise import NoiseHandshake + except ImportError as err: + raise OTAError( + "OTA encryption requires a newer aioesphomeapi; update your " + "esphome installation (pip install -U esphome) and retry" + ) from err + try: + return NoiseHandshake(psk, prologue) + except ValueError as err: + raise OTAError(f"Invalid OTA encryption key: {err}") from err + + class NoiseSocketWrapper: """Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport. @@ -439,25 +501,12 @@ class NoiseSocketWrapper: """ def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None: - # Deliberately lazy: the noise stack (noiseprotocol, cryptography) is - # only imported when an encrypted upload actually runs. - try: - from aioesphomeapi.noise import NoiseHandshake - except ImportError as err: - raise OTAError( - "OTA encryption requires a newer aioesphomeapi; update your " - "esphome installation (pip install -U esphome) and retry" - ) from err - # The aioesphomeapi import above already loaded cryptography; bind - # the exception once so recv() pays no per-frame import lookup + self._handshake = noise_handshake(psk, prologue) + # Bound once; recv() must not import per frame from cryptography.exceptions import InvalidTag self._invalid_tag = InvalidTag self._sock = sock - try: - self._handshake = NoiseHandshake(psk, prologue) - except ValueError as err: - raise OTAError(f"Invalid OTA encryption key: {err}") from err self._encrypt = None self._decrypt = None self._buffer = b"" @@ -564,43 +613,20 @@ class NoiseSocketWrapper: return data -def perform_ota( +def _negotiate_session( sock: socket.socket, - password: str | None, - file_handle: io.IOBase, - filename: Path, - ota_type: int = OTA_TYPE_UPDATE_APP, - noise_psk: str | None = None, + noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> None: - # Validate up front; an out-of-range value would only surface as a - # ValueError deep inside send_check, bypassing OTAError handling - if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: - raise OTAError( - f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" - ) - if ota_type not in _SUPPORTED_OTA_TYPES: - supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) - raise OTAError( - f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" - ) - - if noise_psk is not None and not noise_psk: - raise OTAError( - "An empty OTA encryption key was provided; refusing to upload in plaintext" - ) - - file_contents = file_handle.read() - file_size = len(file_contents) - _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: + """Negotiate through the Noise handshake; returns the socket to go on with + (wrapped once encrypted), version, features, extended.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.info("Connection established; device supports OTA version %s", version) + _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -656,15 +682,9 @@ def perform_ota( # capture the image (wifi credentials, api key) # Remove before 2027.3.0: installing without the block no longer # falls back then; advise 'allow_plaintext_upload: true' instead - raise OTAError( + raise OTAEncryptionNotOffered( "An OTA encryption key is configured but the device did not " - "offer encryption; refusing to send the image in plaintext. " - "The running firmware predates ESPHome 2026.9.0 or has no " - "'api: encryption: key'. With an api key, install once " - "without the 'ota: encryption:' block (that build offers " - f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " - "true' under 'ota: encryption:' for this one install, or flash by " - "serial or the web_server OTA platform." + "offer encryption" ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -682,9 +702,50 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - _LOGGER.info("Encrypted connection established") + _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) + return sock, version, features, extended_proto + + +def perform_ota( + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, + noise_psk: str | None = None, + plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, + on_upload_start: Callable[[], None] | None = None, +) -> None: + # Validate up front; an out-of-range value would only surface as a + # ValueError deep inside send_check, bypassing OTAError handling + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + + if noise_psk is not None and not noise_psk: + raise OTAError( + "An empty OTA encryption key was provided; refusing to upload in plaintext" + ) + + file_contents = file_handle.read() + file_size = len(file_contents) + _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + + sock, version, features, extended_proto = _negotiate_session( + sock, noise_psk, plaintext_fallback, allow_plaintext_upload + ) + _LOGGER.info("Connection established; device supports OTA version %s", version) + if isinstance(sock, NoiseSocketWrapper): + _LOGGER.info("Encrypted connection established") if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -761,18 +822,15 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - (auth,) = receive_exactly( - sock, - 1, - "auth", - [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], - ) + (auth,) = receive_exactly(sock, 1, "auth", list(_AUTH_ANSWERS)) if auth != RESPONSE_AUTH_OK: hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) _LOGGER.info("Handshake complete") + if on_upload_start is not None: + on_upload_start() # the device may commit anything sent from here sock.settimeout(DATA_PHASE_TIMEOUT) @@ -890,6 +948,146 @@ def perform_ota( time.sleep(1) +PROBE_TIMEOUT = 120.0 +PROBE_RETRY_DELAY = 3.0 +PROBE_CONNECT_TIMEOUT = 3.0 +PROBE_HANDSHAKE_TIMEOUT = 10.0 + + +def _resolve_targets(remote_host: str | list[str], remote_port: int) -> list[Any]: + """Every address to try for the host, through the cache; OTAError when + there is none.""" + from esphome.core import CORE + + try: + res = resolve_ip_address( + remote_host, remote_port, address_cache=CORE.address_cache + ) + except EsphomeError as err: + raise OTAError(err) from err + if not res: + raise OTAError(f"No addresses to connect to for {remote_host}") + return res + + +class _DeadlineSocket: + """Re-arms the timeout before every operation so the whole negotiation, + not each read, ends by the deadline.""" + + def __init__(self, sock: socket.socket, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _arm(self) -> None: + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("probe deadline reached") + self._sock.settimeout(min(PROBE_HANDSHAKE_TIMEOUT, remaining)) + + def recv(self, amount: int) -> bytes: + self._arm() + return self._sock.recv(amount) + + def sendall(self, data: bytes) -> None: + self._arm() + self._sock.sendall(data) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def close(self) -> None: + self._sock.close() + + +def probe_ota_key( + remote_host: str | list[str], + remote_port: int, + noise_psk: str, + *, + timeout: float = PROBE_TIMEOUT, + retry_rejected: bool = True, +) -> ProbeResult: + """Whether the device completes a Noise handshake with the key, no image + sent. Retries until the deadline, a rejection too with ``retry_rejected`` + (the old firmware may answer once more after an upload).""" + + noise_handshake(noise_psk, b"") # a local fault must not read as the device's no + deadline = time.monotonic() + timeout + + def pause(delay: float) -> None: + time.sleep(max(0.0, min(delay, deadline - time.monotonic()))) + + res: list[Any] = [] + answered: set[Any] = set() # addresses whose answer no retry changes + last_error = "no time left" + outcome = ProbeResult.UNREACHABLE # a device answer outranks a network error + while time.monotonic() < deadline: + if not res: + # A rebooting device may drop off mDNS for a while; keep asking. + # Addresses the caller pre-resolved (the address cache, or a + # literal IP) are its to keep; only a hostname is looked up again + try: + res = _resolve_targets(remote_host, remote_port) + except OTAError as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = str(err) + _LOGGER.debug("Host not resolved yet (%s); retrying", err) + pause(PROBE_RETRY_DELAY) + continue + # One round: every address that has not answered, back to back + started = time.monotonic() + unreached = 0 + for af, socktype, _, _, sa in res: + if sa in answered: + continue + if (remaining := deadline - time.monotonic()) <= 0: + break + sock = socket.socket(af, socktype) + # A dead host must not eat the budget + sock.settimeout(min(PROBE_CONNECT_TIMEOUT, remaining)) + with contextlib.closing(sock): + try: + sock.connect(sa) + session = _negotiate_session( + _DeadlineSocket(sock, deadline), noise_psk + )[0] + # A password challenge also proves the key: it comes encrypted + receive_exactly(session, 1, "auth", list(_AUTH_ANSWERS)) + except (OSError, OTANetworkError) as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = f"{sa[0]}: {err}" + unreached += 1 + except OTAError as err: + # Final in the precheck; a rebooting device may answer + # differently once it is back + found = next( + (r for t, r in _PROBE_ANSWERS if isinstance(err, t)), + ProbeResult.DEVICE_ERROR, + ) + # A stale address answering with some other error must + # not hide what the device said about the key + if found is not ProbeResult.DEVICE_ERROR or not any( + outcome is r for _, r in _PROBE_ANSWERS + ): + outcome, last_error = found, f"{sa[0]}: {err}" + if not retry_rejected: + answered.add(sa) + else: + _LOGGER.info("Device %s accepted the key", sa[0]) + return ProbeResult.ACCEPTED + _LOGGER.debug("Key not accepted yet (%s); retrying", last_error) + # Every address gets its own final say; a stale one cannot answer + # for the device + pending = sum(1 for *_, sa in res if sa not in answered) + if pending == 0 or time.monotonic() >= deadline: + break + if unreached == pending: + res = [] # no answer this round: the device may have a new address + pause(PROBE_RETRY_DELAY - (time.monotonic() - started)) + _LOGGER.warning("Checking the key failed, %s: %s", outcome.value, last_error) + return outcome + + def run_ota_impl_( remote_host: str | list[str], remote_port: int, @@ -900,16 +1098,13 @@ def run_ota_impl_( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE - # Handle both single host and list of hosts try: - # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address( - remote_host, remote_port, address_cache=CORE.address_cache - ) - except EsphomeError as err: + res = _resolve_targets(remote_host, remote_port) + except OTAError: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", remote_host, @@ -920,11 +1115,7 @@ def run_ota_impl_( "(If this error persists, please set a static IP address: " "https://esphome.io/components/wifi/#manual-ips)" ) - raise OTAError(err) from err - - if not res: - _LOGGER.error("No addresses to connect to for %s", remote_host) - return 1, None + raise # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries # are shared across the addresses, cycling through them. Wait before an @@ -941,6 +1132,13 @@ def run_ota_impl_( reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) + + def upload_started() -> None: + nonlocal on_upload_start + if on_upload_start is not None: + on_upload_start() + on_upload_start = None # once per upload, whatever the retries do + while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -978,6 +1176,7 @@ def run_ota_impl_( encryption.noise_psk, encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + on_upload_start=upload_started, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1013,6 +1212,8 @@ def run_ota_impl_( # Device-reported error (wrong password, wrong flash size, ...); # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) + if isinstance(err, OTAEncryptionNotOffered): + _LOGGER.error(ENCRYPTION_NOT_OFFERED_ADVICE) return 1, None if encryption.tried_old_key: @@ -1038,7 +1239,11 @@ def run_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: + """``on_upload_start`` runs once, when the device first accepted the + session and the image is about to go out: the point from which it may + commit, which a later retry or failure does not undo.""" try: return run_ota_impl_( remote_host, @@ -1050,6 +1255,7 @@ def run_ota( plaintext_fallback, allow_plaintext_upload, old_noise_psk, + on_upload_start, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f1e04da8c87..e83335a175f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -964,10 +964,10 @@ def test_run_ota_impl_device_error_not_retried( def test_run_ota_impl_no_addresses( firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + """An empty resolution is reported once and fails cleanly.""" mock_resolve_ip.return_value = [] - result_code, result_host = espota2.run_ota_impl_( + result_code, result_host = espota2.run_ota( "test.local", 3232, "password", str(firmware_file) ) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index ca50641309b..1adace67395 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -13,6 +13,7 @@ import base64 from collections.abc import Callable import hashlib import io +import itertools import logging from pathlib import Path import socket @@ -24,11 +25,16 @@ from unittest.mock import Mock, patch import pytest from esphome import espota2 +from esphome.core import EsphomeError +from esphome.espota2 import ProbeResult PSK = base64.b64encode(bytes(range(32))).decode() OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode() MAGIC = bytes(espota2.MAGIC_BYTES) +# One resolved loopback address, as resolve_ip_address returns it +RESOLVED = [(2, 1, 0, "", ("127.0.0.1", 1))] +TWO_ADDRESSES = [(2, 1, 0, "", ("10.0.0.1", 1)), (2, 1, 0, "", ("10.0.0.2", 1))] def _recv_exact(sock: socket.socket, amount: int) -> bytes: @@ -82,6 +88,7 @@ class FakeEncryptedDevice(threading.Thread): self.require_noise = require_noise self.prologue_features_override = prologue_features_override self.received: bytes | None = None + self.probes = 0 # clients that left right after the handshake self.error: Exception | None = None self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) @@ -195,7 +202,12 @@ class FakeEncryptedDevice(threading.Thread): ) -> None: """The post-handshake exchange, identical over both transports.""" send_byte(espota2.RESPONSE_AUTH_OK) - recv_unit(1) # ota type + try: + recv_unit(1) # ota type + except ConnectionError: + # A key probe leaves here, like the firmware it is patterned on + self.probes += 1 + return size = int.from_bytes(recv_unit(4), "big") send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK) md5_hex = recv_unit(32) @@ -250,6 +262,7 @@ def _run_ota( noise_psk: str, plaintext_fallback: bool = True, old_noise_psk: str | None = None, + **kwargs: Any, ) -> int: """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" @@ -263,10 +276,110 @@ def _run_ota( noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, old_noise_psk=old_noise_psk, + **kwargs, ) return rc +def test_on_upload_start_runs_once_the_device_accepted_the_session( + tmp_path: Path, +) -> None: + """The rejected first key never reaches the hook; the old_key reconnect does.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, connections=2) + on_upload_start = Mock() + with patch("time.sleep"): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=OTHER_PSK, + on_upload_start=on_upload_start, + ) + device.join_and_check() + assert rc == 0 + on_upload_start.assert_called_once() + + +def test_on_upload_start_fires_once_across_retries() -> None: + """A data phase fault after the hook retries the upload; the hook has + already reported that the device may commit.""" + calls: list[int] = [] + + def perform(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + if len(calls) == 0: + calls.append(1) + raise espota2.OTANetworkError("reset") + + hook = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=perform), + patch("time.sleep"), + ): + rc, _ = espota2.run_ota_impl_( + "h", 1, None, Path("fw.bin"), on_upload_start=hook + ) + assert rc == 0 + hook.assert_called_once() + + +def test_probe_result_is_true_only_when_accepted() -> None: + assert ProbeResult.ACCEPTED + assert not any(r for r in ProbeResult if r is not ProbeResult.ACCEPTED) + + +def test_probe_ota_key_keeps_the_key_answer_over_a_stale_device_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address running some other listener answers with its own + error; what the device said about the key still wins.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=TWO_ADDRESSES), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + espota2.OTAError("unsupported OTA version 9"), + ], + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_on_upload_start_failure_closes_the_socket() -> None: + def start_upload(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket") as sock, + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=start_upload), + pytest.raises(RuntimeError, match="boom"), + ): + espota2.run_ota_impl_( + "h", + 1, + None, + Path("fw.bin"), + on_upload_start=Mock(side_effect=RuntimeError("boom")), + ) + sock.return_value.close.assert_called_once() + + THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() @@ -363,9 +476,7 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises( - espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" - ): + with pytest.raises(espota2.OTAEncryptionNotOffered, match="did not offer"): _upload(device, b"firmware", PSK) device.join_and_check() @@ -666,6 +777,120 @@ def test_non_key_reject_reason_is_a_device_error( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +def test_probe_ota_key_accepts_the_running_key( + caplog: pytest.LogCaptureFixture, +) -> None: + """The negotiation's upload-style progress lines stay below INFO.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + with caplog.at_level(logging.INFO): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + assert device.received is None + assert "accepted the key" in caplog.text + assert "Connection established" not in caplog.text + + +@pytest.mark.parametrize( + "device_kwargs", + [{"psk": OTHER_PSK}, {"offer_noise": False, "require_noise": False}], + ids=["wrong_key", "no_offer"], +) +def test_probe_ota_key_gives_up_at_the_deadline( + device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, **device_kwargs) + device.start() + with patch("time.sleep"), caplog.at_level(logging.WARNING): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5) + is not ProbeResult.ACCEPTED + ) + device.join_and_check() + assert any("Checking the key failed" in r.message for r in caplog.records) + assert device.received is None + + +def test_probe_ota_key_fails_fast_on_a_definitive_answer() -> None: + """A device in steady state that rejects the key gives its final answer + on the first attempt; the precheck does not wait out the deadline.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK) + device.start() + with patch("time.sleep") as sleep: + assert ( + espota2.probe_ota_key( + "127.0.0.1", device.port, PSK, timeout=30, retry_rejected=False + ) + is ProbeResult.REJECTED + ) + device.join_and_check() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_after_a_transport_fault() -> None: + """The device may still be rebooting right after the upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, drop_handshakes=1) + device.start() + with patch("time.sleep"): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + + +@pytest.mark.parametrize( + "kwargs", + [{"side_effect": EsphomeError("no such host")}, {"return_value": []}], + ids=["error", "empty"], +) +def test_probe_ota_key_resolution_failure( + kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A name that never resolves is retried until the deadline, then reported.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + is not ProbeResult.ACCEPTED + ) + assert resolve.call_count > 1 + assert any("failed, unreachable" in r.message for r in caplog.records) + + +def test_probe_ota_key_resolves_again_after_a_reboot() -> None: + """The lookup fails while the device reboots, then the probe goes on.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + real = espota2.resolve_ip_address + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[EsphomeError("not yet"), real("127.0.0.1", device.port)], + ), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: @@ -677,5 +902,399 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( device.join_and_check() assert rc == 1 assert device.received != b"firmware" - assert any("refusing to send the image" in r.message for r in caplog.records) + assert any("Refusing to send the image" in r.message for r in caplog.records) assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + +def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: + """A slow connect leaves less for the handshake than the budget had.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep"), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 0.4) + sock.recv.return_value = b"" + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + connect_timeout, handshake_timeout = ( + c.args[0] for c in sock.settimeout.call_args_list[:2] + ) + assert connect_timeout == 0.5 + assert handshake_timeout == pytest.approx(0.1) + + +def test_probe_ota_key_tries_every_address_before_a_final_no() -> None: + """With retry_rejected off a rejection is final only once every resolved + address has answered; a stale cached IP must not veto the real device.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 2 + sleep.assert_not_called() + + +@pytest.mark.parametrize( + "answer", [espota2.RESPONSE_REQUEST_AUTH, espota2.RESPONSE_REQUEST_SHA256_AUTH] +) +def test_probe_ota_key_takes_a_password_challenge_as_proof(answer: int) -> None: + """A device with a password as well as encryption answers the handshake + with a challenge; it arrives encrypted, so the key was accepted.""" + session = Mock() + session.recv.return_value = bytes([answer]) + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch("esphome.espota2._negotiate_session", return_value=(session, 2, 0, True)), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + + +def test_probe_ota_key_waits_for_every_address_to_answer() -> None: + """One address rejecting does not stand in for another that only failed + to connect so far; the one that answered is not asked again.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + OSError("refused"), + (session, 2, 0, True), + ], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 3 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_pauses_between_rounds_once_an_address_answered() -> None: + """A stale address before a device that said no keeps the round pacing: + the device is not asked again and the stale address is not hammered.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ) as resolve, + patch("socket.socket") as sock_cls, + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [OSError("refused"), espota2.OTAKeyRejected("no")], + itertools.repeat(OSError("refused")), + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep) as sleeper, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + # Ten rounds of one stale attempt each, a pause between every two + assert negotiate.call_count == 11 + assert sleeper.call_count == 10 + assert [c.args[0] for c in sock_cls.return_value.connect.call_args_list].count( + ("10.0.0.2", 1) + ) == 1 + # Every later round had no answer, so the host was resolved again each time + assert resolve.call_count == 10 + + +def test_probe_ota_key_keeps_trying_an_address_a_new_lookup_added() -> None: + """An address that answered earlier and is gone from the lookup does not + count against the ones still pending.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], + itertools.repeat([(2, 1, 0, "", ("10.0.0.3", 1))]), + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("no")], itertools.repeat(OSError("down")) + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + assert negotiate.call_count == 11 # the new address was tried every round + + +def test_probe_ota_key_keeps_the_device_answer_when_a_later_lookup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], itertools.repeat(EsphomeError("dns down")) + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), OSError("down")], + ), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_probe_ota_key_names_a_missing_encryption_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The upload's advice about the image does not apply to a probe.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAEncryptionNotOffered("did not offer encryption"), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.NOT_OFFERED + ) + assert "failed, not offered: 127.0.0.1: did not offer encryption" in caplog.text + assert "image" not in caplog.text + + +def test_probe_ota_key_resolves_again_after_a_round_without_an_answer() -> None: + """A round with no answer looks the host up again, and the device is + found under its new address.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[ + [(2, 1, 0, "", ("10.0.0.1", 1))], + [(2, 1, 0, "", ("10.0.0.2", 1))], + ], + ) as resolve, + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[OSError("unreachable"), (session, 2, 0, True)], + ), + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert resolve.call_count == 2 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_reports_the_device_answer_over_a_network_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address failing last must not hide that the device said no.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("wrong key")], + itertools.repeat(OSError("refused")), + ), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.05, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: wrong key" in caplog.text + + +def test_probe_ota_key_reports_a_local_fault_as_its_own() -> None: + """A malformed key never reads as the device refusing it.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address") as resolve, + pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"), + ): + espota2.probe_ota_key("h", 1, "not-base64!!!", timeout=30) + resolve.assert_not_called() + + +def test_probe_ota_key_takes_another_device_answer_as_final() -> None: + """An unsupported protocol version does not change with a retry.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAError("unsupported OTA version 9"), + ) as negotiate, + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.DEVICE_ERROR + ) + negotiate.assert_called_once() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_a_device_error_while_it_reboots() -> None: + """After an upload the device may answer oddly until it is back.""" + session = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAError("busy"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert negotiate.call_count == 2 + + +def test_probe_ota_key_deadline_spans_the_whole_negotiation() -> None: + """Each read re-arms the timeout from what is left, so a slow peer + cannot stretch the probe one full timeout per read.""" + clock = [0.0] + sock = Mock() + + def slow_recv(_amount: int) -> bytes: + clock[0] += 0.3 + return b"\x00\x02" + + sock.recv.side_effect = slow_recv + with patch("time.monotonic", side_effect=lambda: clock[0]): + wrapped = espota2._DeadlineSocket(sock, 0.5) + wrapped.sendall(b"x") + assert wrapped.recv(2) == b"\x00\x02" + assert wrapped.recv(2) == b"\x00\x02" + with pytest.raises(TimeoutError): + wrapped.recv(1) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert timeouts == [0.5, 0.5, pytest.approx(0.2)] + + +def test_probe_ota_key_stops_when_connect_used_the_budget() -> None: + """No handshake timeout is granted past the deadline.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 1.0) + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + assert [c.args[0] for c in sock.settimeout.call_args_list] == [0.5] + sock.sendall.assert_not_called() + sleep.assert_not_called() + + +def test_probe_ota_key_never_waits_past_its_deadline() -> None: + """Connect and handshake timeouts and the retry sleep are all capped by + what is left of the budget.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + ): + sock = sock_cls.return_value + sock.connect.side_effect = OSError("refused") + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert all(t <= 0.5 for t in timeouts) + assert all(c.args[0] <= 0.5 for c in sleep.call_args_list) From 4a80d785a8d95539ba9ccf6ec8ae41ff8fd56d19 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Tue, 13 Oct 2026 19:52:46 +0200 Subject: [PATCH 067/111] =?UTF-8?q?[filter=5Flifetime]=20Add=20component?= =?UTF-8?q?=20with=20sensor/lambda=20support=20for=20is=5Fon=20=E2=80=A6?= =?UTF-8?q?=20(#13914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/filter_lifetime/__init__.py | 2 + .../filter_lifetime/filter_lifetime.cpp | 112 +++++++++ .../filter_lifetime/filter_lifetime.h | 50 ++++ esphome/components/filter_lifetime/sensor.py | 94 ++++++++ tests/components/filter_lifetime/__init__.py | 12 + tests/components/filter_lifetime/common.yaml | 62 +++++ .../filter_lifetime/lambda_only.yaml | 8 + .../test-lambda-only.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp8266-ard.yaml | 2 + .../filter_lifetime/test.rp2040-ard.yaml | 2 + .../filter_lifetime/test_filter_lifetime.cpp | 224 ++++++++++++++++++ .../components/filter_lifetime/__init__.py | 0 .../components/filter_lifetime/test_sensor.py | 99 ++++++++ 15 files changed, 672 insertions(+) create mode 100644 esphome/components/filter_lifetime/__init__.py create mode 100644 esphome/components/filter_lifetime/filter_lifetime.cpp create mode 100644 esphome/components/filter_lifetime/filter_lifetime.h create mode 100644 esphome/components/filter_lifetime/sensor.py create mode 100644 tests/components/filter_lifetime/__init__.py create mode 100644 tests/components/filter_lifetime/common.yaml create mode 100644 tests/components/filter_lifetime/lambda_only.yaml create mode 100644 tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp8266-ard.yaml create mode 100644 tests/components/filter_lifetime/test.rp2040-ard.yaml create mode 100644 tests/components/filter_lifetime/test_filter_lifetime.cpp create mode 100644 tests/unit_tests/components/filter_lifetime/__init__.py create mode 100644 tests/unit_tests/components/filter_lifetime/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fc14cb33d09..2c026b92f5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core +esphome/components/filter_lifetime/* @tuct esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/friedrich/* @rwrozelle diff --git a/esphome/components/filter_lifetime/__init__.py b/esphome/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..99a67538bf2 --- /dev/null +++ b/esphome/components/filter_lifetime/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tuct"] +DOMAIN = "filter_lifetime" diff --git a/esphome/components/filter_lifetime/filter_lifetime.cpp b/esphome/components/filter_lifetime/filter_lifetime.cpp new file mode 100644 index 00000000000..47059a7f7a2 --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.cpp @@ -0,0 +1,112 @@ +#include "filter_lifetime.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::filter_lifetime { + +ESPHOME_LOG_TAG(TAG, "filter_lifetime"); + +static constexpr uint32_t SECONDS_PER_HOUR = 3600; +static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f; + +void FilterLifetime::setup() { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->runtime_seconds_); + this->last_update_ = millis(); + this->publish_values_(); +} + +void FilterLifetime::update() { + const uint32_t now = App.get_loop_component_start_time(); + const uint32_t elapsed_ms = now - this->last_update_; + this->last_update_ = now; + + if (this->get_is_on_()) { + float speed = this->get_current_speed_(); + if (std::isnan(speed)) + speed = 0.0f; + speed = std::clamp(speed, 0.0f, 100.0f); + const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR; + this->carry_ms_ += static_cast(elapsed_ms * (speed / 100.0f)); + this->runtime_seconds_ += this->carry_ms_ / 1000; + this->carry_ms_ %= 1000; + // Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266. + if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours) + this->pref_.save(&this->runtime_seconds_); + ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_); + } + this->publish_values_(); +} + +void FilterLifetime::publish_values_() { + const float runtime_minutes = this->runtime_seconds_ / 60.0f; + const float max_minutes = this->max_lifetime_minutes_; + const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes); + this->publish_state(100.0f * remaining_minutes / max_minutes); + if (this->runtime_hours_sensor_ != nullptr) + this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f); + if (this->remaining_days_sensor_ != nullptr) + this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY); +} + +void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); } + +void FilterLifetime::reset_filter() { + this->runtime_seconds_ = 0; + this->carry_ms_ = 0; + this->pref_.save(&this->runtime_seconds_); + this->publish_values_(); + ESP_LOGI(TAG, "Filter lifetime reset"); +} + +bool FilterLifetime::get_is_on_() { +#ifdef USE_BINARY_SENSOR + // Reads false until the sensor first publishes, so nothing accumulates before then. + if (this->is_on_sensor_ != nullptr) + return this->is_on_sensor_->state; +#endif + if (this->is_on_lambda_ != nullptr) + return this->is_on_lambda_(); + return true; +} + +float FilterLifetime::get_current_speed_() { + if (this->current_speed_sensor_ != nullptr) + return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0 + if (this->current_speed_lambda_ != nullptr) + return this->current_speed_lambda_(); + return 100.0f; +} + +void FilterLifetime::dump_config() { + LOG_SENSOR("", "Filter Lifetime", this); + ESP_LOGCONFIG(TAG, + " Max lifetime: %" PRIu32 " min (%.1f days)\n" + " Runtime: %.1f h", + this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY, + this->runtime_seconds_ / 3600.0f); +#ifdef USE_BINARY_SENSOR + if (this->is_on_sensor_ != nullptr) { + LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " Is-on: %s", + this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always")); + } + if (this->current_speed_sensor_ != nullptr) { + LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_); + } else { + ESP_LOGCONFIG(TAG, " Speed: %s", + this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%")); + } + LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_); + LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/filter_lifetime.h b/esphome/components/filter_lifetime/filter_lifetime.h new file mode 100644 index 00000000000..e6f40d78f5b --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::filter_lifetime { + +class FilterLifetime : public sensor::Sensor, public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + void on_shutdown() override; + void reset_filter(); + + void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; } + void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; } +#ifdef USE_BINARY_SENSOR + void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; } +#endif + void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; } + void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; } + void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; } + void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; } + + protected: + bool get_is_on_(); + float get_current_speed_(); + void publish_values_(); + + bool (*is_on_lambda_)(){nullptr}; + float (*current_speed_lambda_)(){nullptr}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *is_on_sensor_{nullptr}; +#endif + sensor::Sensor *current_speed_sensor_{nullptr}; + sensor::Sensor *runtime_hours_sensor_{nullptr}; + sensor::Sensor *remaining_days_sensor_{nullptr}; + ESPPreferenceObject pref_; + uint32_t max_lifetime_minutes_{0}; + uint32_t last_update_{0}; + uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted + uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second +}; + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py new file mode 100644 index 00000000000..58af033f329 --- /dev/null +++ b/esphome/components/filter_lifetime/sensor.py @@ -0,0 +1,94 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DURATION, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_MAX_LIFETIME = "max_lifetime" +CONF_IS_ON = "is_on" +CONF_IS_ON_SENSOR = "is_on_sensor" +CONF_CURRENT_SPEED = "current_speed" +CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor" +CONF_RUNTIME_HOURS = "runtime_hours" +CONF_REMAINING_DAYS = "remaining_days" +UNIT_DAY = "d" + +filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime") +FilterLifetime = filter_lifetime_ns.class_( + "FilterLifetime", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + FilterLifetime, + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.Required(CONF_MAX_LIFETIME): cv.All( + cv.positive_time_period_minutes, + cv.Range(min=cv.TimePeriod(minutes=1)), + ), + cv.Optional(CONF_IS_ON): cv.returning_lambda, + cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor), + cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda, + cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + accuracy_decimals=1, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:timer-outline", + ), + cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema( + unit_of_measurement=UNIT_DAY, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + icon="mdi:calendar-clock", + ), + } + ) + .extend(cv.polling_component_schema("60s")), + cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR), + cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME])) + + if (is_on := config.get(CONF_IS_ON)) is not None: + cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) + elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: + cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + + if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: + cg.add( + var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) + ) + elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: + cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) + await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor) + + +automation.register_apply_action( + "filter_lifetime.reset_filter", + cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}), + automation.ApplyCall("reset_filter()"), +) diff --git a/tests/components/filter_lifetime/__init__.py b/tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..70b7f08adf3 --- /dev/null +++ b/tests/components/filter_lifetime/__init__.py @@ -0,0 +1,12 @@ +from esphome.core import CORE +from esphome.types import ConfigType +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"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"] diff --git a/tests/components/filter_lifetime/common.yaml b/tests/components/filter_lifetime/common.yaml new file mode 100644 index 00000000000..1cf8a3e0b18 --- /dev/null +++ b/tests/components/filter_lifetime/common.yaml @@ -0,0 +1,62 @@ +binary_sensor: + - platform: template + name: "Fan Running" + id: fan_running + +sensor: + - platform: template + name: "Fan Speed Percent" + id: fan_speed_percent + unit_of_measurement: "%" + + # Test with lambdas + - platform: filter_lifetime + name: "Filter Lifetime Lambda" + id: filter_lifetime_lambda + max_lifetime: 365d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 50.0;' + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Lambda" + remaining_days: + name: "Filter Remaining Days Lambda" + + # Test with sensor references + - platform: filter_lifetime + name: "Filter Lifetime Sensor" + id: filter_lifetime_sensor + max_lifetime: 365d + is_on_sensor: fan_running + current_speed_sensor: fan_speed_percent + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Sensor" + remaining_days: + name: "Filter Remaining Days Sensor" + + # Test with defaults (always on at 100%) + - platform: filter_lifetime + name: "Filter Lifetime Default" + id: filter_lifetime_default + max_lifetime: 180d + update_interval: 60s + +button: + - platform: template + name: "Reset Filter Lambda" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_lambda + + - platform: template + name: "Reset Filter Sensor" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_sensor + + - platform: template + name: "Reset Filter Default" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_default diff --git a/tests/components/filter_lifetime/lambda_only.yaml b/tests/components/filter_lifetime/lambda_only.yaml new file mode 100644 index 00000000000..9affd2d469f --- /dev/null +++ b/tests/components/filter_lifetime/lambda_only.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: filter_lifetime + name: "Filter Lifetime Lambda Only" + max_lifetime: 180d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 75.0f;' + remaining_days: + name: "Filter Remaining Days Lambda Only" diff --git a/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml new file mode 100644 index 00000000000..aa0d3c679ff --- /dev/null +++ b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include lambda_only.yaml diff --git a/tests/components/filter_lifetime/test.esp32-idf.yaml b/tests/components/filter_lifetime/test.esp32-idf.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.esp8266-ard.yaml b/tests/components/filter_lifetime/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.rp2040-ard.yaml b/tests/components/filter_lifetime/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp new file mode 100644 index 00000000000..a4c89edd856 --- /dev/null +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -0,0 +1,224 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/filter_lifetime/filter_lifetime.h" +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::filter_lifetime::testing { + +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + +class TestableFilterLifetime : public FilterLifetime { + public: + using FilterLifetime::last_update_; + using FilterLifetime::runtime_seconds_; +}; + +static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static constexpr uint32_t MAX_MINUTES = 600; // 10 hours +static constexpr uint32_t FILTER_HASH = 0x104001; + +class FilterLifetimeTest : public ::testing::Test { + protected: + TestableFilterLifetime filter_; + sensor::Sensor runtime_hours_; + sensor::Sensor remaining_days_; + float last_state_{NAN}; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); + host::setup_preferences(); + host::host_preferences->reset(); + g_is_on = true; + g_speed = 100.0f; + this->attach(this->filter_); + this->filter_.add_on_state_callback([this](float value) { + this->last_state_ = value; + ++this->publications_; + }); + } + + void TearDown() override { + host::host_preferences->reset(); + App.~Application(); + } + + void attach(FilterLifetime &filter) { + EntityIdentity::configure(filter, "Filter", FILTER_HASH); + filter.set_max_lifetime(MAX_MINUTES); + filter.set_is_on_lambda([]() { return g_is_on; }); + filter.set_current_speed_lambda([]() { return g_speed; }); + filter.set_runtime_hours_sensor(&this->runtime_hours_); + filter.set_remaining_days_sensor(&this->remaining_days_); + } + + // Run one update() as if elapsed_ms passed since the previous one + void elapse(uint32_t elapsed_ms) { + this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms; + this->filter_.update(); + } + + bool stored(uint32_t &value) { return this->filter_.make_entity_preference().load(&value); } + + static float expected_percent(uint32_t runtime_seconds) { + return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES; + } +}; + +TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) { + this->filter_.setup(); + EXPECT_EQ(this->publications_, 1u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) { + this->filter_.setup(); + g_speed = 50.0f; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 30u); + EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30)); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) { + this->filter_.setup(); + g_speed = 25.0f; + for (int i = 0; i < 3; i++) + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) { + this->filter_.setup(); + g_is_on = false; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_EQ(this->publications_, 2u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); +} + +TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) { + this->filter_.setup(); + g_speed = 250.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = NAN; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = -5.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) { + sensor::Sensor speed; + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.set_current_speed_sensor(&speed); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + speed.state = 50.0f; + this->elapse(2000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) { + binary_sensor::BinarySensor running; + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_is_on_sensor(&running); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + running.publish_state(true); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + running.publish_state(false); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) { + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) { + this->filter_.setup(); + uint32_t value = 0; + this->elapse(3599000); + EXPECT_FALSE(this->stored(value)); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); +} + +TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) { + this->filter_.setup(); + this->elapse(1000); + this->filter_.on_shutdown(); + uint32_t value = 0; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 1u); +} + +TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) { + this->filter_.setup(); + this->elapse(1234000); + this->filter_.on_shutdown(); + + TestableFilterLifetime restored; + this->attach(restored); + restored.setup(); + EXPECT_EQ(restored.runtime_seconds_, 1234u); + EXPECT_FLOAT_EQ(restored.state, expected_percent(1234)); +} + +TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) { + this->filter_.setup(); + this->elapse(5000); + this->filter_.reset_filter(); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + uint32_t value = 1; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 0u); +} + +TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) { + this->filter_.set_max_lifetime(1); + this->filter_.setup(); + this->elapse(120000); + EXPECT_FLOAT_EQ(this->last_state_, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f); +} + +} // namespace esphome::filter_lifetime::testing + +#endif // USE_HOST diff --git a/tests/unit_tests/components/filter_lifetime/__init__.py b/tests/unit_tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/filter_lifetime/test_sensor.py b/tests/unit_tests/components/filter_lifetime/test_sensor.py new file mode 100644 index 00000000000..7e8d85fcda6 --- /dev/null +++ b/tests/unit_tests/components/filter_lifetime/test_sensor.py @@ -0,0 +1,99 @@ +"""Tests for the filter_lifetime configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.filter_lifetime import sensor as filter_lifetime +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_DURATION, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.core import Lambda +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config}) + + +def test_max_lifetime_is_stored_in_minutes() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config["max_lifetime"].total_minutes == 180 * 24 * 60 + + +def test_max_lifetime_is_required() -> None: + with pytest.raises(cv.Invalid, match="required key not provided"): + _validate({}) + + +@pytest.mark.parametrize("value", ["0min", "30s", 6]) +def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None: + with pytest.raises(cv.Invalid): + _validate({"max_lifetime": value}) + + +def test_defaults() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config[CONF_UPDATE_INTERVAL].total_seconds == 60 + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT + assert config[CONF_ACCURACY_DECIMALS] == 2 + assert "is_on" not in config + assert "current_speed" not in config + + +@pytest.mark.parametrize( + ("lambda_key", "sensor_key"), + [("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")], +) +def test_lambda_and_sensor_are_mutually_exclusive( + lambda_key: str, sensor_key: str +) -> None: + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + _validate( + { + "max_lifetime": "180d", + lambda_key: Lambda("return 1;"), + sensor_key: "some_id", + } + ) + + +def test_lambda_inputs_accepted() -> None: + config = _validate( + { + "max_lifetime": "180d", + "is_on": Lambda("return true;"), + "current_speed": Lambda("return 50.0f;"), + } + ) + assert config["is_on"].value == "return true;" + assert config["current_speed"].value == "return 50.0f;" + + +def test_sub_sensor_defaults() -> None: + config = _validate( + { + "max_lifetime": "180d", + "runtime_hours": {"name": "Runtime"}, + "remaining_days": {"name": "Remaining"}, + } + ) + runtime = config["runtime_hours"] + assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR + assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + remaining = config["remaining_days"] + assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY + assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert remaining[CONF_ACCURACY_DECIMALS] == 0 From 95766ed3ad043f45eabf6e92b7847769427f4b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Oct 2026 20:07:32 +0200 Subject: [PATCH 068/111] [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icm20689/__init__.py | 13 ++ esphome/components/icm20689/icm20689.cpp | 126 ++++++++++++++++++ esphome/components/icm20689/icm20689.h | 66 +++++++++ esphome/components/icm20689/motion.py | 56 ++++++++ esphome/components/icm20689/sensor.py | 42 ++++++ tests/components/icm20689/common.yaml | 73 ++++++++++ tests/components/icm20689/test.esp32-idf.yaml | 3 + .../components/icm20689/test.esp8266-ard.yaml | 3 + .../components/icm20689/test.rp2040-ard.yaml | 3 + 10 files changed, 386 insertions(+) create mode 100644 esphome/components/icm20689/__init__.py create mode 100644 esphome/components/icm20689/icm20689.cpp create mode 100644 esphome/components/icm20689/icm20689.h create mode 100644 esphome/components/icm20689/motion.py create mode 100644 esphome/components/icm20689/sensor.py create mode 100644 tests/components/icm20689/common.yaml create mode 100644 tests/components/icm20689/test.esp32-idf.yaml create mode 100644 tests/components/icm20689/test.esp8266-ard.yaml create mode 100644 tests/components/icm20689/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c026b92f5b..fc53a28c0b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 +esphome/components/icm20689/* @lboue esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core diff --git a/esphome/components/icm20689/__init__.py b/esphome/components/icm20689/__init__.py new file mode 100644 index 00000000000..b57221814e8 --- /dev/null +++ b/esphome/components/icm20689/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@lboue"] +DOMAIN = "icm20689" + +CONF_ICM20689_ID = "icm20689_id" +# C++ namespace / class +icm20689_ns = cg.esphome_ns.namespace("icm20689") +ICM20689Component = icm20689_ns.class_( + "ICM20689Component", MotionComponent, spi.SPIDevice +) diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp new file mode 100644 index 00000000000..7331a3fe4f6 --- /dev/null +++ b/esphome/components/icm20689/icm20689.cpp @@ -0,0 +1,126 @@ +#include "icm20689.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::icm20689 { + +ESPHOME_LOG_TAG(TAG, "icm20689"); + +namespace { + +// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range. +constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; } + +constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; } + +} // namespace + +void ICM20689Component::write_register_(uint8_t reg, uint8_t value) { + this->enable(); + this->write_byte(reg & 0x7F); + this->write_byte(value); + this->disable(); +} + +void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) { + this->enable(); + this->write_byte(reg | ICM20689_READ_BIT); + this->read_array(data, len); + this->disable(); +} + +void ICM20689Component::setup() { + MotionComponent::setup(); + + this->spi_setup(); + + if (!this->spi_is_ready()) { + ESP_LOGE(TAG, "SPI device is not ready"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Datasheet 4.20: a soft reset must be the first operation after power-up, + // before any register initialization. WHO_AM_I is only valid afterwards. + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET); + delay(100); // NOLINT + + // The chip comes out of reset in sleep mode: wake it and select the + // auto-selected best clock source (required for full gyro performance). + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO); + + // USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake. + // Datasheet 6.1: disable the I2C interface when using SPI. + this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS); + + uint8_t who_am_i = 0; + this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1); + + if (who_am_i != ICM20689_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Allow the sensor to settle after waking from sleep. + delay(50); // NOLINT + + // Configure the accelerometer and gyroscope full-scale ranges. + this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast(this->accel_range_) << 3); + this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast(this->gyro_range_) << 3); +} + +void ICM20689Component::dump_config() { + ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); + LOG_PIN(" CS Pin: ", this->cs_); + + if (this->is_failed()) { + ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); + ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); + + MotionComponent::dump_config(); +} + +bool ICM20689Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + const uint8_t accel_range = static_cast(this->accel_range_); + const uint8_t gyro_range = static_cast(this->gyro_range_); + + // Read one contiguous sample: + // 0..5 accelerometer X/Y/Z + // 6..7 temperature + // 8..13 gyroscope X/Y/Z + uint8_t raw_data[14] = {}; + this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data)); + + const float accel_factor = accel_scale(accel_range); + + data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; + data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; + data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + + const float gyro_factor = gyro_scale(gyro_range); + + data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + + if (!this->temperature_callback_.empty()) { + // Datasheet formula: temperature (°C) = raw / 326.8 + 25. + const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + this->temperature_callback_.call(temperature); + } + + return true; +} + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/icm20689.h b/esphome/components/icm20689/icm20689.h new file mode 100644 index 00000000000..08ed9d03eff --- /dev/null +++ b/esphome/components/icm20689/icm20689.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::icm20689 { + +// Register map +static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75; +static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B; +static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A; +static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B; +static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C; +static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B; +static constexpr uint8_t ICM20689_READ_BIT = 0x80; +static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98; +static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20) +static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock) +static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4 + +// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG +enum ICM20689AccelRange : uint8_t { + ICM20689_ACCEL_RANGE_2G = 0x00, + ICM20689_ACCEL_RANGE_4G = 0x01, + ICM20689_ACCEL_RANGE_8G = 0x02, + ICM20689_ACCEL_RANGE_16G = 0x03, +}; + +// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG +enum ICM20689GyroRange : uint8_t { + ICM20689_GYRO_RANGE_250 = 0x00, + ICM20689_GYRO_RANGE_500 = 0x01, + ICM20689_GYRO_RANGE_1000 = 0x02, + ICM20689_GYRO_RANGE_2000 = 0x03, +}; + +class ICM20689Component final : public motion::MotionComponent, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; } + void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; } + + template void add_temperature_listener(F &&callback) { + this->temperature_callback_.add(std::forward(callback)); + } + + protected: + bool update_data(motion::MotionData &data) override; + + void write_register_(uint8_t reg, uint8_t value); + // Reads len bytes starting at reg in one CS-low transaction. + void read_registers_(uint8_t reg, uint8_t *data, size_t len); + + ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G}; + ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py new file mode 100644 index 00000000000..7673dcf4cf4 --- /dev/null +++ b/esphome/components/icm20689/motion.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ICM20689Component, icm20689_ns + +DEPENDENCIES = ["spi"] + +# Enum proxies (must match the C++ enum values exactly) +ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, + "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, + "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, + "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, +} + +ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") +GYRO_RANGE_OPTIONS = { + "250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250, + "500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500, + "1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000, + "2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(ICM20689Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + } + ) + .extend( + spi.spi_device_schema( + cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz" + ) + ) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await spi.register_spi_device(var, config) + + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) diff --git a/esphome/components/icm20689/sensor.py b/esphome/components/icm20689/sensor.py new file mode 100644 index 00000000000..7cd5dedba07 --- /dev/null +++ b/esphome/components/icm20689/sensor.py @@ -0,0 +1,42 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_ICM20689_ID, ICM20689Component + +AUTO_LOAD = ["icm20689"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_ICM20689_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/icm20689/common.yaml b/tests/components/icm20689/common.yaml new file mode 100644 index 00000000000..522a0a2bbf7 --- /dev/null +++ b/tests/components/icm20689/common.yaml @@ -0,0 +1,73 @@ +sensor: + - platform: icm20689 + type: temperature + name: "ICM20689 Temperature" + + - platform: motion + motion_id: icm20689_motion + type: acceleration_x + name: "ICM20689 Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + motion_id: icm20689_motion + type: acceleration_y + name: "ICM20689 Accel Y" + accuracy_decimals: 4 + - platform: motion + motion_id: icm20689_motion + type: acceleration_z + name: "ICM20689 Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + motion_id: icm20689_motion + type: gyroscope_x + name: "ICM20689 Gyro X" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_y + name: "ICM20689 Gyro Y" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_z + name: "ICM20689 Gyro Z" + + - platform: motion + motion_id: icm20689_motion + type: angular_rate_x + name: "ICM20689 Angular Rate X" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_y + name: "ICM20689 Angular Rate Y" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_z + name: "ICM20689 Angular Rate Z" + + - platform: motion + motion_id: icm20689_motion + type: pitch + name: "ICM20689 Pitch" + - platform: motion + motion_id: icm20689_motion + type: roll + name: "ICM20689 Roll" + +motion: + - platform: icm20689 + id: icm20689_motion + cs_pin: GPIO5 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + # Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 500DPS + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/icm20689/test.esp32-idf.yaml b/tests/components/icm20689/test.esp32-idf.yaml new file mode 100644 index 00000000000..0c9ea68ef00 --- /dev/null +++ b/tests/components/icm20689/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.esp8266-ard.yaml b/tests/components/icm20689/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ab217e25fb8 --- /dev/null +++ b/tests/components/icm20689/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.rp2040-ard.yaml b/tests/components/icm20689/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fc5a73cf62d --- /dev/null +++ b/tests/components/icm20689/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + icm20689: !include common.yaml From 40271fb59768552597151e14b5f2a340a810e3a8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 14 Oct 2026 05:10:36 +1100 Subject: [PATCH 069/111] [haier_tundra] New Climate IR component (#20469) 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 --- CODEOWNERS | 3 + esphome/components/haier_tundra/__init__.py | 2 + .../haier_tundra/button/__init__.py | 43 +++ .../button/haier_tundra_button.cpp | 18 ++ .../haier_tundra/button/haier_tundra_button.h | 25 ++ esphome/components/haier_tundra/climate.py | 51 ++++ .../components/haier_tundra/haier_tundra.cpp | 260 ++++++++++++++++++ .../components/haier_tundra/haier_tundra.h | 139 ++++++++++ .../haier_tundra/switch/__init__.py | 60 ++++ .../switch/haier_tundra_switch.cpp | 43 +++ .../haier_tundra/switch/haier_tundra_switch.h | 28 ++ tests/components/haier_tundra/common.yaml | 45 +++ .../haier_tundra/test.esp32-idf.yaml | 3 + .../haier_tundra/test.esp8266-ard.yaml | 3 + 14 files changed, 723 insertions(+) create mode 100644 esphome/components/haier_tundra/__init__.py create mode 100644 esphome/components/haier_tundra/button/__init__.py create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.cpp create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.h create mode 100644 esphome/components/haier_tundra/climate.py create mode 100644 esphome/components/haier_tundra/haier_tundra.cpp create mode 100644 esphome/components/haier_tundra/haier_tundra.h create mode 100644 esphome/components/haier_tundra/switch/__init__.py create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.cpp create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.h create mode 100644 tests/components/haier_tundra/common.yaml create mode 100644 tests/components/haier_tundra/test.esp32-idf.yaml create mode 100644 tests/components/haier_tundra/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fc53a28c0b3..a770810490b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,9 @@ esphome/components/haier/button/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn +esphome/components/haier_tundra/* @fauxpark +esphome/components/haier_tundra/button/* @fauxpark +esphome/components/haier_tundra/switch/* @fauxpark esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_tundra/__init__.py b/esphome/components/haier_tundra/__init__.py new file mode 100644 index 00000000000..d74ae6947f2 --- /dev/null +++ b/esphome/components/haier_tundra/__init__.py @@ -0,0 +1,2 @@ +DOMAIN = "haier_tundra" +CODEOWNERS = ["@fauxpark"] diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button/__init__.py new file mode 100644 index 00000000000..2e919b80099 --- /dev/null +++ b/esphome/components/haier_tundra/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraButton = haier_tundra_ns.class_( + "HaierTundraButton", button.Button, cg.Component +) +ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True) + +ICON_LED_OUTLINE = "mdi:led-outline" +ICON_SPRAY_BOTTLE = "mdi:spray-bottle" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_SELF_CLEAN = "self_clean" + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_LIGHT): button.button_schema( + HaierTundraButton, icon=ICON_LED_OUTLINE + ), + cv.Optional(CONF_SELF_CLEAN): button.button_schema( + HaierTundraButton, icon=ICON_SPRAY_BOTTLE + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + for key, kind in ( + (CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT), + (CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN), + ): + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, hub, kind) + await cg.register_component(var, conf) diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.cpp b/esphome/components/haier_tundra/button/haier_tundra_button.cpp new file mode 100644 index 00000000000..39246530bc3 --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.cpp @@ -0,0 +1,18 @@ +#include "haier_tundra_button.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.button"); + +void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); } + +void HaierTundraButton::press_action() { + if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) { + this->parent_->toggle_light(); + } else { + this->parent_->start_self_clean(); + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.h b/esphome/components/haier_tundra/button/haier_tundra_button.h new file mode 100644 index 00000000000..c4888a4616a --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/button/button.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class ButtonKind : uint8_t { + BUTTON_KIND_LIGHT, + BUTTON_KIND_SELF_CLEAN, +}; + +class HaierTundraButton final : public button::Button, public Component, public Parented { + public: + HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {} + void dump_config() override; + + protected: + void press_action() override; + + ButtonKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py new file mode 100644 index 00000000000..c992d14bfed --- /dev/null +++ b/esphome/components/haier_tundra/climate.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra") +HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) + +CONF_UNIT = "unit" +Unit = haier_tundra_ns.enum("Unit") +UNITS = { + "a": Unit.HAIER_HEADER_UNIT_A, + "b": Unit.HAIER_HEADER_UNIT_B, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( + { + cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True), + } +) + + +def _final_validate_unit(config: ConfigType) -> None: + # Each transmitter may drive at most one unit A and one unit B + climates = fv.full_config.get()["climate"] + seen = set() + + for climate in climates: + if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra": + unit_tx_pair = ( + climate[CONF_UNIT], + climate[remote_base.CONF_TRANSMITTER_ID], + ) + + if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]: + raise cv.Invalid( + "Only one of each unit is allowed per transmitter", path=[CONF_UNIT] + ) + seen.add(unit_tx_pair) + + +FINAL_VALIDATE_SCHEMA = _final_validate_unit + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("haier") + await climate_ir.new_climate_ir(config, config[CONF_UNIT]) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp new file mode 100644 index 00000000000..7a0b88d3aac --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -0,0 +1,260 @@ +#include "haier_tundra.h" + +namespace esphome::haier_tundra { + +// Number of bytes in decoded packet sans checksum +static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13; + +void HaierTundra::transmit_state() { + // Button value does not seem to matter too much except + // when toggling light, as it has no bit flag of its own + // So in the default case we can simply set it to POWER + this->transmit_(HAIER_BUTTON_POWER); +} + +void HaierTundra::transmit_(uint8_t button) { + // Force Quiet and Turbo off if we're not in Cool or Heat mode + if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + + this->quiet_mode_ = false; + this->turbo_mode_ = false; + } + + remote_base::HaierData remote_state{{ + this->unit_, + static_cast(this->temperature_() << 4 | this->vertical_swing_()), + static_cast(this->horizontal_swing_() << 5), + static_cast(this->health_() << 1), + static_cast(this->power_() << 6), + static_cast(this->fan_speed_() << 5), + static_cast(this->quiet_() << 7 | this->turbo_() << 6), + static_cast(this->operation_mode_() << 5), + 0x00, + 0x00, + static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), + 0x00, + button, + }}; + + auto transmit = this->transmitter_->transmit(); + remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + transmit.perform(); +} + +uint8_t HaierTundra::temperature_() { + return (uint8_t) roundf(clamp(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN; +} + +uint8_t HaierTundra::vertical_swing_() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + return HAIER_SWING_V_OFF; + } + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_V_AUTO; + default: + if (this->mode == climate::CLIMATE_MODE_HEAT) { + return HAIER_SWING_V_1BOT; + } + return HAIER_SWING_V_1TOP; // Cool/Dry/Auto + } +} + +uint8_t HaierTundra::horizontal_swing_() { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_HORIZONTAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_H_AUTO; + default: + return HAIER_SWING_H_MIDDLE; + } +} + +uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; } + +uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; } + +uint8_t HaierTundra::fan_speed_() { + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { + case climate::CLIMATE_FAN_LOW: + return HAIER_FAN_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return HAIER_FAN_MED; + case climate::CLIMATE_FAN_HIGH: + return HAIER_FAN_HIGH; + case climate::CLIMATE_FAN_AUTO: + default: + return HAIER_FAN_AUTO; + } +} + +uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; } + +uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; } + +uint8_t HaierTundra::operation_mode_() { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + return HAIER_MODE_COOL; + case climate::CLIMATE_MODE_DRY: + return HAIER_MODE_DRY; + case climate::CLIMATE_MODE_HEAT: + return HAIER_MODE_HEAT; + case climate::CLIMATE_MODE_FAN_ONLY: + return HAIER_MODE_FAN; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return HAIER_MODE_AUTO; + } +} + +bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) { + auto haier = remote_base::HaierProtocol().decode(data); + if (haier.has_value()) { + return this->on_haier_(*haier); + } + return false; +} + +bool HaierTundra::on_haier_(const remote_base::HaierData &haier) { + // Verify frame length and header + if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) { + return false; + } + + this->target_temperature = this->get_temperature_(haier.data[1] >> 4); + this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5); + this->health_mode_ = haier.data[3] & 0x2; + this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5); + this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5); + this->quiet_mode_ = haier.data[6] & 0x80; + this->turbo_mode_ = haier.data[6] & 0x40; + +#ifdef USE_SWITCH + if (this->health_mode_switch_ != nullptr) { + this->health_mode_switch_->publish_state(this->health_mode_); + } + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(this->quiet_mode_); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(this->turbo_mode_); + } +#endif + + this->publish_state(); + + return true; +} + +float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; } + +climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) { + if (swing_v == HAIER_SWING_V_AUTO) { + if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_BOTH; + } + + return climate::CLIMATE_SWING_VERTICAL; + } else if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_HORIZONTAL; + } + + return climate::CLIMATE_SWING_OFF; +} + +climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) { + if (!power) { + return climate::CLIMATE_MODE_OFF; + } + + switch (mode) { + case HAIER_MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case HAIER_MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case HAIER_MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case HAIER_MODE_FAN: + return climate::CLIMATE_MODE_FAN_ONLY; + case HAIER_MODE_AUTO: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) { + switch (fan) { + case HAIER_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case HAIER_FAN_MED: + return climate::CLIMATE_FAN_MEDIUM; + case HAIER_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + case HAIER_FAN_AUTO: + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +void HaierTundra::set_health_mode(bool health, bool transmit) { + this->health_mode_ = health; + + if (transmit && this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_HEALTH); + } +} + +void HaierTundra::set_quiet_mode(bool quiet, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (quiet) { +#ifdef USE_SWITCH + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + this->turbo_mode_ = false; + } + + this->quiet_mode_ = quiet; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::set_turbo_mode(bool turbo, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (turbo) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } +#endif + this->quiet_mode_ = false; + } + + this->turbo_mode_ = turbo; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::toggle_light() { + if (this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_LIGHT); + } +} + +void HaierTundra::start_self_clean() { + // Can be started when off + this->transmit_(HAIER_BUTTON_SELF_CLEAN); +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/haier_tundra.h b/esphome/components/haier_tundra/haier_tundra.h new file mode 100644 index 00000000000..b2bb1e51ce9 --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.h @@ -0,0 +1,139 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/haier_protocol.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +namespace esphome::haier_tundra { + +// Byte 0 - Header value (Unit ID) +enum Unit : uint8_t { + HAIER_HEADER_UNIT_B = 0x59, + HAIER_HEADER_UNIT_A = 0xA6, +}; + +// Byte 1[7:4] - Temperature (˚C) 0x0..0xE +static constexpr uint8_t HAIER_TEMP_MIN = 16; +static constexpr uint8_t HAIER_TEMP_MAX = 30; + +// Byte 1[3:0] - Vertical Swing +static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position +static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ] +static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode) +static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //] +static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only) +static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC; + +// Byte 2[7:5] - Horizontal Swing +static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ] +static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ] +static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \] +static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\] + +// Byte 3[7:5] - Timer Mode +static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0; +static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF +static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON +static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF +static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF + +// Byte 3[1] - Health +// Byte 4[6] - Power + +// Byte 5[7:5] - Fan Speed +static constexpr uint8_t HAIER_FAN_HIGH = 0x1; +static constexpr uint8_t HAIER_FAN_MED = 0x2; +static constexpr uint8_t HAIER_FAN_LOW = 0x3; +static constexpr uint8_t HAIER_FAN_AUTO = 0x5; + +// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17 +// Byte 6[7] - Quiet Mode +// Byte 6[6] - Turbo Mode +// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B + +// Byte 7[7:5] - Mode +static constexpr uint8_t HAIER_MODE_AUTO = 0x0; +static constexpr uint8_t HAIER_MODE_COOL = 0x1; +static constexpr uint8_t HAIER_MODE_DRY = 0x2; +static constexpr uint8_t HAIER_MODE_HEAT = 0x4; +static constexpr uint8_t HAIER_MODE_FAN = 0x6; + +// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17 +// Byte 8[7] - Sleep Mode +// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B +// Byte 10[4] - Self Clean + +// Byte 12 - Button +static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00; +static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01; +static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02; +static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03; +static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04; +static constexpr uint8_t HAIER_BUTTON_POWER = 0x05; +static constexpr uint8_t HAIER_BUTTON_MODE = 0x06; +static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07; +static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08; +static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B; +static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10; +static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15; +static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19; + +class HaierTundra final : public climate_ir::ClimateIR { + public: + explicit HaierTundra(Unit unit) + : climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}), + unit_(unit) {} + +#ifdef USE_SWITCH + SUB_SWITCH(health_mode); + SUB_SWITCH(quiet_mode); + SUB_SWITCH(turbo_mode); +#endif + + void set_health_mode(bool health, bool transmit = true); + void set_quiet_mode(bool quiet, bool transmit = true); + void set_turbo_mode(bool turbo, bool transmit = true); + + void toggle_light(); + void start_self_clean(); + + protected: + // Transmit via IR the state of this climate controller. + void transmit_state() override; + + void transmit_(uint8_t button); + uint8_t temperature_(); + uint8_t vertical_swing_(); + uint8_t horizontal_swing_(); + uint8_t health_(); + uint8_t power_(); + uint8_t fan_speed_(); + uint8_t quiet_(); + uint8_t turbo_(); + uint8_t operation_mode_(); + + // Handle received IR Buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + bool on_haier_(const remote_base::HaierData &haier); + + float get_temperature_(uint8_t temp); + climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h); + climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode); + climate::ClimateFanMode get_fan_speed_(uint8_t fan); + + Unit unit_; + bool health_mode_{false}; + bool quiet_mode_{false}; + bool turbo_mode_{false}; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/__init__.py b/esphome/components/haier_tundra/switch/__init__.py new file mode 100644 index 00000000000..f62769c1350 --- /dev/null +++ b/esphome/components/haier_tundra/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraSwitch = haier_tundra_ns.class_( + "HaierTundraSwitch", switch.Switch, cg.Component +) +SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True) + +ICON_LEAF = "mdi:leaf" +ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right" +ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_HEALTH_MODE = "health_mode" +CONF_QUIET_MODE = "quiet_mode" +CONF_TURBO_MODE = "turbo_mode" + + +def _switch_schema(icon: str) -> cv.Schema: + return switch.switch_schema( + HaierTundraSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF), + cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT), + cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + switches = switch.sub_switches(config) + if sw := await switches( + CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH + ): + await cg.register_component(sw, config[CONF_HEALTH_MODE]) + if sw := await switches( + CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET + ): + await cg.register_component(sw, config[CONF_QUIET_MODE]) + if sw := await switches( + CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO + ): + await cg.register_component(sw, config[CONF_TURBO_MODE]) diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp new file mode 100644 index 00000000000..e24ada79b25 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp @@ -0,0 +1,43 @@ +#include "haier_tundra_switch.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.switch"); + +void HaierTundraSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + const bool state = initial.value(); + this->apply_(state, false); + this->publish_state(state); + } +} + +void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); } + +void HaierTundraSwitch::write_state(bool state) { + // Quiet and Turbo can only be turned on in Cool or Heat mode + if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL && + this->parent_->mode != climate::CLIMATE_MODE_HEAT) { + return; + } + this->apply_(state, true); + this->publish_state(state); +} + +void HaierTundraSwitch::apply_(bool state, bool transmit) { + switch (this->kind_) { + case SwitchKind::SWITCH_KIND_HEALTH: + this->parent_->set_health_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_QUIET: + this->parent_->set_quiet_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_TURBO: + this->parent_->set_turbo_mode(state, transmit); + break; + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.h b/esphome/components/haier_tundra/switch/haier_tundra_switch.h new file mode 100644 index 00000000000..d718b940a15 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class SwitchKind : uint8_t { + SWITCH_KIND_HEALTH, + SWITCH_KIND_QUIET, + SWITCH_KIND_TURBO, +}; + +class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented { + public: + HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {} + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; + void apply_(bool state, bool transmit); + + SwitchKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/tests/components/haier_tundra/common.yaml b/tests/components/haier_tundra/common.yaml new file mode 100644 index 00000000000..0388220cacd --- /dev/null +++ b/tests/components/haier_tundra/common.yaml @@ -0,0 +1,45 @@ +climate: + - platform: haier_tundra + name: Haier AC A + id: my_haier_ac_a + transmitter_id: xmitr + + - platform: haier_tundra + name: Haier AC B + id: my_haier_ac_b + transmitter_id: xmitr + unit: b + +switch: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + quiet_mode: + name: "Quiet Mode (Unit A)" + turbo_mode: + name: "Turbo Mode (Unit A)" + health_mode: + name: "Health Mode (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + quiet_mode: + name: "Quiet Mode (Unit B)" + turbo_mode: + name: "Turbo Mode (Unit B)" + health_mode: + name: "Health Mode (Unit B)" + +button: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + light: + name: "Toggle Light (Unit A)" + self_clean: + name: "Start Self Clean (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + light: + name: "Toggle Light (Unit B)" + self_clean: + name: "Start Self Clean (Unit B)" diff --git a/tests/components/haier_tundra/test.esp32-idf.yaml b/tests/components/haier_tundra/test.esp32-idf.yaml new file mode 100644 index 00000000000..b88eb95efe7 --- /dev/null +++ b/tests/components/haier_tundra/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + haier_tundra: !include common.yaml diff --git a/tests/components/haier_tundra/test.esp8266-ard.yaml b/tests/components/haier_tundra/test.esp8266-ard.yaml new file mode 100644 index 00000000000..888edbf140b --- /dev/null +++ b/tests/components/haier_tundra/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + haier_tundra: !include common.yaml From 5acbd44067417199f89c8c8d6abcbdd01de6b159 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:12:28 +0200 Subject: [PATCH 070/111] [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589) Co-authored-by: J. Nick Koston --- esphome/components/pcf8574/__init__.py | 6 + esphome/components/pcf8574/pcf8574.cpp | 33 ++-- esphome/components/pcf8574/pcf8574.h | 11 +- tests/components/pcf8574/common.yaml | 32 ++++ tests/components/pcf8574/test_pcf8574.cpp | 189 ++++++++++++++++++++ tests/unit_tests/components/test_pcf8574.py | 35 ++++ 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 tests/components/pcf8574/test_pcf8574.cpp create mode 100644 tests/unit_tests/components/test_pcf8574.py diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 6c9ed3cd98b..5ede39d7145 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_RESET, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" +# Keep in sync with PCF8574Component::reset_ in pcf8574.h. +DEFAULT_RESET = True CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) @@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if (reset := config[CONF_RESET]) != DEFAULT_RESET: + cg.add(var.set_reset(reset)) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 727e9a20265..ea1e894a530 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -12,8 +12,13 @@ void PCF8574Component::setup() { return; } - this->write_gpio_(); - this->read_gpio_(); + if (this->reset_) { + this->write_gpio_(); + this->read_gpio_(); + } else { + this->output_mask_ = this->input_mask_; + this->mode_mask_ = ~this->configured_input_pins_; + } if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); @@ -40,8 +45,9 @@ void PCF8574Component::loop() { void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" - " Is PCF8575: %s", - YESNO(this->pcf8575_)); + " Is PCF8575: %s\n" + " Reset: %s", + YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { @@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; - bool success; - uint8_t data[2]; - if (this->pcf8575_) { - success = this->read_bytes_raw(data, 2); - this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); - } else { - success = this->read_bytes_raw(data, 1); - this->input_mask_ = data[0]; - } - - if (!success) { + uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. + if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { this->status_set_warning(); return false; } + this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; this->status_clear_warning(); return true; } @@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() { float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } void PCF8574GPIOPin::setup() { pin_mode(flags_); } +void PCF8574GPIOPin::set_flags(gpio::Flags flags) { + this->flags_ = flags; + if (flags & gpio::FLAG_INPUT) + this->parent_->register_input_pin(this->pin_); +} void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 9879d6a47eb..465bbd7a918 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -16,6 +16,9 @@ class PCF8574Component final : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_reset(bool reset) { this->reset_ = reset; } + // Called by pin setters before any component's setup or GPIO writes. + void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks @@ -44,6 +47,11 @@ class PCF8574Component final : public Component, uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins + // (including undeclared pins) are treated as outputs seeded from their observed levels. + uint16_t configured_input_pins_{0x00}; + // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. + bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 InternalGPIOPin *interrupt_pin_{nullptr}; }; @@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin { void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + // Call after set_parent() and set_pin(): input flags register the pin with the parent. + void set_flags(gpio::Flags flags); gpio::Flags get_flags() const override { return this->flags_; } diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 8a26b930152..4613f4b8859 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -8,6 +8,16 @@ pcf8574: address: 0x22 pcf8575: false interrupt_pin: ${interrupt_pin} + reset: true + - id: pcf8574_preserved + i2c_id: i2c_bus + address: 0x23 + reset: false + - id: pcf8575_preserved + i2c_id: i2c_bus + address: 0x24 + pcf8575: true + reset: false binary_sensor: - platform: gpio @@ -18,6 +28,18 @@ binary_sensor: number: 1 mode: INPUT inverted: true + - platform: gpio + id: pcf8574_preserved_input + pin: + pcf8574: pcf8574_preserved + number: 1 + mode: INPUT + - platform: gpio + id: pcf8575_preserved_input + pin: + pcf8574: pcf8575_preserved + number: 15 + mode: INPUT output: - platform: gpio @@ -27,3 +49,13 @@ output: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcf8574_preserved_output + pin: + pcf8574: pcf8574_preserved + number: 0 + - platform: gpio + id: pcf8575_preserved_output + pin: + pcf8574: pcf8575_preserved + number: 8 diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp new file mode 100644 index 00000000000..3500069ba10 --- /dev/null +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -0,0 +1,189 @@ +#include + +#include "esphome/components/pcf8574/pcf8574.h" + +namespace esphome::pcf8574::testing { + +// Model quasi-bidirectional readback separately from the output latch. +class PortBus : public i2c::I2CBus { + public: + 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, 0x21); + if (read_count != 0) { + EXPECT_EQ(read_count, this->width); + this->reads++; + if (this->fail_read) + return i2c::ERROR_TIMEOUT; + uint16_t levels = this->latch & ~this->external_low; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = levels >> (8 * i); + } + if (write_count != 0) { + EXPECT_EQ(write_count, this->width); + this->writes++; + this->latch = write_buffer[0]; + if (write_count == 2) + this->latch |= uint16_t(write_buffer[1]) << 8; + } + return i2c::ERROR_OK; + } + + size_t width{1}; + uint16_t latch{0}; + uint16_t external_low{0}; + unsigned reads{0}; + bool fail_read{false}; + unsigned writes{0}; +}; + +class PCF8574ResetTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + this->bus_.width = GetParam() ? 2 : 1; + this->mask_ = GetParam() ? 0xFFFF : 0xFF; + this->high_pin_ = GetParam() ? 15 : 7; + this->bus_.latch = 0xA55A & this->mask_; + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(0x21); + this->component_.set_pcf8575(GetParam()); + } + + void configure_input_(uint8_t number) { + PCF8574GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 0); + } + + PortBus bus_; + PCF8574Component component_; + uint16_t mask_; + uint8_t high_pin_; +}; + +TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) { + this->configure_input_(1); + + this->component_.setup(); + ASSERT_EQ(this->bus_.writes, 1); + EXPECT_EQ(this->bus_.latch, this->mask_); + EXPECT_EQ(this->bus_.reads, 2); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 1); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, this->mask_ & ~1); +} + +TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) { + this->component_.set_reset(false); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 1); + EXPECT_EQ(this->bus_.latch, initial); + + // Consumers can write before calling their GPIO pin's setup(). + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + const uint16_t high_pin_mask = 1 << this->high_pin_; + this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0); + const uint16_t expected = (initial | 1) ^ high_pin_mask; + EXPECT_EQ(this->bus_.latch, expected); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, expected & ~1); + ASSERT_EQ(this->bus_.writes, 3); +} + +TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) { + const uint16_t inputs = (1 << 1) | (1 << this->high_pin_); + this->bus_.latch |= inputs; + const uint16_t initial = this->bus_.latch; + this->bus_.external_low = inputs; + + this->component_.set_reset(false); + this->configure_input_(1); + this->configure_input_(this->high_pin_); + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial | 1); + EXPECT_FALSE(this->component_.digital_read(1)); +} + +TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) { + this->component_.set_reset(false); + this->configure_input_(1); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + this->component_.pin_mode(1, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); +} + +TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) { + const uint16_t loaded_pin = 1 << this->high_pin_; + this->bus_.latch |= loaded_pin; + this->bus_.external_low = loaded_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin); +} + +TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) { + const uint16_t undeclared_pin = 1 << this->high_pin_; + this->bus_.latch |= undeclared_pin; + this->bus_.external_low = undeclared_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + this->bus_.external_low = 0; + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin); + EXPECT_FALSE(this->component_.digital_read(this->high_pin_)); +} + +TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) { + this->bus_.fail_read = true; + + this->component_.set_reset(false); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_failed()); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.writes, 0); +} + +INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool()); + +} // namespace esphome::pcf8574::testing diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py new file mode 100644 index 00000000000..43751815358 --- /dev/null +++ b/tests/unit_tests/components/test_pcf8574.py @@ -0,0 +1,35 @@ +"""PCF8574 reset configuration and code generation.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome.components import pcf8574 +from esphome.const import CONF_ID, CONF_RESET + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options, expected", + [ + ({}, True), + ({"reset": True}, True), + ({"reset": False}, False), + ], +) +async def test_reset_configuration(options: dict, expected: bool) -> None: + config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options}) + assert config[CONF_RESET] is expected + var = Mock() + with ( + patch.object(pcf8574.cg, "new_Pvariable", return_value=var), + patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.to_code(config) + + if expected: + var.set_reset.assert_not_called() + else: + var.set_reset.assert_called_once_with(False) From d27ec8ec4966634d59dee9002bd631e71f71d458 Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:13:26 +0200 Subject: [PATCH 071/111] [voice_assistant] Make TTS playback start timeout configurable (#18707) Co-authored-by: n-IA-hane --- .../components/voice_assistant/__init__.py | 16 ++++++- .../voice_assistant/voice_assistant.cpp | 2 +- .../voice_assistant/voice_assistant.h | 3 ++ .../voice_assistant/__init__.py | 0 .../voice_assistant/test_playback_timeout.py | 44 +++++++++++++++++++ .../voice_assistant/common-idf.yaml | 1 + tests/components/voice_assistant/common.yaml | 1 + .../components/voice_assistant/__init__.py | 0 .../test_tts_playback_timeout.py | 35 +++++++++++++++ 9 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/voice_assistant/__init__.py create mode 100644 tests/component_tests/voice_assistant/test_playback_timeout.py create mode 100644 tests/unit_tests/components/voice_assistant/__init__.py create mode 100644 tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d306947ec13..488e16a3839 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, core import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" +CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" +# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h. +TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000) CONF_ON_TIMER_STARTED = "on_timer_started" CONF_ON_TIMER_UPDATED = "on_timer_updated" @@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_CONVERSATION_TIMEOUT, default="300s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TTS_PLAYBACK_START_TIMEOUT, + default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT, + ): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range( min=0.0, min_included=False ), @@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN])) cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER])) cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT])) + if ( + timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT] + ) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT: + cg.add(var.set_tts_playback_start_timeout(timeout)) if CONF_ON_LISTENING in config: await automation.build_automation( diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 039410abdbf..1d66ca5950f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() { this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index ae5eee3e227..139a1c84c38 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -193,6 +193,7 @@ class VoiceAssistant final : public Component { void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; } void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; } void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; } + void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; } void reset_conversation_id(); Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; } @@ -318,6 +319,8 @@ class VoiceAssistant final : public Component { float volume_multiplier_; uint32_t conversation_timeout_; + // Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py. + uint32_t tts_playback_start_timeout_{2000}; Configuration config_{}; diff --git a/tests/component_tests/voice_assistant/__init__.py b/tests/component_tests/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/voice_assistant/test_playback_timeout.py b/tests/component_tests/voice_assistant/test_playback_timeout.py new file mode 100644 index 00000000000..cae93e18bf8 --- /dev/null +++ b/tests/component_tests/voice_assistant/test_playback_timeout.py @@ -0,0 +1,44 @@ +"""Test playback timeout C++ generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"]) +def test_playback_timeout_codegen( + timeout: str | None, + tmp_path: Path, + generate_main: Callable[[Path], str], +) -> None: + config = tmp_path / "voice_assistant.yaml" + config.write_text( + """esphome: + name: test-voice-assistant +esp32: + board: esp32dev + framework: + type: esp-idf +wifi: + ssid: test-network +api: +i2s_audio: + i2s_lrclk_pin: GPIO25 + i2s_bclk_pin: GPIO26 +microphone: + - platform: i2s_audio + id: test_mic + adc_type: external + i2s_din_pin: GPIO34 +voice_assistant: + microphone: test_mic +""" + + (f" tts_playback_start_timeout: {timeout}\n" if timeout else "") + ) + main = generate_main(config) + if timeout in (None, "2s", "2000ms"): + assert "set_tts_playback_start_timeout(" not in main + else: + milliseconds = 1 if timeout == "1ms" else 10000 + assert f"set_tts_playback_start_timeout({milliseconds});" in main diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 998ba90dd99..d08c76879e1 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -55,6 +55,7 @@ voice_assistant: speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 5a5791899e2..df1fa50eac9 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -36,6 +36,7 @@ voice_assistant: channels: 0 speaker: va_speaker_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/unit_tests/components/voice_assistant/__init__.py b/tests/unit_tests/components/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py new file mode 100644 index 00000000000..96f44a09e32 --- /dev/null +++ b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py @@ -0,0 +1,35 @@ +"""Validate Voice Assistant playback timeout configuration.""" + +import pytest + +from esphome.components import voice_assistant +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE + + +@pytest.mark.parametrize( + "value", + ["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}], +) +def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + + +@pytest.mark.parametrize( + "value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)] +) +def test_playback_timeout_accepts_positive_milliseconds( + value: str, milliseconds: int +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds + + +def test_playback_timeout_default_is_two_seconds() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({}) + assert config["tts_playback_start_timeout"].total_milliseconds == 2000 From 1d8423edd802f8cb8de213c1020e1b36b769e3dc Mon Sep 17 00:00:00 2001 From: cociweb Date: Tue, 13 Oct 2026 20:14:43 +0200 Subject: [PATCH 072/111] [es8388] Add initial option configuration for DAC output and ADC input mic (#16342) Co-authored-by: J. Nick Koston --- esphome/components/es8388/es8388.cpp | 40 +++++++++++------ esphome/components/es8388/es8388.h | 15 ++++++- esphome/components/es8388/select/__init__.py | 46 ++++++++++++++++++-- esphome/core/defines.h | 1 + tests/components/es8388/common.yaml | 4 ++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index b57949ccd0a..56ee1ac4a59 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -111,24 +111,36 @@ void ES8388::setup() { // Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09)); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT if (this->dac_output_select_ != nullptr) { - auto dac_power = this->get_dac_power(); - if (dac_power.has_value()) { - if (this->dac_output_select_->has_index(dac_power.value())) { - this->dac_output_select_->publish_state(dac_power.value()); - } else { - ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + if (this->dac_output_initial_.has_value()) { + const DacOutputLine line = this->dac_output_initial_.value(); + ES8388_ERROR_FAILED(this->set_dac_output(line)); + this->dac_output_select_->publish_state(static_cast(line)); + } else { + auto dac_power = this->get_dac_power(); + if (dac_power.has_value()) { + if (this->dac_output_select_->has_index(dac_power.value())) { + this->dac_output_select_->publish_state(dac_power.value()); + } else { + ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + } } } } if (this->adc_input_mic_select_ != nullptr) { - auto mic_input = this->get_mic_input(); - if (mic_input.has_value()) { - if (this->adc_input_mic_select_->has_index(mic_input.value())) { - this->adc_input_mic_select_->publish_state(mic_input.value()); - } else { - ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + if (this->adc_input_mic_initial_.has_value()) { + const AdcInputMicLine line = this->adc_input_mic_initial_.value(); + ES8388_ERROR_FAILED(this->set_adc_input_mic(line)); + this->adc_input_mic_select_->publish_state(static_cast(line)); + } else { + auto mic_input = this->get_mic_input(); + if (mic_input.has_value()) { + if (this->adc_input_mic_select_->has_index(mic_input.value())) { + this->adc_input_mic_select_->publish_state(mic_input.value()); + } else { + ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + } } } } @@ -138,7 +150,7 @@ void ES8388::setup() { void ES8388::dump_config() { ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:"); LOG_I2C_DEVICE(this); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index b01acb69c1e..b55538c94f8 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -5,7 +5,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT #include "esphome/components/select/select.h" #endif @@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t { }; class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) #endif @@ -68,11 +68,22 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I bool set_dac_output(DacOutputLine line); bool set_adc_input_mic(AdcInputMicLine line); +#ifdef USE_ES8388_SELECT + /// Line to apply in setup() instead of keeping what the codec reports. + void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; } + void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; } +#endif + protected: /// @brief Mutes or unmutes the DAC audio out /// @param mute_state True to mute, false to unmute /// @return True if successful and false otherwise bool set_mute_state_(bool mute_state); + +#ifdef USE_ES8388_SELECT + optional dac_output_initial_{}; + optional adc_input_mic_initial_{}; +#endif }; } // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index 62ddd6cb38a..f0e3f5b7c89 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -1,7 +1,11 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401 +from esphome.const import ( + CONF_INITIAL_OPTION, + ENTITY_CATEGORY_CONFIG, + ICON_CHIP, # noqa: F401 +) from esphome.types import ConfigType from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns @@ -9,6 +13,21 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" +DacOutputLine = es8388_ns.enum("DacOutputLine") +DAC_OUTPUT_OPTIONS = { + "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, + "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, + "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, +} +DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) +AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") +ADC_INPUT_MIC_OPTIONS = { + "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, + "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, + "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, +} +ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) + DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select) @@ -19,24 +38,45 @@ CONFIG_SCHEMA = cv.All( DacOutputSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + DAC_OUTPUT_OPTIONS, upper=True + ), + } ), cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema( ADCInputMicSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + ADC_INPUT_MIC_OPTIONS, upper=True + ), + } ), } ) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_ES8388_SELECT") hub = await cg.get_variable(config[CONF_ES8388_ID]) selects = select.sub_selects(config, parent=hub) await selects( - CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"] + CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES ) await selects( CONF_ADC_INPUT_MIC, hub.set_adc_input_mic_select, - options=["LINE1", "LINE2", "DIFFERENCE"], + options=ADC_INPUT_MIC_OPTION_NAMES, ) + if ( + initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_dac_output_initial(initial)) + if ( + initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_adc_input_mic_initial(initial)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d9b12fdddb1..08f1e31d6c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ES8388_SELECT #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED diff --git a/tests/components/es8388/common.yaml b/tests/components/es8388/common.yaml index cc7465ab935..aa08b9b0470 100644 --- a/tests/components/es8388/common.yaml +++ b/tests/components/es8388/common.yaml @@ -17,5 +17,9 @@ select: es8388_id: es8388_parent dac_output: name: "DAC Output" + id: es8388_test_dac_output_select + initial_option: BOTH adc_input_mic: name: "ADC Input MIC" + id: es8388_test_adc_input_mic_select + initial_option: LINE2 From 55261ae7e5df30e846d2e84bc1b6775b3eab49ad Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 20:17:25 +0200 Subject: [PATCH 073/111] [tmc2209] Add driver diagnostics and stall detection (#20618) Co-authored-by: Claude Opus 5.5 --- esphome/components/tmc2209/__init__.py | 44 +++++ esphome/components/tmc2209/stepper.py | 28 ++- esphome/components/tmc2209/tmc2209.cpp | 20 ++ esphome/components/tmc2209/tmc2209.h | 39 ++++ esphome/components/tmc22xx/__init__.py | 18 +- esphome/components/tmc22xx/tmc22xx.cpp | 186 ++++++++++++++++-- esphome/components/tmc22xx/tmc22xx.h | 55 ++++++ tests/components/tmc2209/common.yaml | 29 +++ tests/components/tmc2209/test.esp32-idf.yaml | 1 + .../components/tmc2209/test.esp8266-ard.yaml | 1 + tests/components/tmc2209/test.rp2040-ard.yaml | 1 + 11 files changed, 404 insertions(+), 18 deletions(-) diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py index b3822f7582e..06dd8603ff5 100644 --- a/esphome/components/tmc2209/__init__.py +++ b/esphome/components/tmc2209/__init__.py @@ -1,10 +1,54 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import tmc22xx +import esphome.config_validation as cv +from esphome.const import CONF_THRESHOLD CODEOWNERS = ["@remcom"] DOMAIN = "tmc2209" +CONF_SEDN = "sedn" +CONF_SEIMIN = "seimin" +CONF_SEMAX = "semax" +CONF_SEMIN = "semin" +CONF_SEUP = "seup" +CONF_TCOOL_THRESHOLD = "tcool_threshold" + tmc2209_ns = cg.esphome_ns.namespace("tmc2209") TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) tmc22xx.register_actions("tmc2209", TMC2209Stepper) + +automation.register_apply_action( + "tmc2209.stallguard", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8), + automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32), +) + +automation.register_apply_action( + "tmc2209.coolconf", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8), + automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8), + automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8), + automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8), + automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_), +) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py index 5ef91dc591f..8867242aa25 100644 --- a/esphome/components/tmc2209/stepper.py +++ b/esphome/components/tmc2209/stepper.py @@ -1,4 +1,6 @@ +from esphome import automation from esphome.components import tmc22xx +import esphome.config_validation as cv from esphome.types import ConfigType from . import TMC2209Stepper @@ -6,9 +8,31 @@ from . import TMC2209Stepper AUTO_LOAD = ["tmc22xx"] DEPENDENCIES = ["uart"] -CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +CONF_ON_STALL = "on_stall" + + +def _validate_stall_needs_diag(config: ConfigType) -> ConfigType: + if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config: + raise cv.Invalid( + f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG" + ) + return config + + +CONFIG_SCHEMA = cv.All( + tmc22xx.tmc22xx_schema( + TMC2209Stepper, + max_address=3, + extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})}, + ), + _validate_stall_needs_diag, +) FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate async def to_code(config: ConfigType) -> None: - await tmc22xx.new_tmc22xx(config) + var = await tmc22xx.new_tmc22xx(config) + for conf in config.get(CONF_ON_STALL, []): + await automation.build_callback_automation( + var, "add_on_stall_callback", [], conf + ) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp index 040b362a8d5..21625a5147d 100644 --- a/esphome/components/tmc2209/tmc2209.cpp +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -10,4 +10,24 @@ void TMC2209Stepper::dump_config() { TMC22XXStepper::dump_config(); } +uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_TCOOLTHRS: + return &this->tcoolthrs_; + case REG_SGTHRS: + return &this->sgthrs_; + case REG_COOLCONF: + return &this->coolconf_; + default: + return TMC22XXStepper::shadow_register(reg); + } +} + +void TMC2209Stepper::restore_registers() { + TMC22XXStepper::restore_registers(); + this->write_register(REG_TCOOLTHRS, this->tcoolthrs_); + this->write_register(REG_SGTHRS, this->sgthrs_); + this->write_register(REG_COOLCONF, this->coolconf_); +} + } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index 2f880251e56..f1c83485fa3 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -4,12 +4,51 @@ namespace esphome::tmc2209 { +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_COOLCONF = 0x42; + +static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; +static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, 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}; +static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false}; +static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false}; + class TMC2209Stepper : public tmc22xx::TMC22XXStepper { public: void dump_config() override; + /// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS. + template void add_on_stall_callback(F &&callback) { + this->stall_callback_.add(std::forward(callback)); + } + + /// 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); } + /// 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); } + void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); } + void set_seup(uint8_t seup) { this->write_field(SEUP, seup); } + void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); } + void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); } + protected: uint8_t expected_version() const override { return 0x21; } + uint32_t *shadow_register(uint8_t reg) override; + void restore_registers() override; + void on_diag_without_error() override { this->stall_callback_.call(); } + + LazyCallbackManager stall_callback_; + // Write-only registers with their power-on values + uint32_t tcoolthrs_{0}; + uint32_t sgthrs_{0}; + uint32_t coolconf_{0}; }; } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ad7221f0b21..da760dde2c5 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -21,6 +21,7 @@ DEPENDENCIES = ["uart"] CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" CONF_HOLD_CURRENT = "hold_current" @@ -31,6 +32,7 @@ CONF_INTERPOLATION = "interpolation" CONF_INVERSE_DIRECTION = "inverse_direction" CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" +CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" @@ -44,6 +46,7 @@ TMC22XXStepper = tmc22xx_ns.class_( "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice ) StandstillMode = tmc22xx_ns.enum("StandstillMode") +DriverEvent = tmc22xx_ns.enum("DriverEvent") STANDSTILL_MODES = { "normal": StandstillMode.STANDSTILL_MODE_NORMAL, @@ -56,7 +59,9 @@ validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) validate_current = cv.All(cv.current, cv.positive_float) -def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: +def tmc22xx_schema( + cls: MockObjClass, max_address: int, extra: dict | None = None +) -> cv.Schema: """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" return cv.All( stepper.STEPPER_SCHEMA.extend( @@ -67,6 +72,7 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_DIAG_PIN): pins.internal_gpio_input_pin_schema, cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( cv.frequency, cv.int_range(min=1) ), @@ -79,6 +85,8 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_RUN_CURRENT): validate_current, cv.Optional(CONF_HOLD_CURRENT): validate_current, cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + cv.Optional(CONF_ON_STATUS): automation.validate_automation({}), + **(extra or {}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +141,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_STEP_PIN, var.set_step_pin), (CONF_DIR_PIN, var.set_dir_pin), (CONF_INDEX_PIN, var.set_index_pin), + (CONF_DIAG_PIN, var.set_diag_pin), ): if (pin := config.get(key)) is not None: cg.add(setter(await cg.gpio_pin_expression(pin))) @@ -145,6 +154,13 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: ): if (value := config.get(key)) is not None: cg.add(setter(value)) + for conf in config.get(CONF_ON_STATUS, []): + await automation.build_callback_automation( + var, + "add_on_status_callback", + [(DriverEvent, "event"), (cg.bool_, "active")], + conf, + ) return var diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 1628572d406..2aed97bea10 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -23,6 +23,49 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis static constexpr float APPROACH_TIME_S = 0.02f; // Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; +static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000; +// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read +// blocks the loop for the UART timeout +static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9; +static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp +static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF; + +static const LogString *driver_event_to_string(DriverEvent event) { + switch (event) { + case DRIVER_EVENT_RESET: + return LOG_STR("Driver reset"); + case DRIVER_EVENT_DRIVER_ERROR: + return LOG_STR("Driver error"); + case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE: + return LOG_STR("Charge pump undervoltage"); + case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING: + return LOG_STR("Overtemperature prewarning"); + case DRIVER_EVENT_OVERTEMPERATURE: + return LOG_STR("Overtemperature shutdown"); + case DRIVER_EVENT_SHORT_TO_GROUND_A: + return LOG_STR("Short to ground on phase A"); + case DRIVER_EVENT_SHORT_TO_GROUND_B: + return LOG_STR("Short to ground on phase B"); + case DRIVER_EVENT_LOW_SIDE_SHORT_A: + return LOG_STR("Low side short on phase A"); + case DRIVER_EVENT_LOW_SIDE_SHORT_B: + return LOG_STR("Low side short on phase B"); + case DRIVER_EVENT_OPEN_LOAD_A: + return LOG_STR("Open load on phase A"); + case DRIVER_EVENT_OPEN_LOAD_B: + return LOG_STR("Open load on phase B"); + case DRIVER_EVENT_TEMPERATURE_120C: + return LOG_STR("Temperature above 120C"); + case DRIVER_EVENT_TEMPERATURE_143C: + return LOG_STR("Temperature above 143C"); + case DRIVER_EVENT_TEMPERATURE_150C: + return LOG_STR("Temperature above 150C"); + case DRIVER_EVENT_TEMPERATURE_157C: + return LOG_STR("Temperature above 157C"); + default: + return LOG_STR("Unknown"); + } +} // CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first static uint8_t datagram_crc(const uint8_t *data, size_t len) { @@ -34,8 +77,19 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) { return crc; } +// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset +static int restored_index(uint8_t reg) { + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if (RESTORED_REGISTERS[i] == reg) + return static_cast(i); + } + return -1; +} + void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } +void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; } + void TMC22XXStepper::setup() { if (this->enn_pin_ != nullptr) { this->enn_pin_->setup(); @@ -53,13 +107,54 @@ void TMC22XXStepper::setup() { ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); } - // Bring write-only registers in line with their cached values and clear the reset flag + this->restore_registers(); + this->write_register(REG_GSTAT, GSTAT_FLAGS); + this->apply_config_(); + // Currents live in a write-only register, so later changes survive a driver reset through its cached value + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + if (this->diag_pin_ != nullptr) { + this->diag_pin_->setup(); + this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE); + } + // Polling notices a driver reset, and conditions that clear without a DIAG edge + this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() { + if (this->status_poll_skip_ > 0) { + this->status_poll_skip_--; + return; + } + if (!this->update_status_()) + this->status_poll_skip_ = UNREACHABLE_POLL_SKIP; + }); + + this->set_enabled(true); +} + +void TMC22XXStepper::restore_registers() { this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); this->write_register(REG_TPOWERDOWN, this->tpowerdown_); this->write_register(REG_TPWMTHRS, this->tpwmthrs_); this->write_register(REG_VACTUAL, 0); - this->write_register(REG_GSTAT, 0x07); + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if ((this->restored_written_ & (1u << i)) != 0) + this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]); + } +} +void TMC22XXStepper::apply_config_() { this->write_field(PDN_DISABLE, true); this->write_field(MSTEP_REG_SELECT, true); this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); @@ -76,28 +171,59 @@ void TMC22XXStepper::setup() { this->toff_ = *toff; this->set_microsteps(this->initial_microsteps_); - if (!std::isnan(this->initial_run_current_)) - this->set_run_current(this->initial_run_current_); - if (!std::isnan(this->initial_hold_current_)) - this->set_hold_current(this->initial_hold_current_); if (this->index_pin_ != nullptr) { // INDEX toggles on every step of the internal pulse generator that VACTUAL drives this->write_field(INDEX_OTPW, false); this->write_field(INDEX_STEP, true); - this->index_pin_->setup(); - this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); } else { // Step on both edges, so every step pin toggle is one step this->write_field(DEDGE, true); this->write_field(MULTISTEP_FILT, false); - this->step_pin_->setup(); - this->step_pin_->digital_write(false); - this->dir_pin_->setup(); - this->dir_pin_->digital_write(false); } +} - this->set_enabled(true); +bool TMC22XXStepper::update_status_() { + auto gstat = this->read_register(REG_GSTAT); + if (!gstat.has_value()) + return false; + uint32_t drv_status = 0; + // DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used + if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) { + auto value = this->read_register(REG_DRV_STATUS); + if (!value.has_value()) + return false; + drv_status = *value; + } + // GSTAT flags stay set until written with 1; they are set again while the condition lasts + if ((*gstat & GSTAT_FLAGS) != 0) + this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS); + + uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3); + // Restore before reporting, so an on_status automation for the reset sees and can change the restored settings + if ((status & (1u << DRIVER_EVENT_RESET)) != 0) { + ESP_LOGW(TAG, "Restoring settings after driver reset"); + this->restore_registers(); + // The motor stopped with the reset, ramp up again from standstill + this->current_speed_ = 0.0f; + this->set_enabled(this->enabled_); + } + uint16_t changed = status ^ this->status_; + this->status_ = status; + for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) { + if ((changed & (1u << event)) == 0) + continue; + bool active = (status & (1u << event)) != 0; + // Open load and temperature levels are informational, the driver keeps running + if (active && event < DRIVER_EVENT_OPEN_LOAD_A) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast(event)))); + } else { + ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast(event))), + active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared")); + } + this->status_callback_.call(static_cast(event), active); + } + return true; } void TMC22XXStepper::dump_config() { @@ -128,6 +254,7 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" STEP Pin: ", this->step_pin_); LOG_PIN(" DIR Pin: ", this->dir_pin_); LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -137,12 +264,29 @@ void TMC22XXStepper::dump_config() { void TMC22XXStepper::loop() { if (this->index_pin_ != nullptr) this->add_index_pulses_(); + if (this->diag_store_.triggered) { + this->diag_store_.triggered = false; + // DIAG rises on a driver error, any other rise is chip specific + if (!this->update_status_()) { + ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read"); + } else if ((this->status_ & GSTAT_FLAGS) == 0) { + this->on_diag_without_error(); + } + } + if (this->has_reached_target()) { this->high_freq_.stop(); + this->moving_ = false; } else { this->high_freq_.start(); if (!this->enabled_) this->set_enabled(true); + // An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the + // motor supply dropped are restored before the move runs on the chip defaults. + if (!this->moving_ && this->status_poll_skip_ > 0) { + this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP; + } + this->moving_ = true; } if (this->index_pin_ != nullptr) { @@ -311,8 +455,12 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { } this->status_clear_warning(); - if (uint32_t *shadow = this->shadow_register(reg)) + if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; + } else if (int index = restored_index(reg); index >= 0) { + this->restored_values_[index] = value; + this->restored_written_ |= 1u << index; + } return true; } @@ -346,7 +494,15 @@ bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { // Skip the UART timeout when the driver never answered during setup if (this->is_failed()) return false; - auto data = this->read_register(field.reg); + optional data; + int index = restored_index(field.reg); + if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) { + // Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip + // holds its defaults, and those would replace the values to restore + data = this->restored_values_[index]; + } else { + data = this->read_register(field.reg); + } if (!data.has_value()) return false; uint32_t mask = field.mask(); diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 8b0c92d3520..546a27e70d3 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/stepper/stepper.h" @@ -30,8 +31,12 @@ static constexpr uint8_t REG_TPOWERDOWN = 0x11; static constexpr uint8_t REG_TPWMTHRS = 0x13; 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; +// 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}; + static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; @@ -63,6 +68,33 @@ enum StandstillMode : uint8_t { STANDSTILL_MODE_COIL_SHORT_HS = 3, }; +/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits +/// 0-11. +enum DriverEvent : uint8_t { + DRIVER_EVENT_RESET = 0, + DRIVER_EVENT_DRIVER_ERROR, + DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE, + DRIVER_EVENT_OVERTEMPERATURE_PREWARNING, + DRIVER_EVENT_OVERTEMPERATURE, + DRIVER_EVENT_SHORT_TO_GROUND_A, + DRIVER_EVENT_SHORT_TO_GROUND_B, + DRIVER_EVENT_LOW_SIDE_SHORT_A, + DRIVER_EVENT_LOW_SIDE_SHORT_B, + DRIVER_EVENT_OPEN_LOAD_A, + DRIVER_EVENT_OPEN_LOAD_B, + DRIVER_EVENT_TEMPERATURE_120C, + DRIVER_EVENT_TEMPERATURE_143C, + DRIVER_EVENT_TEMPERATURE_150C, + DRIVER_EVENT_TEMPERATURE_157C, + DRIVER_EVENT_COUNT, +}; + +/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall. +struct DiagPinStore { + volatile bool triggered{false}; + static void gpio_intr(DiagPinStore *arg); +}; + /// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. struct IndexPulseStore { volatile int32_t pulses{0}; @@ -84,6 +116,7 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_diag_pin(InternalGPIOPin *pin) { this->diag_pin_ = pin; } void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } void set_rsense(float rsense) { this->rsense_ = rsense; } void set_vsense(bool vsense) { this->vsense_ = vsense; } @@ -97,6 +130,11 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// Called with the condition and whether it became active or cleared. + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + void set_microsteps(uint16_t microsteps); uint16_t get_microsteps(); void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } @@ -127,6 +165,15 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U virtual uint8_t expected_version() const = 0; /// Return the cached value of a write-only register, or nullptr when the register is readable. virtual uint32_t *shadow_register(uint8_t reg); + /// Write the cached write-only registers and the last written configuration registers to the driver, at setup + /// and after a driver reset. + virtual void restore_registers(); + /// Write the settings from the configuration to the readable registers. + void apply_config_(); + /// Called when DIAG rises without a driver error. + virtual void on_diag_without_error() {} + /// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read. + bool update_status_(); optional current_to_scale_(float current); float full_scale_current_(); int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } @@ -139,28 +186,36 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U GPIOPin *step_pin_{nullptr}; GPIOPin *dir_pin_{nullptr}; InternalGPIOPin *index_pin_{nullptr}; + InternalGPIOPin *diag_pin_{nullptr}; + LazyCallbackManager status_callback_; uint32_t clock_frequency_{12000000}; // Write-only registers shared by all family members, with their power-on values uint32_t ihold_irun_{0x00071703}; uint32_t tpowerdown_{0x14}; uint32_t tpwmthrs_{0}; uint32_t vactual_reg_{0}; + std::array restored_values_{}; // NaN while not configured float rsense_{NAN}; float initial_run_current_{NAN}; float initial_hold_current_{NAN}; IndexPulseStore index_store_{}; uint16_t initial_microsteps_{16}; + uint16_t status_{0}; optional vsense_{}; optional ottrim_{}; uint8_t address_{0}; uint8_t toff_{3}; uint8_t version_{0}; + uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written + uint8_t status_poll_skip_{0}; int8_t direction_{0}; + DiagPinStore diag_store_{}; bool analog_current_scale_{false}; bool enabled_{false}; bool vsense_active_{false}; bool step_state_{false}; + bool moving_{false}; HighFrequencyLoopRequester high_freq_; }; diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index ff10c79edcf..e53c3ba8233 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -6,6 +6,7 @@ stepper: step_pin: ${step_pin} dir_pin: ${dir_pin} enn_pin: ${enn_pin} + diag_pin: ${diag_pin} rsense: 110 mOhm vsense: false ottrim: 1 @@ -15,6 +16,17 @@ stepper: max_speed: 2000 steps/s acceleration: 1000 steps/s^2 deceleration: 1000 steps/s^2 + on_stall: + - stepper.report_position: + id: tmc2209_step_dir + position: 0 + - stepper.set_target: + id: tmc2209_step_dir + target: 0 + on_status: + - logger.log: + format: "Driver event %u active %d" + args: [event, active] - platform: tmc2209 id: tmc2209_serial @@ -24,6 +36,12 @@ stepper: clock_frequency: 12MHz analog_current_scale: true max_speed: 1000 steps/s + on_status: + - if: + condition: + lambda: "return event == tmc22xx::DRIVER_EVENT_OVERTEMPERATURE_PREWARNING && active;" + then: + - tmc2209.disable: tmc2209_serial esphome: on_boot: @@ -49,6 +67,17 @@ esphome: id: tmc2209_serial irun: 20 ihold: !lambda "return 5;" + - tmc2209.stallguard: + id: tmc2209_step_dir + threshold: 50 + tcool_threshold: !lambda "return 400;" + - tmc2209.coolconf: + id: tmc2209_step_dir + semin: 5 + semax: 2 + seup: 1 + sedn: 0 + seimin: true - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml index 5634c186860..18fcfbcaed0 100644 --- a/tests/components/tmc2209/test.esp32-idf.yaml +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO4 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml index 3e4ea33f7a5..fe1c6289af0 100644 --- a/tests/components/tmc2209/test.esp8266-ard.yaml +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO2 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml index 722f8f201fc..d7ceff9ee1f 100644 --- a/tests/components/tmc2209/test.rp2040-ard.yaml +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO3 enn_pin: GPIO6 index_pin: GPIO7 + diag_pin: GPIO8 packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml From 3063b76210acbbf4102910dfe6a5d10d5ce24c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 08:25:36 -1000 Subject: [PATCH 074/111] [haier_tundra] Build the IR frame on the stack (#20635) --- esphome/components/haier_tundra/haier_tundra.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp index 7a0b88d3aac..a3a5e0a8400 100644 --- a/esphome/components/haier_tundra/haier_tundra.cpp +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -28,7 +28,7 @@ void HaierTundra::transmit_(uint8_t button) { this->turbo_mode_ = false; } - remote_base::HaierData remote_state{{ + const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = { this->unit_, static_cast(this->temperature_() << 4 | this->vertical_swing_()), static_cast(this->horizontal_swing_() << 5), @@ -42,10 +42,10 @@ void HaierTundra::transmit_(uint8_t button) { static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), 0x00, button, - }}; + }; auto transmit = this->transmitter_->transmit(); - remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame)); transmit.perform(); } From a82705892800258d590e630fc8b105a00b08283d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:27:50 +1100 Subject: [PATCH 075/111] [lvgl] Change deprecated calls to current version (#20620) --- esphome/components/lvgl/color_picker.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h index e2392694f07..1949ac00de0 100644 --- a/esphome/components/lvgl/color_picker.h +++ b/esphome/components/lvgl/color_picker.h @@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound { if (left != nullptr) { lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); // A narrow hue column may be slimmer than its label. - lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); } // The text rows keep their natural height and the bars between them take what is left, @@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound { static lv_obj_t *create_box(lv_obj_t *parent) { auto *obj = lv_obj_create(parent); lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false); return obj; } @@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); - lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false); return disc; } @@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound { this->wheel_ = create_disc(container, &wheel_hue_grad); // Not clickable, so touches fall through to the wheel underneath. auto *overlay = create_disc(this->wheel_, &wheel_white_grad); - lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false); this->wheel_handle_ = lv_obj_create(this->wheel_); lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); @@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false); // PRESSED as well, so a tap takes effect without the finger moving. lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); @@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound { void add_value_label_(SliderIndex index, lv_color_t color) { auto *slider = this->sliders_[index]; - lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); auto *label = lv_label_create(slider); this->values_[index] = label; lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); @@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound { static void bubble_events(lv_obj_t *obj) { for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { auto *child = lv_obj_get_child(obj, i); - lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true); bubble_events(child); } } From 0821c84b7aea3c9c47aadb16c23aa7bb736a04eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:24 -0400 Subject: [PATCH 076/111] [core] Let a nested repeat shadow the outer iteration variable (#20634) --- esphome/automation.py | 17 +++++++- tests/components/script/common.yaml | 17 ++++++++ tests/unit_tests/test_automation.py | 65 ++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a2c10cb52d..32f423d2a34 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -906,6 +906,21 @@ async def while_action_to_code( return var +def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType: + """Rename an inherited `iteration` so the inner repeat's own counter can shadow it.""" + names = {name for _, name in args} + renamed: TemplateArgsType = [] + for type_, name in args: + if name == "iteration": + depth = 1 + while f"iteration_{depth}" in names: + depth += 1 + name = f"iteration_{depth}" + names.add(name) + renamed.append((type_, name)) + return renamed + + @register_action( "repeat", RepeatAction, @@ -929,7 +944,7 @@ async def repeat_action_to_code( actions = await build_action_list( config[CONF_THEN], cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *args], + [(cg.uint32, "iteration"), *_shadow_iteration(args)], ) for action in actions: cg.add(var.add_then_action(action)) diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 6b7290277a1..f8396e9648f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -23,6 +23,10 @@ esphome: then: - logger.log: my_script is running +globals: + - id: nested_total + type: int + script: - id: my_script mode: single @@ -71,3 +75,16 @@ script: count: 5 then: - logger.log: looping! + - repeat: + count: 2 + then: + - repeat: + count: 2 + then: + - delay: 200ms + - lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);' + - globals.set: + id: nested_total + value: !lambda |- + int total = iteration; + return total; diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fe0b1b02ed1..b56d0a2920e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -13,9 +13,13 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + LambdaAction, + RepeatAction, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + _shadow_iteration, + build_action, build_callback_automation, build_callback_automations, build_parent_callback_automation, @@ -41,7 +45,13 @@ from esphome.automation import ( ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_COUNT, + CONF_ID, + CONF_THEN, + CONF_TYPE_ID, +) from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.types import ConfigType @@ -1545,3 +1555,56 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None assert str(mock_cg.add.call_args.args[0]) == ( f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" ) + + +def test_shadow_iteration_leaves_other_names() -> None: + args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] + assert _shadow_iteration(args) == args + + +def test_shadow_iteration_renames_inherited_counter() -> None: + assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [ + (cg.uint32, "iteration_1"), + (cg.bool_, "x"), + ] + + +def test_shadow_iteration_picks_first_unused_suffix() -> None: + """A counter keeps its suffix at deeper levels; the new one takes the next free name.""" + args = [ + (cg.uint32, "iteration"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + assert _shadow_iteration(args) == [ + (cg.uint32, "iteration_2"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + + +def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType: + return { + "repeat": {CONF_COUNT: 2, CONF_THEN: then}, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction), + } + + +@pytest.mark.asyncio +async def test_nested_repeat_lambda_parameters_are_distinct() -> None: + """Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated.""" + CORE.config = {} + log = { + "lambda": Lambda('ESP_LOGD("t", "%u", iteration);'), + CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction), + } + conf = _repeat_conf( + "outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])] + ) + await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")]) + text = CORE.cpp_main_section + assert ( + "(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)" + in text + ) + assert "uint32_t iteration, uint32_t iteration)" not in text From 417ab4d2e940d54fa2d9b11eaa79946e196df1d9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 13 Oct 2026 15:00:45 -0400 Subject: [PATCH 077/111] [openthread] Add core_options for OpenThread compile-time options (#20595) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/__init__.py | 68 +++++++ esphome/components/openthread/const.py | 3 + .../openthread/config/custom_header.yaml | 19 ++ .../openthread/config/no_custom_header.yaml | 16 ++ tests/component_tests/openthread/test_init.py | 171 +++++++++++++++++- .../openthread/test.esp32-c6-idf.yaml | 2 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/openthread/config/custom_header.yaml create mode 100644 tests/component_tests/openthread/config/no_custom_header.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6fca8bfdd9a..2c278f12f49 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +import re from typing import Any from esphome import automation @@ -44,9 +45,12 @@ from esphome.core import ( coroutine_with_priority, ) import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_build from .const import ( + CONF_CORE_OPTIONS, CONF_DEVICE_TYPE, CONF_EXT_PAN_ID, CONF_FORCE_DATASET, @@ -59,6 +63,7 @@ from .const import ( CONF_PSKC, CONF_SRP_ID, CONF_TLV, + OPENTHREAD_CONFIG_PREFIX, ) CODEOWNERS = ["@mrene"] @@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [ "MTD", ] +OT_HEADER_DIR = "openthread" +OT_HEADER_FILE = "esphome_ot_config.h" +OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") + def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: @@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) +def _write_custom_header(config: ConfigType) -> None: + """Write core_options to the OpenThread custom header.""" + if not (options := config.get(CONF_CORE_OPTIONS)): + return + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True) + # IDF resolves the path relative to the project (build) directory. + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE + ) + content = ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + + "".join( + f"#define {name} {value}\n" for name, value in sorted(options.items()) + ) + ) + # PlatformIO does not track this header as an OpenThread input; the esp-idf + # toolchain does. + header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE) + if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf: + clean_build(clear_pio_cache=False) + + openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) @@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType: )(config) +def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]: + for name in options: + if ( + not name.startswith(OPENTHREAD_CONFIG_PREFIX) + or name == OPENTHREAD_CONFIG_PREFIX + ): + raise cv.Invalid( + f"'{name}' is not an OpenThread core option; names must be " + f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name", + path=[name], + ) + if not OT_CORE_OPTION_NAME.fullmatch(name): + raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name]) + return options + + +def _validate_core_option_value(value: Any) -> str: + value = cv.string_strict(value) + if not value.strip(): + raise cv.Invalid("OpenThread core option value must not be empty") + if "\n" in value or "\r" in value: + raise cv.Invalid("OpenThread core option value must be a single line") + # GCC treats a backslash followed by spaces as a line continuation too. + if value.rstrip().endswith("\\"): + raise cv.Invalid("OpenThread core option value must not end with \\") + return value + + def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: @@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All( _validate_txpower, ), cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CORE_OPTIONS): cv.All( + cv.only_on_esp32, + {cv.string_strict: _validate_core_option_value}, + _validate_core_option_names, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) + _write_custom_header(config) elif CORE.using_zephyr: # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 include_west_project("mbedtls") diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index f0274a8c9e5..bd9c8cfa1c7 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -1,3 +1,4 @@ +CONF_CORE_OPTIONS = "core_options" CONF_DEVICE_TYPE = "device_type" CONF_EXT_PAN_ID = "ext_pan_id" CONF_FORCE_DATASET = "force_dataset" @@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" + +OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_" diff --git a/tests/component_tests/openthread/config/custom_header.yaml b/tests/component_tests/openthread/config/custom_header.yaml new file mode 100644 index 00000000000..767497e9b2f --- /dev/null +++ b/tests/component_tests/openthread/config/custom_header.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + core_options: + OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1" + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" diff --git a/tests/component_tests/openthread/config/no_custom_header.yaml b/tests/component_tests/openthread/config/no_custom_header.yaml new file mode 100644 index 00000000000..df0634e98d7 --- /dev/null +++ b/tests/component_tests/openthread/config/no_custom_header.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index 7ba9bdd4bba..c0e155ec7e3 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -1,11 +1,22 @@ -"""Tests for the openthread component schema.""" +"""Tests for the openthread component.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 -from esphome.components.openthread import CONFIG_SCHEMA -from esphome.const import PlatformFramework -from esphome.core import HexInt +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, + VARIANT_ESP32C6, +) +from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE +from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX +import esphome.config_validation as cv +from esphome.const import PlatformFramework, Toolchain +from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -37,3 +48,153 @@ def test_credentials_are_sensitive( assert isinstance(validated[field], Sensitive) assert validated[field] == expected assert isinstance(validated[field], HexInt) == isinstance(expected, int) + + +def _generate_in( + tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str] +) -> tuple[Path, MagicMock]: + """Generate from ``yaml_text`` written to tmp_path, so the build directory lands there. + + Returns the header path and the mocked ``clean_build``. + """ + config_path = tmp_path / "test.yaml" + config_path.write_text(yaml_text, encoding="utf-8") + with patch("esphome.components.openthread.clean_build") as clean_build: + generate_main(config_path) + return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build + + +def test_core_options_written_to_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, _ = _generate_in( + tmp_path, + component_config_path("custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE + assert header.read_text(encoding="utf-8") == ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + "#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n" + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n" + ) + + +def test_no_header_options_no_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, clean_build = _generate_in( + tmp_path, + component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig + assert not header.exists() + clean_build.assert_not_called() + + +@pytest.mark.parametrize( + ("toolchain", "cleans"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)], +) +def test_header_change_cleans_only_on_platformio( + toolchain: Toolchain, + cleans: bool, + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = component_config_path("custom_header.yaml").read_text(encoding="utf-8") + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + assert clean_build.called is cleans + # Unchanged content: no clean on either toolchain. + CORE.reset() + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + clean_build.assert_not_called() + # A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread. + changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"') + assert changed != source + CORE.reset() + CORE.toolchain = toolchain + header, clean_build = _generate_in(tmp_path, changed, generate_main) + assert clean_build.called is cleans + assert ( + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n" + in header.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + "name", + [ + "CONFIG_OPENTHREAD_JOINER", + "openthread_config_x", + "OT_CONFIG_X", + OPENTHREAD_CONFIG_PREFIX, + ], +) +def test_core_option_without_prefix_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + "name", + ["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"], +) +def test_core_option_bad_characters_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be empty"), + (" ", "must not be empty"), + ("1\n#define X 2", "must be a single line"), + ("1\r2", "must be a single line"), + ("1 \\", "must not end with"), + ("1 \\ ", "must not end with"), + ], +) +def test_core_option_bad_value_rejected( + value: str, message: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=message): + CONFIG_SCHEMA( + { + "network_key": hex(NETWORK_KEY), + "core_options": {"OPENTHREAD_CONFIG_X": value}, + } + ) + + +def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="ESP32"): + CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}}) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 92d120e5d19..746b7151f30 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -9,3 +9,5 @@ openthread: pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 output_power: 1dBm + core_options: + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" From 918b7d255357a814327b8b7052d4cb2a3c3a9437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 078/111] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 05bf7d34408e7358411c5bd1a5e24717093d7bf5 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:40:56 +0200 Subject: [PATCH 079/111] [epaper_spi] Write pixel blocks straight into mono buffers (#20552) --- .../components/display/display_color_utils.h | 52 +++++ esphome/components/epaper_spi/colorconv.h | 19 +- esphome/components/epaper_spi/epaper_spi.cpp | 114 +++++++++++ esphome/components/epaper_spi/epaper_spi.h | 6 + .../components/epaper_spi/epaper_spi_mono.h | 1 + .../epaper_spi/epaper_spi_ssd1677.cpp | 4 +- .../epaper_spi/epaper_spi_ssd1677.h | 3 - .../epaper_spi/epaper_spi_ssd1677_gray4.h | 2 +- .../components/epaper_spi/epaper_spi_uc8179.h | 1 + esphome/components/it8951/it8951.cpp | 10 +- .../epaper_spi/display/test_draw_pixels.cpp | 193 ++++++++++++++++++ 11 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_draw_pixels.cpp diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 608642caa44..be39e2a3e2f 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -1,4 +1,6 @@ #pragma once +#include +#include #include "esphome/core/color.h" namespace esphome::display { @@ -154,4 +156,54 @@ class ColorUtil { return color; } }; + +/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */ +static constexpr uint16_t REC601_LUMA_R = 77; +static constexpr uint16_t REC601_LUMA_G = 150; +static constexpr uint16_t REC601_LUMA_B = 29; + +/** Rec.601 luma of an RGB color, for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8); +} + +/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division + * per channel. + */ +class Rgb565ToMono { + public: + explicit Rgb565ToMono(ColorOrder order) { + // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order + uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; + if (order == COLOR_ORDER_BGR) { + high = REC601_LUMA_B; + low = REC601_LUMA_R; + } else if (order == COLOR_ORDER_GRB) { + high = REC601_LUMA_G; + middle = REC601_LUMA_R; + } + // Each field value scaled to 0..255, then weighted + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + + uint8_t operator()(uint16_t value) const { + const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F]; + return ((weighted + 128u) >> 8) >= 128 ? 1 : 0; + } + + protected: + // Weighted contribution of each value of each field + std::array high_{}; + std::array middle_{}; + std::array low_{}; +}; + } // namespace esphome::display diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 0240aa5cccf..cb59538abab 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/color.h" /* Utility for converting internal \a Color RGB representation to supported IC hardware color keys @@ -13,21 +14,15 @@ namespace esphome::epaper_spi { +// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other +// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too. +using display::color_to_mono; +using display::rec601_luma; +using display::Rgb565ToMono; + /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; -/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ -constexpr uint8_t rec601_luma(Color color) { - return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); -} - -/** Map RGB color to a single monochrome bit - * - * @param color RGB color to convert from - * @return 1 = white, 0 = black - */ -constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } - /** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) * * @param color RGB color to convert from diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0c1d9b0fafb..2a2f74763f0 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -345,6 +345,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } } +namespace { +// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies +// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step +// rows. +template +void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count, + const Rgb565ToMono &to_mono) { + const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; + for (; count != 0; count--, source += 2) { + const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + if (to_mono(value)) { + *byte |= mask; + } else { + *byte &= ~mask; + } + if constexpr (SwapAxes) { + byte += byte_step; + } else if (step > 0) { + mask >>= 1; + if (mask == 0) { + mask = 0x80; + byte++; + } + } else { + mask <<= 1; + if (mask == 0) { + mask = 0x01; + byte--; + } + } + } +} +} // namespace + +// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so +// the visible part of a block and its dirty area are worked out once for the whole block. +void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, 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 bool swap = this->effective_transform_ & SWAP_XY; + const bool mirror_x = this->effective_transform_ & MIRROR_X; + const bool mirror_y = this->effective_transform_ & MIRROR_Y; + // The buffer axis a source column moves along, and the one a source row moves along + const int column_limit = swap ? this->height_ : this->width_; + const int row_limit = swap ? this->width_ : this->height_; + const bool column_mirrored = swap ? mirror_y : mirror_x; + const bool row_mirrored = swap ? mirror_x : mirror_y; + const int first_column = std::max(0, -x_start); + const int end_column = std::min(w, column_limit - x_start); + const int first_row = std::max(0, -y_start); + const int end_row = std::min(h, row_limit - y_start); + if (first_column >= end_column || first_row >= end_row) + return; + auto column_to_buffer = [&](int column) { + const int u = x_start + column; + return column_mirrored ? column_limit - 1 - u : u; + }; + auto row_to_buffer = [&](int row) { + const int v = y_start + row; + return row_mirrored ? row_limit - 1 - v : v; + }; + + const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1)); + const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1)); + const int columns = end_column - first_column; + const int rows = end_row - first_row; + const int x_low = swap ? row_low : column_low; + const int y_low = swap ? column_low : row_low; + const int x_high = x_low + (swap ? rows : columns); + const int y_high = y_low + (swap ? columns : rows); + + const Rgb565ToMono to_mono(order); + const size_t line_stride = x_offset + w + x_pad; + const size_t row_width = this->row_width_; + const int step = column_mirrored ? -1 : 1; + for (int row = first_row; row != end_row; row++) { + const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2; + const int along = column_to_buffer(first_column); + const int across = row_to_buffer(row); + const int x = swap ? across : along; + const int y = swap ? along : across; + uint8_t *byte = buffer + y * row_width + x / 8; + const uint8_t mask = 0x80u >> (x % 8); + if (swap) { + if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } else if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } + this->x_low_ = clamp_at_most(this->x_low_, x_low); + this->x_high_ = clamp_at_least(this->x_high_, x_high); + this->y_low_ = clamp_at_most(this->y_low_, y_low); + this->y_high_ = clamp_at_least(this->y_high_, y_high); +} + +// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the +// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). +void HOT EPaperBase::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 contiguous_length; + uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); + if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + contiguous_length != this->buffer_length_) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 96755a404ec..c7ef90f1160 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -107,6 +107,8 @@ 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_; } 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; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. @@ -128,6 +130,10 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); + void draw_rgb565_block_(uint8_t *buffer, 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); + // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly + virtual bool buffer_is_mono() const { return false; } /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index d0740595fc5..0f570149de4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index a9245fa1c2b..a79bbc3dc91 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - const bool direct = send_copy || this->buffer_is_plane(); + // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper + // buffer (the four-level model) returns false and packs the plane in plane_row(). + const bool direct = send_copy || this->buffer_is_mono(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index ddf3d4e0892..b8c93a1ecb8 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono { void init_comparison_frame_(); // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. size_t plane_row_length_() const { return (this->width_ + 7) / 8; } - // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. - // A subclass with a deeper buffer returns false and overrides plane_row(). - virtual bool buffer_is_plane() const { return true; } // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. virtual void plane_row(size_t y, uint8_t *out); bool reset() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index 47436d2db1d..f21ab3eb069 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { void draw_pixel_at(int x, int y, Color color) override; protected: + bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } - bool buffer_is_plane() const override { return false; } void plane_row(size_t y, uint8_t *out) override; void refresh_screen(bool partial) override; bool transfer_data() override; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index 85c0eb623ee..b12ed54ef6d 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index fe99017774d..565f6f7f8bb 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) { if (color.raw_32 == 0xFFFFFFFF) return 0x0F; // white - // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by - // 256). Rec.601 is the standard for converting SDR images to grayscale and - // spreads saturated colours across the mid-range; Rec.709 instead crams them - // against white/black where the 16 panel levels are hard to tell apart. - auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); - return quantize_8bit_to_nibble(luma); + // Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours + // across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are + // hard to tell apart. + return quantize_8bit_to_nibble(display::rec601_luma(color)); } // 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp new file mode 100644 index 00000000000..a3b47ea8fc1 --- /dev/null +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/colorconv.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel. +template class TestableDisplay : public Driver { + public: + TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) { + EXPECT_TRUE(this->init_buffer_(this->buffer_length_)); + this->reset_bounds(); + } + + void reset_bounds() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + std::vector buffer() const { + std::vector out; + for (size_t i = 0; i != this->buffer_length_; i++) + out.push_back(this->buffer_[i]); + return out; + } + + /// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented. + void split_buffer(size_t max_block) { + ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block)); + ASSERT_GT(this->buffer_.get_buffer_count(), 1u); + this->clear(); + this->reset_bounds(); + } + + std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } + + void draw_one_by_one(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) { + // The per-pixel path the fast one must match + display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + } +}; + +/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold. +static std::vector rgb565(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels; i++) { + state = state * 1103515245u + 12345u; + const uint16_t pixel = static_cast(state >> 8); + out.push_back(pixel & 0xFF); + out.push_back(pixel >> 8); + } + return out; +} + +/// Random bytes for a block of pixels, enough for 3 bytes a pixel. +static std::vector random_bytes(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels * 3; i++) { + state = state * 1103515245u + 12345u; + out.push_back(static_cast(state >> 16)); + } + return out; +} + +/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel, +/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same +/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8. +TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { + struct Block { + int x, y, w, h; + }; + const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}}; + const display::DisplayRotation rotations[] = { + display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES, + display::DISPLAY_ROTATION_270_DEGREES}; + // Swapping the axes comes from the 90 and 270 degree rotations + const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y}; + uint32_t seed = 1; + for (const auto rotation : rotations) { + for (const uint8_t mirroring : mirrorings) { + for (const bool big_endian : {false, true}) { + for (const auto &block : blocks) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + for (auto *display : {&fast, &slow}) { + display->set_transform(mirroring); + display->set_rotation(rotation); + } + // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding + const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) + << " big endian " << big_endian << " block " << block.x << "," + << block.y << " " << block.w << "x" << block.h; + EXPECT_EQ(fast.buffer(), slow.buffer()) << where; + EXPECT_EQ(fast.bounds(), slow.bounds()) << where; + } + } + } + } +} + +/// The colour order reaches the RGB565 block writer. +TEST(EPaperDrawPixels, ColourOrderIsHonoured) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + const auto data = random_bytes(21 * 13, 99); + fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); + } +} + +/// With a clipping region set the block goes through draw_pixel_at(), which honours it. +TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = rgb565(24 * 16, 7); + for (auto *display : {&fast, &slow}) + display->start_clipping(4, 4, 12, 10); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); + EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); +} + +/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too. +TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { + TestableDisplay fast(16, 4); + TestableDisplay slow(16, 4); + const auto data = rgb565(16 * 4, 11); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); +} + +/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + const Rgb565ToMono to_mono(order); + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u) << "order " << int(order); + } +} + +/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. +TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { + { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + fast.split_buffer(7); + slow.split_buffer(7); + const auto data = rgb565(24 * 16, 5); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; + EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; + } + for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = random_bytes(24 * 16, 9); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); + EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); + } +} + +} // namespace esphome::epaper_spi::testing From 8721976066be7f191ae6b481ee812d3558adb7b0 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:47:09 +0200 Subject: [PATCH 080/111] [epaper_spi] Abandon an update when the busy pin never clears (#20555) --- esphome/components/epaper_spi/epaper_spi.cpp | 21 ++- esphome/components/epaper_spi/epaper_spi.h | 9 +- .../epaper_spi/epaper_spi_t133a01.cpp | 9 ++ .../epaper_spi/epaper_spi_t133a01.h | 1 + .../epaper_spi/display/test_busy_timeout.cpp | 145 ++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_busy_timeout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 2a2f74763f0..7b962d59ea8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -131,9 +131,7 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->waiting_for_idle_start_ = millis(); -#endif this->waiting_for_idle_ = should_wait; } @@ -156,6 +154,10 @@ void EPaperBase::loop() { ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { + if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + this->abandon_update_(); + return; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -168,6 +170,20 @@ void EPaperBase::loop() { this->process_state_(); } +// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full +void EPaperBase::abandon_update_() { + ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_, + this->epaper_state_to_string_()); + this->status_set_warning(LOG_STR("Busy pin timeout")); + if (this->state_ == EPaperState::TRANSFER_DATA) + this->abort_transfer(); + // Leave the panel powered down rather than with its high-voltage drivers enabled + this->power_off(); + this->full_update_requested_ = true; + this->current_data_index_ = 0; + this->set_state_(EPaperState::IDLE); +} + /** * Process the state machine. * Typical state sequence: @@ -242,6 +258,7 @@ void EPaperBase::process_state_() { break; case EPaperState::DEEP_SLEEP: this->deep_sleep(); + this->status_clear_warning(); this->set_state_(EPaperState::IDLE); ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index c7ef90f1160..7f1167ae6a4 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -127,6 +127,7 @@ class EPaperBase : public Display, virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); + void abandon_update_(); bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); @@ -143,6 +144,8 @@ class EPaperBase : public Display, * @return true if done, false if it should be called next loop */ virtual bool transfer_data() = 0; + // Called when an update is abandoned in the middle of transfer_data(), to release anything it left open + virtual void abort_transfer() {} /** * Refresh the screen after data transfer */ @@ -193,18 +196,20 @@ class EPaperBase : public Display, // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; + uint32_t waiting_for_idle_start_{}; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE uint32_t waiting_for_idle_last_print_{}; - uint32_t waiting_for_idle_start_{}; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t update_start_time_{}; #endif // properties with specific initialisers go last - EPaperState state_{EPaperState::IDLE}; uint32_t reset_duration_{10}; + // Longer than any panel takes to refresh; past it the busy pin is assumed stuck + uint16_t busy_timeout_seconds_{120}; uint8_t full_update_every_{1}; + EPaperState state_{EPaperState::IDLE}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index bd5a4c9b6e2..585b21f807a 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() { return true; } +void EPaperT133A01::abort_transfer() { + // A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows + if (this->current_data_index_ == 0) + return; + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + void EPaperT133A01::dump_config() { EPaperBase::dump_config(); LOG_PIN(" CS Pin: ", this->cs_pin_); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 0d07fc03ae8..5d356f90b01 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase { void deep_sleep() override; bool transfer_data() override; + void abort_transfer() override; /** * Send a command (and optional data) selecting one or both controllers. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp new file mode 100644 index 00000000000..7080619b2ff --- /dev/null +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -0,0 +1,145 @@ +#include + +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + +template void loop_until_idle(T &display) { + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) + display.loop(); +} + +class TestableSSD1683 : public EPaperSSD1683 { + public: + TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_full_update_every(5); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + EPaperState state() const { return this->state_; } + uint8_t update_count() const { return this->update_count_; } + void set_update_count(uint8_t count) { this->update_count_ = count; } + + RecordingPin dc; + SwitchableBusyPin busy; +}; + +/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to +/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing +/// was drawn since, and completing it clears the warning. +TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_update_count(3); + + // Stick the pin after the data has been sent, when the dirty area has already been emptied + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::POWER_ON); + display.busy.busy = true; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_TRUE(display.status_has_warning()); + + display.busy.busy = false; + display.update(); + EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused"; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete"; + EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one"; + EXPECT_FALSE(display.status_has_warning()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + EPaperState state() const { return this->state_; } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI +/// transaction and release both chip selects, or the next command would block. +TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open"; + + display.busy.busy = true; + loop_until_idle(display); + + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "CS left asserted"; + EXPECT_TRUE(display.cs1.level) << "CS1 left asserted"; +} + +/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled. +TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { + TestableT133A01 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream + display.install(&bus); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + + display.busy.busy = true; + loop_until_idle(display); + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + + EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end()) + << "the panel was not powered off (POF) when the update was abandoned"; +} + +} // namespace esphome::epaper_spi::testing From f941fee5edb459fe17f1159800ee6294c4b4d34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:10:14 -1000 Subject: [PATCH 081/111] [homeassistant] Add sensor platform benchmarks (#20637) --- .../bench_homeassistant_sensor.cpp | 196 ++++++++++++++++++ .../components/homeassistant/benchmark.yaml | 3 + .../homeassistant/sensor/__init__.py | 13 ++ 3 files changed, 212 insertions(+) create mode 100644 tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp create mode 100644 tests/benchmarks/components/homeassistant/benchmark.yaml create mode 100644 tests/benchmarks/components/homeassistant/sensor/__init__.py diff --git a/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp new file mode 100644 index 00000000000..8ecf56ed130 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp @@ -0,0 +1,196 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR) + +#include +#include + +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Each benchmark gets its own API server so subscriptions from one do not +// stay in the list scanned by the next. The constructor installs itself as +// global_api_server, which is what HomeassistantSensor::setup() subscribes to. +class ScopedApiServer { + public: + ScopedApiServer() : previous_(api::global_api_server) {} + ~ScopedApiServer() { api::global_api_server = this->previous_; } + api::APIServer *get() { return &this->server_; } + + protected: + api::APIServer *previous_; + api::APIServer server_; +}; + +// The handler only reads the server's subscription list, so the connection is +// never started and its socket never used. +static std::unique_ptr create_connection(api::APIServer *server) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + return std::make_unique(std::make_unique(fd), server); +} + +// State strings as Home Assistant sends them. The handler writes a terminator +// one byte past the state, so the buffers are mutable and the terminator it +// writes lands on the one the literal already has. +static constexpr int kStateCount = 4; +static constexpr size_t kStateBufferSize = 16; +using StateBuffers = char[kStateCount][kStateBufferSize]; +using Messages = std::array; + +static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"}; +static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"}; +static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"}; + +static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) { + Messages msgs; + for (int i = 0; i < kStateCount; i++) { + msgs[i].entity_id = StringRef(entity_id); + if (attribute != nullptr) { + msgs[i].attribute = StringRef(attribute); + } + msgs[i].state = StringRef(states[i]); + } + return msgs; +} + +// --- One imported sensor, numeric state that changes every message --- +// Exercises: subscription match, in-place terminator, parse_number, publish_state. + +static void HomeassistantSensor_StateResponse(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse); + +// --- The same sensor with integer states such as a percentage or a count --- +// Exercises: the same path with no fractional digits to parse. Only the state +// differs from the decimal case, so the gap between the two is the parse. + +static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Integer); + +// --- Attribute import: the subscription and the message both carry an attribute --- +// Exercises: the extra attribute compare before the callback. + +static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("climate.living_room"); + sensor.set_attribute("current_temperature"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Attribute); + +// --- Sixteen imported sensors, message for the last one --- +// Exercises: the linear scan over the subscription list with a string compare +// per entry, which is what a device with many imported entities pays per update. +// The IDs share a length and differ only in the last two bytes, so every +// entry runs a near-full compare, the worst case for the scan. + +static constexpr int kSensorCount = 16; + +static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + + static constexpr const char *const ENTITY_IDS[kSensorCount] = { + "sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03", + "sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07", + "sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11", + "sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15", + }; + HomeassistantSensor sensors[kSensorCount]; + for (int i = 0; i < kSensorCount; i++) { + sensors[i].set_entity_id(ENTITY_IDS[i]); + sensors[i].setup(); + } + const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensors[kSensorCount - 1].state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions); + +// --- Non-numeric state such as "unavailable" --- +// Exercises: the failed parse, the warning the platform logs for it, and the +// NAN publish. The benchmark logger runs at the production default level with +// console output off, so a log line at DEBUG or above costs its formatting +// here, the same as on a device, and a log level change in the platform shows +// up as a delta. + +static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Unavailable); + +} // namespace esphome::homeassistant::benchmarks + +#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR diff --git a/tests/benchmarks/components/homeassistant/benchmark.yaml b/tests/benchmarks/components/homeassistant/benchmark.yaml new file mode 100644 index 00000000000..e3924a22117 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/benchmark.yaml @@ -0,0 +1,3 @@ +# The homeassistant platforms subscribe through the API server. +api: +sensor: diff --git a/tests/benchmarks/components/homeassistant/sensor/__init__.py b/tests/benchmarks/components/homeassistant/sensor/__init__.py new file mode 100644 index 00000000000..c4b3f4d8d67 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/sensor/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # In production the platform's to_code emits this define; the host build + # suppresses that to_code, and the API server needs the define for the + # state subscription path the benchmarks drive. + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + + manifest.to_code = to_code From 42341d05f9a3fda0c2afb410e8b3cc4afa65b713 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 082/111] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f2a78259973..fcdffc0c645 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 0dc1d3b5a19ddd47603dc6938b419c2997acfa66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:25:18 -1000 Subject: [PATCH 083/111] [toshiba] Move the lookup tables to PROGMEM (#20636) --- esphome/components/toshiba/toshiba.cpp | 50 +++++++++----------- tests/components/toshiba/toshiba_tests.cpp | 55 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 406a1bc55db..eae24641c40 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -3,8 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { @@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72; const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E; // RAC-PT1411HWRU Swing const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9; -const std::vector RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; -const std::vector RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; // RAC-PT1411HWRU Fan speeds const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B; constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66}; @@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E; // Hence these look-up tables. In addition, the upper nibble is used here for additional // "negative" and "fractional value" flags as required for some temperatures. // RAC-PT1411HWRU °C Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, - 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, + 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; // RAC-PT1411HWRU °F Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, - 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, - 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = { + 0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05, + 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; // RAS-2819T protocol constants const uint16_t RAS_2819T_HEADER1 = 0xC23D; @@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame // RAS-2819T temperature codes for 18-30°C -static const uint8_t RAS_2819T_TEMP_CODES[] = { +static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = { 0x10, // 18°C 0x30, // 19°C 0x20, // 20°C @@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) { return 0x40; // Default to 24°C } - return RAS_2819T_TEMP_CODES[temp_index]; + return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]); } /** @@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) { // Find the code in the temperature array for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { - if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) { return static_cast(temp_index + 18); // 18°C is the minimum } } @@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { index = static_cast(roundf(temp_adjd)); if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - code = RAC_PT1411HWRU_TEMPERATURE_F[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]); message[9] |= RAC_PT1411HWRU_FLAG_FAH; } else { - code = RAC_PT1411HWRU_TEMPERATURE_C[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]); } if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) { code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY; @@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { // Swing Mode data->reset(); data->space(TOSHIBA_PACKET_SPACE); - switch (this->swing_mode) { - case climate::CLIMATE_SWING_VERTICAL: - this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - break; - - case climate::CLIMATE_SWING_OFF: - default: - this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - } + const uint8_t *swing_frame = + this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF; + uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH]; + progmem_memcpy(swing, swing_frame, sizeof(swing)); + this->encode_(data, swing, sizeof(swing), 1); data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); @@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) { this->target_temperature = static_cast((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9; } } } else { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) { this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; } } @@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Swing mode case RAC_PT1411HWRU_SWING_HEADER: - if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) { + if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; } else { this->swing_mode = climate::CLIMATE_SWING_OFF; diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 528af888544..3c0ce6cd6ce 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); } +// The RAC-PT1411HWRU sends its main message, then a separate swing packet +// wrapped in packet spaces; both use the generic timings. +static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector &bytes) { + ASSERT_GE(actual.size(), 2u); + EXPECT_EQ(actual.front(), -10500); + EXPECT_EQ(actual.back(), -10500); + const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1); + expect_frame(frame, waveform(bytes, GENERIC), 6); +} + +TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) { + Fixture f(MODEL_RAC_PT1411HWRU_C); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}); +} + +// The temperature tables are read on the way out and searched on the way back +// in, so what one fixture sends must decode to the same state in another. +TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { + for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) { + for (int temperature = 16; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature); + Fixture tx(model); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + // The main message is sent before the swing packet + const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; + + Fixture rx(model); + remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); + } + } +} + +// The RAS-2819T table is read for the code on the way out and searched on the way back in. +TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { + for (int temperature = 18; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "temperature " << temperature); + Fixture tx(MODEL_RAS_2819T); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + + Fixture rx(MODEL_RAS_2819T); + remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); + } +} + static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { const remote_base::RawTimings raw = waveform(bytes, t); remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); From 6c24d4aa43c72962619579038a62334126244f6d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:05:16 +0000 Subject: [PATCH 084/111] Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7df6ca4fc7..a54897ef36d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From f4a6d6f18bc3252daf851e199e239666857cfb63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 085/111] [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) --- esphome/platformio/library.py | 7 +++---- esphome/platformio/prefetch.py | 4 ++++ esphome/platformio/runner.py | 11 +++++++++++ tests/unit_tests/test_platformio_prefetch.py | 13 +++++++++++++ tests/unit_tests/test_platformio_runner.py | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e0..769b86519fa 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb993..a86b5a63b97 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d0..cde79af0b68 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6d..8a60e9c3881 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a3..b108cf298ab 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] From 39ea6a32204b8c085e6f7b25c6990c93126b502d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 086/111] [audio] Bump microFLAC to v0.3.0 (#20591) --- 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 1480faa8c3f..332638e75bb 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..a490f007d94 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: From c9664d4e4282149fe2daeafefe2ee6840b56d2ad Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 087/111] [wifi] Reject unsupported WPA3 on ESP8266 (#20592) Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com> --- esphome/components/wifi/__init__.py | 7 ++ .../wifi/test_min_auth_mode.py | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/component_tests/wifi/test_min_auth_mode.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f357bf75b35..f500bc6e3f0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -344,6 +344,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -522,6 +528,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 00000000000..e0e5ca98053 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" From b43630cf8e4075d8daf6cdde01f18a471a592c90 Mon Sep 17 00:00:00 2001 From: emes30 <35312919+emes30@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:01:11 +0200 Subject: [PATCH 088/111] [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260) Co-authored-by: J. Nick Koston --- esphome/components/rp2/__init__.py | 2 ++ esphome/components/rp2_pio/__init__.py | 8 ++++---- .../components/rp2040_pio_led_strip/test.rp2350-ard.yaml | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 23346ca6f4b..1ac37f8e456 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -194,6 +194,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b876..96ed422de2d 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -7,9 +7,9 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -20,8 +20,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 00000000000..25135f1d390 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml From bb0546fc0b2633db98f53afd09d5cce81c8caacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 089/111] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 53d504137b9219b1c853beac247712f16a085176 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 090/111] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f8649f968ed..16fddadc878 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1039,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 5a78f636b99f53f01c0bb21cf3ec6b368075b6cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:50 +1300 Subject: [PATCH 091/111] Bump version to 2026.10.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a72069f8449..614b9a6fc05 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b3 +PROJECT_NUMBER = 2026.10.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 15dcce03ff9..3888f8f95d3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b3" +__version__ = "2026.10.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a97ce3a20140c2deddcbc7227926c2775f7ebc48 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 23:58:21 +0200 Subject: [PATCH 092/111] [image] Fix pillow deprecation warning (#20679) --- esphome/components/file/image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) From 6562c49686969f6c23baf5dcdb3cb73a12b6ec01 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:08:13 +0100 Subject: [PATCH 093/111] [thermostat] Allow a dry-only thermostat without cool_action (#20682) --- esphome/components/thermostat/climate.py | 17 +++++---- .../config/cooling_timer_without_action.yaml | 27 ++++++++++++++ .../thermostat/config/dry_only.yaml | 30 ++++++++++++++++ .../thermostat/test_validation.py | 36 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/thermostat/config/cooling_timer_without_action.yaml create mode 100644 tests/component_tests/thermostat/config/dry_only.yaml create mode 100644 tests/component_tests/thermostat/test_validation.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) From a0c7d754cbe037e43499283f32bc30e76de597c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 12:22:35 -1000 Subject: [PATCH 094/111] [syslog] Keep the log level to severity table in flash (#20662) --- esphome/components/syslog/esphome_syslog.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; From e265c0c0fc8e78300f8a9897585d826545dd0820 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:42 -0400 Subject: [PATCH 095/111] [core] Sync the static analysis dependency pins with the code (#20681) --- esphome/components/qr_code/__init__.py | 2 +- esphome/components/runtime_image/__init__.py | 2 +- esphome/idf_component.yml | 2 ++ platformio.ini | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..2ef48602d1b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..96f3d25b6eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY From 5c23a827b65351be1a1692bc9d5c478a245793af Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 00:35:55 +0200 Subject: [PATCH 096/111] [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 097/111] [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 098/111] [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 099/111] [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 100/111] [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 101/111] [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 102/111] [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 103/111] [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 104/111] [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 105/111] [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 106/111] [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 107/111] [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 108/111] [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 109/111] [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 110/111] [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 111/111] [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