From a07a1ab7282415f26f5e339aef83d2b615065d2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:43:34 -1000 Subject: [PATCH 01/50] [qmi8658] Keep the range name tables in flash (#20714) --- esphome/components/qmi8658/qmi8658.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 4f35d5bf365..5c9402c2018 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -1,11 +1,16 @@ #include "qmi8658.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::qmi8658 { ESPHOME_LOG_TAG(TAG, "qmi8658"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±16°/s", "±32°/s", "±64°/s", "±128°/s", "±256°/s", "±512°/s", "±1024°/s", + "±2048°/s"); + // Acceleration scale (g per LSB), indexed by accel_range_ >> 4. // Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). static constexpr float ACCEL_SCALE[] = { @@ -86,12 +91,10 @@ void QMI8658Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", - "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_ >> 4, GyroRangeStrings::LAST_INDEX))); MotionComponent::dump_config(); } From a522553e8220c4d1fb96210003c290210c1dfd42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:43:54 -1000 Subject: [PATCH 02/50] [lsm6ds] Keep the range and chip names in flash (#20718) --- esphome/components/lsm6ds/lsm6ds.cpp | 74 ++++++++++++++-------------- esphome/components/lsm6ds/lsm6ds.h | 3 +- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index b02a610c0a8..0bd14bd0875 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,15 +1,42 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::lsm6ds { ESPHOME_LOG_TAG(TAG, "lsm6ds"); -static const struct { - uint8_t who_am_i; - const char *const name; -} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; +// Indexed by the accel range field, which is not in ascending order (datasheet Table 3) +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±16g", "±4g", "±8g"); + +static const LogString *gyro_range_to_str(LSM6DSGyroRange range) { + switch (range) { + case LSM6DS_GYRO_RANGE_125: + return LOG_STR("±125dps"); + case LSM6DS_GYRO_RANGE_250: + return LOG_STR("±250dps"); + case LSM6DS_GYRO_RANGE_500: + return LOG_STR("±500dps"); + case LSM6DS_GYRO_RANGE_1000: + return LOG_STR("±1000dps"); + case LSM6DS_GYRO_RANGE_2000: + return LOG_STR("±2000dps"); + default: + return LOG_STR("unknown"); + } +} + +static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { + switch (who_am_i) { + case 0x69: + return LOG_STR("LSMDSO"); + case 0x6A: + return LOG_STR("LSM6DS3"); + default: + return nullptr; + } +} void LSM6DSComponent::setup() { MotionComponent::setup(); @@ -19,19 +46,13 @@ void LSM6DSComponent::setup() { this->mark_failed(); return; } - const char *chip_name = nullptr; - for (const auto &chip : CHIP_IDS) { - if (chip.who_am_i == who_am_i) { - chip_name = chip.name; - break; - } - } + const LogString *chip_name = chip_name_from_who_am_i(who_am_i); if (chip_name == nullptr) { ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); return; } - ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i); this->chip_name_ = chip_name; // 2. Software reset — clears all registers to defaults @@ -85,34 +106,10 @@ void LSM6DSComponent::dump_config() { ESP_LOGCONFIG(TAG, "LSM6DS IMU:\n" " Chip type: %s\n", - this->chip_name_); + LOG_STR_ARG(this->chip_name_ != nullptr ? this->chip_name_ : LOG_STR("Unknown"))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - // Accel range — index into the sensitivity table (datasheet Table 3) - static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; - - const char *gyro_str; - switch (this->gyro_range_) { - case LSM6DS_GYRO_RANGE_125: - gyro_str = "±125dps"; - break; - case LSM6DS_GYRO_RANGE_250: - gyro_str = "±250dps"; - break; - case LSM6DS_GYRO_RANGE_500: - gyro_str = "±500dps"; - break; - case LSM6DS_GYRO_RANGE_1000: - gyro_str = "±1000dps"; - break; - case LSM6DS_GYRO_RANGE_2000: - gyro_str = "±2000dps"; - break; - default: - gyro_str = "unknown"; - break; - } auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); ESP_LOGCONFIG(TAG, @@ -120,7 +117,8 @@ void LSM6DSComponent::dump_config() { " Accel data rate : %dHz\n" " Gyro range : %s\n" " Gyro data rate : %dHz", - ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX)), + accel_odr, LOG_STR_ARG(gyro_range_to_str(this->gyro_range_)), gyro_odr); } // update_data() diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 47d6f55939e..2cc282ba124 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/motion/motion_component.h" @@ -97,7 +98,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } protected: - const char *chip_name_{"Unknown"}; + const LogString *chip_name_{nullptr}; bool update_data(motion::MotionData &data) override; LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; From 39705a65e3e5a010b6ae0a6cb54b560ebeaee330 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 17:44:46 -1000 Subject: [PATCH 03/50] [bmi270] Keep the range name and scale tables in flash (#20715) --- esphome/components/bmi270/bmi270.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 37f718fb7eb..6f14598a5f4 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -2,6 +2,7 @@ #include "bmi270_config.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/progmem.h" namespace esphome::bmi270 { @@ -11,6 +12,9 @@ using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device defi ESPHOME_LOG_TAG(TAG, "bmi270"); +PROGMEM_STRING_TABLE(AccelRangeStrings, "±2g", "±4g", "±8g", "±16g"); +PROGMEM_STRING_TABLE(GyroRangeStrings, "±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"); + #if defined(USE_ARDUINO) && !defined(USE_ESP32) static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else @@ -273,11 +277,10 @@ void BMI270Component::dump_config() { return; } - static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; - static constexpr const char *const GYRO_RANGE_STRS[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; - - ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); - ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + ESP_LOGCONFIG(TAG, " Accel range : %s", + LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX))); + ESP_LOGCONFIG(TAG, " Gyro range : %s", + LOG_STR_ARG(GyroRangeStrings::get_log_str(this->gyro_range_, GyroRangeStrings::LAST_INDEX))); #ifdef USE_BMI270_BMM150 if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", @@ -315,7 +318,8 @@ bool BMI270Component::update_data(motion::MotionData &data) { } // Scale factor: LSB/g depends on range // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb - static constexpr float ACCEL_SCALE[] = { + // In PROGMEM; each read is a 32-bit load, which is safe from flash on ESP8266 + alignas(4) static constexpr float ACCEL_SCALE[] PROGMEM = { 2.0f / 32768.0f, 4.0f / 32768.0f, 8.0f / 32768.0f, @@ -329,7 +333,7 @@ bool BMI270Component::update_data(motion::MotionData &data) { // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 - static constexpr float GYRO_SCALE[] = { + alignas(4) static constexpr float GYRO_SCALE[] PROGMEM = { 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, }; static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; From 41e82b029553810f581a4de0ef2232c669fb48ab Mon Sep 17 00:00:00 2001 From: Jos van Eijndhoven Date: Thu, 15 Oct 2026 06:16:09 +0200 Subject: [PATCH 04/50] [hdmi_cec] Add HDMI-CEC bus component (part 1: bus core) (#9189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stéphane Lepin Co-authored-by: Stéphane Lepin Co-authored-by: Niko Co-authored-by: Minims Co-authored-by: Nicholas Pace Co-authored-by: Nicholas Pace <3300476+segfly@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 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/esp32/__init__.py | 2 +- esphome/components/hdmi_cec/__init__.py | 141 ++++++ esphome/components/hdmi_cec/hdmi_cec.cpp | 450 ++++++++++++++++++ esphome/components/hdmi_cec/hdmi_cec.h | 218 +++++++++ esphome/core/defines.h | 1 + tests/components/hdmi_cec/common.yaml | 18 + .../components/hdmi_cec/test.bk72xx-ard.yaml | 5 + tests/components/hdmi_cec/test.esp32-idf.yaml | 5 + .../components/hdmi_cec/test.esp8266-ard.yaml | 5 + .../components/hdmi_cec/test.rp2040-ard.yaml | 5 + 11 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hdmi_cec/__init__.py create mode 100644 esphome/components/hdmi_cec/hdmi_cec.cpp create mode 100644 esphome/components/hdmi_cec/hdmi_cec.h create mode 100644 tests/components/hdmi_cec/common.yaml create mode 100644 tests/components/hdmi_cec/test.bk72xx-ard.yaml create mode 100644 tests/components/hdmi_cec/test.esp32-idf.yaml create mode 100644 tests/components/hdmi_cec/test.esp8266-ard.yaml create mode 100644 tests/components/hdmi_cec/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 6adf2a93594..ae813fa1be2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -257,6 +257,7 @@ esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/hdc2080/* @G-Pereira @jesserockz esphome/components/hdc302x/* @joshuasing +esphome/components/hdmi_cec/* @JosVanEijndhoven esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 25a00b522c0..aae1a4c41f6 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -243,7 +243,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component - "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, hdmi_cec, opentherm, Arduino BLE libs "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py new file mode 100644 index 00000000000..c718968e201 --- /dev/null +++ b/esphome/components/hdmi_cec/__init__.py @@ -0,0 +1,141 @@ +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components.esp32 import include_builtin_idf_component +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DATA, + CONF_ID, + CONF_ON_MESSAGE, + CONF_PIN, + CONF_SOURCE, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RP2, + PLATFORM_RTL87XX, +) +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@JosVanEijndhoven"] +DOMAIN = "hdmi_cec" + +MAX_LOGICAL_ADDRESS = 15 +MAX_PAYLOAD_LENGTH = 15 +CONF_PROMISCUOUS_MODE = "promiscuous_mode" +CONF_MONITOR_MODE = "monitor_mode" +CONF_DESTINATION = "destination" +CONF_OPCODE = "opcode" + +hdmi_cec_ns = cg.esphome_ns.namespace("hdmi_cec") +HDMICEC = hdmi_cec_ns.class_("HDMICEC", cg.Component) +_PAYLOAD_SPAN = cg.std_span.template(cg.uint8.operator("const")) +_PAYLOAD_VECTOR = cg.std_vector.template(cg.uint8) +_MESSAGE_ARGS = [ + (cg.uint8, "source"), + (cg.uint8, "destination"), + (_PAYLOAD_SPAN, "data"), +] + +_request_message_callback_slot = cg.slot_counter("HDMI_CEC_MESSAGE_CALLBACK_COUNT") + +logical_address = cv.int_range(min=0, max=MAX_LOGICAL_ADDRESS) +payload = cv.All(cv.ensure_list(cv.hex_uint8_t), cv.Length(max=MAX_PAYLOAD_LENGTH)) +payload_prefix = cv.All(payload, cv.Length(min=1)) + + +def _validate_message_filter(config: ConfigType) -> ConfigType: + # The opcode is the first payload byte: fold it into the data prefix + if (opcode := config.pop(CONF_OPCODE, None)) is not None: + data = config.setdefault(CONF_DATA, [opcode]) + if data[0] != opcode: + raise cv.Invalid( + f"opcode 0x{opcode:02X} differs from the first data byte 0x{data[0]:02X}" + ) + return config + + +def _message_filter(conf: ConfigType) -> str | None: + """The C++ condition an on_message automation runs under, or None for every frame.""" + checks = [] + if (source := conf.get(CONF_SOURCE)) is not None: + checks.append(f"source == {source}") + if (destination := conf.get(CONF_DESTINATION)) is not None: + checks.append(f"destination == {destination}") + if data := conf.get(CONF_DATA): + checks.append(f"data.size() >= {len(data)}") + checks.extend(f"data[{i}] == 0x{byte:02X}" for i, byte in enumerate(data)) + return " && ".join(checks) or None + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDMICEC), + cv.Required(CONF_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_ADDRESS): logical_address, + cv.Optional(CONF_PROMISCUOUS_MODE, default=False): cv.boolean, + cv.Optional(CONF_MONITOR_MODE, default=False): cv.boolean, + cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( + { + cv.Optional(CONF_SOURCE): logical_address, + cv.Optional(CONF_DESTINATION): logical_address, + cv.Optional(CONF_OPCODE): cv.hex_uint8_t, + cv.Optional(CONF_DATA): payload_prefix, + }, + _validate_message_filter, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2, + PLATFORM_BK72XX, + PLATFORM_RTL87XX, + PLATFORM_LN882X, + ] + ), +) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + # The acknowledge bit is released from a general purpose timer, left out of the build by default + include_builtin_idf_component("esp_driver_gptimer") + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + var = cg.new_Pvariable(config[CONF_ID], pin, config[CONF_ADDRESS]) + await cg.register_component(var, config) + if config[CONF_PROMISCUOUS_MODE]: + cg.add(var.set_promiscuous_mode(True)) + if config[CONF_MONITOR_MODE]: + cg.add(var.set_monitor_mode(True)) + + for conf in config.get(CONF_ON_MESSAGE, []): + _request_message_callback_slot() + await automation.build_callback_automation( + var, + "add_on_message_callback", + _MESSAGE_ARGS, + conf, + when=_message_filter(conf), + ) + + +automation.register_apply_action( + "hdmi_cec.send", + cv.Schema( + { + cv.GenerateID(): cv.use_id(HDMICEC), + cv.Required(CONF_DESTINATION): cv.templatable(logical_address), + cv.Required(CONF_DATA): cv.templatable(payload), + } + ), + automation.ApplyCall( + "send({}, {})", + ((CONF_DESTINATION, cg.uint8), (CONF_DATA, _PAYLOAD_VECTOR)), + ), +) diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp new file mode 100644 index 00000000000..82e354dbae7 --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -0,0 +1,450 @@ +#include "hdmi_cec.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +ESPHOME_LOG_TAG(TAG, "hdmi_cec"); + +#if defined(USE_ESP8266) || defined(USE_RP2040) +// The timer interrupt of these platforms carries no argument +static HDMICEC *ack_timer_owner = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +// Bit timing from the HDMI 1.4 CEC specification, in microseconds +static constexpr uint32_t START_BIT_MIN_US = 3500; // a low period at least this long is a start bit +static constexpr uint32_t START_BIT_NOM_US = 3700; +static constexpr uint32_t START_BIT_HIGH_US = 800; +static constexpr uint32_t HIGH_BIT_MIN_US = 400; // shortest low period of a logical 1 +static constexpr uint32_t HIGH_BIT_US = 600; // nominal low period of a logical 1 +static constexpr uint32_t LOW_BIT_US = 1500; // nominal low period of a logical 0 +static constexpr uint32_t SAFE_SAMPLE_US = 1050; // the line is sampled here; a longer low period is a logical 0 +static constexpr uint32_t TOTAL_BIT_US = 2400; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_RECEIVE = TOTAL_BIT_US * 5; // also for a new initiator +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_FAIL = TOTAL_BIT_US * 3; +static constexpr uint32_t SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS = TOTAL_BIT_US * 7; +static constexpr uint32_t ABORTED_FRAME_US = TOTAL_BIT_US * 15; // no edge for this long: our frame was cut short +static constexpr uint8_t MAX_ATTEMPTS = 8; // CEC allows 5, but a busy bus counts as an attempt here too + +Frame::Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload) { + this->data_[0] = ((initiator_addr & 0xf) << 4) | (destination_addr & 0xf); + this->size_ = 1 + payload.size(); + std::copy(payload.begin(), payload.end(), this->data_.begin() + 1); +} + +void HDMICEC::setup() { + // The line is open drain with a weak pull-up: driven low to send, released to read + this->pin_->setup(); + this->isr_pin_ = this->pin_->to_isr(); + this->set_pin_input_high_(); + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + if (!this->setup_ack_timer_()) { + ESP_LOGE(TAG, "Failed to set up the acknowledge timer"); + this->mark_failed(); + return; + } + this->pin_->attach_interrupt(HDMICEC::gpio_isr_s, this, gpio::INTERRUPT_ANY_EDGE); + // loop() only has work once the interrupt queues a frame or send() queues one + this->disable_loop(); +} + +void HDMICEC::dump_config() { + ESP_LOGCONFIG(TAG, + "HDMI-CEC:\n" + " Address: 0x%X\n" + " Monitor mode: %s\n" + " Promiscuous mode: %s", + this->address_, YESNO(this->monitor_mode_), YESNO(this->promiscuous_mode_)); + LOG_PIN(" Pin: ", this->pin_); +} + +void HDMICEC::loop() { + const uint8_t dropped = atomic_get(this->dropped_frames_); + if (dropped != this->reported_dropped_frames_) { + const uint8_t count = dropped - this->reported_dropped_frames_; + ESP_LOGW(TAG, "%u received frame%s dropped (queue full or oversized)", count, + count == 1 ? LOG_STR_LITERAL("") : LOG_STR_LITERAL("s")); + this->reported_dropped_frames_ = dropped; + } + // One inbound frame per pass keeps the time spent in the automations bounded + if (const Frame *frame = this->frames_queue_.front()) { +#ifdef ESPHOME_LOG_HAS_DEBUG + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); +#endif +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); +#endif + this->frames_queue_.pop_front(); + } + // A frame being sent stays at the front of the queue until its attempts are over + if (!this->send_queue_.is_empty()) { + this->transmit_message_(); + } + if (this->frames_queue_.is_empty() && this->send_queue_.is_empty()) { + this->disable_loop(); + } +} + +bool HDMICEC::send(uint8_t source, uint8_t destination, std::span payload) { + if (this->monitor_mode_) { + ESP_LOGW(TAG, "Monitor mode, not sending 0x%X->0x%X", source, destination); + return false; + } + if (source > 0xf || destination > 0xf) { + ESP_LOGE(TAG, "Send abort: logical address 0x%X->0x%X out of range", source, destination); + return false; + } + if (payload.size() > Frame::MAX_PAYLOAD_LENGTH) { + ESP_LOGE(TAG, "Send abort 0x%X->0x%X: payload of %zu bytes exceeds %zu", source, destination, payload.size(), + Frame::MAX_PAYLOAD_LENGTH); + return false; + } + Frame *frame = this->send_queue_.back(); + if (frame == nullptr) { + ESP_LOGW(TAG, "Send queue full, frame 0x%X->0x%X dropped", source, destination); + return false; + } + *frame = Frame(source, destination, payload); + this->send_queue_.push_back(); + this->enable_loop(); + return true; +} + +void HDMICEC::transmit_message_() { + const uint32_t now = micros(); + const uint32_t since_last_edge = now - atomic_get(this->last_edge_us_); + if (this->transmitting_) { + // The interrupt sees our own frame; wait for its end of message bit, or give up when the + // bus went quiet without one + if (!atomic_get(this->eom_received_) && since_last_edge < ABORTED_FRAME_US) { + return; + } + this->transmitting_ = false; + atomic_set(this->eom_received_, false); // the end of our own frame is not a foreign one + const Frame *frame = this->send_queue_.front(); + // A broadcast is acknowledged by nobody; a directed frame on every byte + const bool bytes_ok = this->n_bytes_received_ == frame->size(); + const bool acks_ok = this->n_acks_received_ == (frame->is_broadcast() ? 0 : frame->size()); + if (bytes_ok && acks_ok) { + ESP_LOGD(TAG, "Send frame success in %u attempt%s", this->transmit_attempts_, + this->transmit_attempts_ > 1 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL("")); + } else if (!bytes_ok) { + ESP_LOGD(TAG, "Send frame incorrect on attempt %u, saw %u bytes but expected %zu", this->transmit_attempts_, + this->n_bytes_received_, frame->size()); + } else { + ESP_LOGD(TAG, "Send frame not acknowledged on attempt %u", this->transmit_attempts_); + } + this->finish_attempt_(bytes_ok && acks_ok); + } else if (atomic_get(this->eom_received_)) { + // Another device finished a frame; the standard asks a new initiator to wait after it + this->required_idle_period_ = SIGNAL_FREE_TIME_AFTER_RECEIVE; + atomic_set(this->eom_received_, false); + } + + const Frame *frame = this->send_queue_.front(); + // An edge within the signal free time means the bus is in use or not yet free for us; a start + // bit refused by a held line drives no edge, so that attempt is timed on its own + if (frame == nullptr || since_last_edge < this->required_idle_period_ || + now - this->last_attempt_us_ < this->required_idle_period_) { + return; + } + + this->last_attempt_us_ = now; + this->transmit_attempts_++; +#ifdef ESPHOME_LOG_HAS_DEBUG + if (this->transmit_attempts_ == 1) { + char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; + ESP_LOGD(TAG, "Sending: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); + } +#endif + // The interrupt counts the bytes and acknowledge bits of our own frame + this->n_bytes_received_ = 0; + this->n_acks_received_ = 0; + atomic_set(this->eom_received_, false); + this->transmitting_ = true; + if (!this->transmit_frame_(frame)) { + // The line was held, or another initiator sent at the same time + this->transmitting_ = false; + this->finish_attempt_(false); + } + // Otherwise the acknowledge result arrives through the interrupt +} + +void HDMICEC::finish_attempt_(bool sent_ok) { + this->required_idle_period_ = sent_ok ? SIGNAL_FREE_TIME_AFTER_XMIT_SUCCESS : SIGNAL_FREE_TIME_AFTER_XMIT_FAIL; + if (!sent_ok) { + if (this->transmit_attempts_ < MAX_ATTEMPTS) { + return; // the signal free time has to pass before the retry + } + ESP_LOGW(TAG, "Frame not sent after %u attempts, dropped", this->transmit_attempts_); + } + this->transmit_attempts_ = 0; + this->send_queue_.pop_front(); +} + +bool HDMICEC::transmit_frame_(const Frame *frame) { + if (!this->send_start_bit_()) { + return false; + } + const auto bytes = frame->bytes(); + for (size_t byte_inx = 0; byte_inx < bytes.size(); byte_inx++) { + const uint8_t current_byte = bytes[byte_inx]; + // Most significant bit first. The four initiator address bits watch for a collision with + // another initiator that started at the same moment; a lower address wins by holding the line + for (int8_t i = 7; i >= 0; i--) { + const bool bit_value = (current_byte >> i) & 0x1; + if (!this->send_bit_(bit_value) && byte_inx == 0 && i >= 4) { + ESP_LOGD(TAG, "Bus collision on sending initiator address 0x%X on attempt %u", frame->initiator_addr(), + this->transmit_attempts_); + return false; + } + } + // End of message bit, then the acknowledge bit which the receiver may pull low + this->send_bit_(byte_inx == bytes.size() - 1); + this->send_bit_(true); + } + return true; +} + +bool HDMICEC::send_start_bit_() { + if (!this->isr_pin_.digital_read()) { + ESP_LOGD(TAG, "Bus occupied before start bit on attempt %u", this->transmit_attempts_); + return false; + } + this->set_pin_output_low_(); + delay_microseconds_safe(START_BIT_NOM_US); + // Release the line and check that nobody else keeps it low + this->set_pin_input_high_(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + bool value = this->isr_pin_.digital_read(); + delay_microseconds_safe(START_BIT_HIGH_US / 2); + value &= this->isr_pin_.digital_read(); + if (!value) { + ESP_LOGD(TAG, "Bus collision on sending start bit on attempt %u", this->transmit_attempts_); + } + return value; +} + +bool HDMICEC::send_bit_(bool bit_value) { + const uint32_t start_us = micros(); + this->set_pin_output_low_(); + delay_microseconds_safe(bit_value ? HIGH_BIT_US : LOW_BIT_US); + this->set_pin_input_high_(); + bool clear = true; + if (bit_value) { + // Sample in the middle of the safe sample period: a line still low means another initiator + // is sending a logical 0 at the same time + uint32_t elapsed = micros() - start_us; + if (elapsed < SAFE_SAMPLE_US) { + delay_microseconds_safe(SAFE_SAMPLE_US - elapsed); + } + clear = this->isr_pin_.digital_read(); + } + const uint32_t elapsed = micros() - start_us; + if (elapsed < TOTAL_BIT_US) { + delay_microseconds_safe(TOTAL_BIT_US - elapsed); + } + return clear; +} + +void IRAM_ATTR HDMICEC::send_ack_() { + // The acknowledge has to start right at this falling edge; the timer releases the line + this->set_pin_output_low_(); + this->start_ack_timer_(); +} + +#if defined(USE_ESP32) +bool HDMICEC::setup_ack_timer_() { + gptimer_config_t config = { + .clk_src = GPTIMER_CLK_SRC_DEFAULT, + .direction = GPTIMER_COUNT_UP, + .resolution_hz = 1000000, // one tick per microsecond + }; + if (gptimer_new_timer(&config, &this->ack_timer_) != ESP_OK) { + return false; + } + gptimer_event_callbacks_t callbacks = {.on_alarm = HDMICEC::ack_timer_isr_s}; + // The alarm reloads the count to zero itself, so the interrupt only has to start the timer + gptimer_alarm_config_t alarm = { + .alarm_count = LOW_BIT_US, .reload_count = 0, .flags = {.auto_reload_on_alarm = true}}; + return gptimer_register_event_callbacks(this->ack_timer_, &callbacks, this) == ESP_OK && + gptimer_set_alarm_action(this->ack_timer_, &alarm) == ESP_OK && gptimer_enable(this->ack_timer_) == ESP_OK; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { gptimer_start(this->ack_timer_); } + +bool IRAM_ATTR HDMICEC::ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg) { + auto *self = static_cast(arg); + self->set_pin_input_high_(); + gptimer_stop(timer); + return false; // no task to wake +} + +#elif defined(USE_ESP8266) +static constexpr uint32_t TIMER1_TICKS_PER_US = 5; // TIM_DIV16 of the 80 MHz clock + +bool HDMICEC::setup_ack_timer_() { + ack_timer_owner = this; + timer1_isr_init(); + timer1_attachInterrupt(HDMICEC::ack_timer_isr_s); + timer1_enable(TIM_DIV16, TIM_EDGE, TIM_SINGLE); // a write arms one shot + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { timer1_write(LOW_BIT_US * TIMER1_TICKS_PER_US); } + +void IRAM_ATTR HDMICEC::ack_timer_isr_s() { ack_timer_owner->set_pin_input_high_(); } + +#elif defined(USE_RP2040) +bool HDMICEC::setup_ack_timer_() { + this->ack_alarm_ = hardware_alarm_claim_unused(false); + if (this->ack_alarm_ < 0) { + return false; // every hardware alarm is taken + } + ack_timer_owner = this; + hardware_alarm_set_callback(this->ack_alarm_, HDMICEC::ack_timer_isr_s); + return true; +} + +void IRAM_ATTR HDMICEC::start_ack_timer_() { + hardware_alarm_set_target(this->ack_alarm_, make_timeout_time_us(LOW_BIT_US)); +} + +void IRAM_ATTR HDMICEC::ack_timer_isr_s(uint alarm_num) { ack_timer_owner->set_pin_input_high_(); } + +#else +bool HDMICEC::setup_ack_timer_() { return true; } + +// Without a one-shot timer the interrupt holds the line itself +void IRAM_ATTR HDMICEC::start_ack_timer_() { + delay_microseconds_safe(LOW_BIT_US); + this->set_pin_input_high_(); +} +#endif + +void IRAM_ATTR HDMICEC::gpio_isr_s(HDMICEC *self) { self->gpio_isr_(); } + +void IRAM_ATTR HDMICEC::gpio_isr_() { + const uint32_t now = micros(); + const bool level = this->isr_pin_.digital_read(); + if (level == this->prev_pin_level_) { + return; // a mode change of the pin, not a bus edge + } + this->prev_pin_level_ = level; + atomic_set(this->last_edge_us_, now); + + if (!level) { + // Falling edge: the start of a low period + if (now - this->last_falling_edge_us_ > START_BIT_NOM_US + TOTAL_BIT_US) { + // The bus was quiet; whatever was gathered belongs to an aborted frame + this->reset_receiver_(); + } + this->last_falling_edge_us_ = now; + if (this->recv_ack_queued_) { + // Acknowledge by stretching this low period + this->send_ack_(); + this->recv_ack_queued_ = false; + } + return; + } + + // Rising edge: the low period tells the bit value + const uint32_t pulse_duration = now - this->last_falling_edge_us_; + if (pulse_duration > START_BIT_MIN_US) { + this->reset_receiver_(); + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + // Without a free slot the frame is neither acknowledged nor delivered, so its initiator retries + this->recv_frame_ = this->frames_queue_.back(); + if (this->recv_frame_ != nullptr) { + this->recv_frame_->clear(); + } + return; + } + if (pulse_duration < HIGH_BIT_MIN_US / 4) { + return; // glitch + } + const bool value = pulse_duration <= SAFE_SAMPLE_US; + // Our own frame is on the bus until its end of message; send() may give it any initiator address + const bool own_frame = this->transmitting_ && !atomic_get(this->eom_received_); + + switch (this->receiver_state_) { + case ReceiverState::RECEIVER_STATE_IDLE: + break; + + case ReceiverState::RECEIVER_STATE_RECEIVING_BYTE: + this->recv_byte_ = (this->recv_byte_ << 1) | value; + if (++this->recv_bit_counter_ == 8) { + if (this->recv_length_++ == 0) { + this->recv_header_ = this->recv_byte_; + } + if (this->recv_frame_ != nullptr && !this->recv_frame_->push_back(this->recv_byte_)) { + this->recv_frame_ = nullptr; // longer than the bus allows: corrupt, do not deliver it + } + this->recv_bit_counter_ = 0; + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM; + } + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_EOM: + this->recv_eom_ = value; + // A frame for us is acknowledged on the next bit, unless there is no room for it (so its + // initiator retries) or it is our own, which includes a polling message from a device + // claiming our logical address. A polling message for us (header only) needs no room, and + // goes unacknowledged only when nobody holds the address + if (!this->monitor_mode_ && !own_frame && (this->recv_frame_ != nullptr || (value && this->recv_length_ == 1)) && + (this->recv_header_ & 0xf) == this->address_ && (this->recv_header_ >> 4) != this->address_) { + this->recv_ack_queued_ = true; + } + this->receiver_state_ = ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK; + break; + + case ReceiverState::RECEIVER_STATE_WAITING_FOR_ACK: + if (own_frame) { + // A low acknowledge bit means the addressed device took the byte + // (for a broadcast it means some device refused it) + this->n_bytes_received_++; + if (!value) { + this->n_acks_received_++; + } + } + if (!this->recv_eom_) { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_RECEIVING_BYTE; + break; + } + atomic_set(this->eom_received_, true); // publishes the tally above to loop() + // The application only gets frames that are not our own and not a polling message (header + // only), which the acknowledge answered already, and that are addressed to us unless + // promiscuous + const uint8_t destination = this->recv_header_ & 0xf; + const bool wanted = this->recv_length_ > 1 && !own_frame && (this->recv_header_ >> 4) != this->address_ && + (this->promiscuous_mode_ || destination == 0xf || destination == this->address_); + if (!wanted) { + // nothing to deliver + } else if (this->recv_frame_ == nullptr) { + atomic_set(this->dropped_frames_, atomic_get(this->dropped_frames_) + 1); + } else { + this->frames_queue_.push_back(); + this->enable_loop_soon_any_context(); + } + this->reset_receiver_(); + break; + } +} + +void IRAM_ATTR HDMICEC::reset_receiver_() { + this->receiver_state_ = ReceiverState::RECEIVER_STATE_IDLE; + this->recv_bit_counter_ = 0; + this->recv_length_ = 0; + this->recv_ack_queued_ = false; + this->recv_frame_ = nullptr; +} + +} // namespace esphome::hdmi_cec diff --git a/esphome/components/hdmi_cec/hdmi_cec.h b/esphome/components/hdmi_cec/hdmi_cec.h new file mode 100644 index 00000000000..d001a7ae23b --- /dev/null +++ b/esphome/components/hdmi_cec/hdmi_cec.h @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +#ifdef USE_ESP32 +#include "driver/gptimer.h" +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome::hdmi_cec { + +// Release/acquire store and load. The read-modify-write forms of std::atomic are not available on +// every target (the ESP8266 has none), so a writer computes the new value itself; that is safe here +// because every atomic in this component has exactly one writer. +template ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic &atom, T value) { + atom.store(value, std::memory_order_release); +} +template ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic &atom) { + return atom.load(std::memory_order_acquire); +} + +/// One CEC bus message: a header byte with the initiator and destination addresses, then up to +/// 15 payload bytes of which the first is the opcode. Fixed storage, so the receive interrupt +/// never allocates. +class Frame { + public: + static constexpr size_t MAX_LENGTH = 16; // HDMI-CEC 1.4 + static constexpr size_t MAX_PAYLOAD_LENGTH = MAX_LENGTH - 1; + + Frame() = default; + /// The payload is at most MAX_PAYLOAD_LENGTH bytes; send() checks that + Frame(uint8_t initiator_addr, uint8_t destination_addr, std::span payload); + + ESPHOME_ALWAYS_INLINE uint8_t initiator_addr() const { return this->data_[0] >> 4; } + ESPHOME_ALWAYS_INLINE uint8_t destination_addr() const { return this->data_[0] & 0xf; } + ESPHOME_ALWAYS_INLINE bool is_broadcast() const { return this->destination_addr() == 0xf; } + ESPHOME_ALWAYS_INLINE size_t size() const { return this->size_; } + std::span bytes() const { return {this->data_.data(), this->size_}; } + /// The bytes after the header, starting with the opcode + std::span payload() const { return this->bytes().subspan(1); } + ESPHOME_ALWAYS_INLINE void clear() { this->size_ = 0; } + /// False when the frame is full already; the bus allows no more + ESPHOME_ALWAYS_INLINE bool push_back(uint8_t data) { + if (this->size_ == MAX_LENGTH) { + return false; + } + this->data_[this->size_++] = data; + return true; + } + + protected: + std::array data_{}; + uint8_t size_{0}; +}; + +/// A queue of frames between one producer and one consumer, for instance the receive interrupt and +/// loop(). The indices run freely and each is written by one side only, so no lock is needed. +/// back() hands out a slot to fill and push_back() commits it; front() and pop_front() do the same +/// on the reading side. +template class FrameRingBuffer { + static_assert((SIZE & (SIZE - 1)) == 0, "SIZE must be a power of two"); + + public: + ESPHOME_ALWAYS_INLINE Frame *front() { return this->is_empty() ? nullptr : this->slot_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE void pop_front() { this->advance_(this->front_inx_); } + ESPHOME_ALWAYS_INLINE Frame *back() { return this->count_() == SIZE ? nullptr : this->slot_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE void push_back() { this->advance_(this->back_inx_); } + ESPHOME_ALWAYS_INLINE bool is_empty() const { return this->count_() == 0; } + + protected: + ESPHOME_ALWAYS_INLINE uint8_t count_() const { + return static_cast(atomic_get(this->back_inx_) - atomic_get(this->front_inx_)); + } + ESPHOME_ALWAYS_INLINE Frame *slot_(const std::atomic &inx) { return &this->store_[atomic_get(inx) % SIZE]; } + ESPHOME_ALWAYS_INLINE void advance_(std::atomic &inx) { + atomic_set(inx, static_cast(atomic_get(inx) + 1)); + } + + std::array store_{}; + std::atomic front_inx_{0}; + std::atomic back_inx_{0}; +}; + +/// The HDMI-CEC bus on one GPIO pin. The receive interrupt decodes the edges of every frame, +/// including those this device sends, and queues complete frames for loop(); loop() runs the +/// on_message automations and transmits queued frames, one blocking bit-banged attempt per pass. +class HDMICEC : public Component { + public: + HDMICEC(InternalGPIOPin *pin, uint8_t address) : pin_(pin), address_(address) {} + void setup() override; + void dump_config() override; + void loop() override; + + void set_promiscuous_mode(bool promiscuous) { this->promiscuous_mode_ = promiscuous; } + void set_monitor_mode(bool monitor_mode) { this->monitor_mode_ = monitor_mode; } +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + /// Called with the initiator, the destination and the payload (opcode first) of every received + /// frame; the payload is only valid during the call + template void add_on_message_callback(F &&callback) { + this->message_callbacks_.add(std::forward(callback)); + } +#endif + + /// Queue a frame for transmission; false when the queue is full or in monitor mode + bool send(uint8_t source, uint8_t destination, std::span payload); + bool send(uint8_t destination, std::span payload) { + return this->send(this->address_, destination, payload); + } + /// For lambdas: id(cec).send(0x0, {0x44, 0x41}); + bool send(uint8_t destination, std::initializer_list payload) { + return this->send(destination, std::span(payload.begin(), payload.size())); + } + + protected: + enum class ReceiverState : uint8_t { + RECEIVER_STATE_IDLE, + RECEIVER_STATE_RECEIVING_BYTE, + RECEIVER_STATE_WAITING_FOR_EOM, + RECEIVER_STATE_WAITING_FOR_ACK, + }; + + // Receive side, in the interrupt + static void gpio_isr_s(HDMICEC *self); + void gpio_isr_(); + void reset_receiver_(); + /// Pull the line low for the acknowledge bit of a frame addressed to us + void send_ack_(); + // A hardware one-shot timer releases the line after the acknowledge bit (gptimer on the ESP32, + // timer 1 on the ESP8266, a hardware alarm on the RP2040), so the interrupt returns at once; + // without one the interrupt holds the line for the 1.5 ms itself + bool setup_ack_timer_(); + void start_ack_timer_(); +#ifdef USE_ESP32 + static bool ack_timer_isr_s(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, void *arg); +#endif +#ifdef USE_ESP8266 + static void ack_timer_isr_s(); +#endif +#ifdef USE_RP2040 + static void ack_timer_isr_s(uint alarm_num); +#endif + + // Transmit side, in loop() + void transmit_message_(); + /// Keep the frame for another attempt, or drop it once the attempts are used up + void finish_attempt_(bool sent_ok); + /// Drive the frame onto the line; false when another initiator sent at the same time + bool transmit_frame_(const Frame *frame); + bool send_start_bit_(); + /// Send one bit; false when a logical 1 found the line held low by another initiator + bool send_bit_(bool bit_value); + ESPHOME_ALWAYS_INLINE void set_pin_input_high_() { this->isr_pin_.pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); } + ESPHOME_ALWAYS_INLINE void set_pin_output_low_() { + this->isr_pin_.pin_mode(gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN); + this->isr_pin_.digital_write(false); + } + + static constexpr size_t QUEUE_SIZE = 4; + // Each queue is 70 bytes, so two single-byte fields follow each to fill the alignment gap + FrameRingBuffer frames_queue_; // received, interrupt to loop() + uint8_t recv_bit_counter_{0}; + uint8_t recv_byte_{0}; + FrameRingBuffer send_queue_; // to send, both ends on loop() + uint8_t transmit_attempts_{0}; + uint8_t reported_dropped_frames_{0}; +#ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT + StaticCallbackManager)> + message_callbacks_; +#endif + InternalGPIOPin *pin_; + ISRInternalGPIOPin isr_pin_; +#ifdef USE_ESP32 + gptimer_handle_t ack_timer_{nullptr}; +#endif +#ifdef USE_RP2040 + int ack_alarm_{-1}; +#endif + + // The word-sized fields come first and the single-byte ones form one run, so nothing is padded + std::atomic last_edge_us_{0}; // the bus is busy until this is some time ago; interrupt writes + Frame *recv_frame_{nullptr}; // interrupt only; null when the queue was full or the frame too long + uint32_t last_falling_edge_us_{0}; // interrupt only + uint32_t last_attempt_us_{0}; // loop() only; a refused start bit drives no edge, so it is timed here + uint16_t required_idle_period_{0}; // loop() only + + // Shared with the interrupt. The tally of our own frame is written by the interrupt while + // transmitting_ is set and no end of message has passed yet; loop() reads it once eom_received_ + // says the frame is over + std::atomic eom_received_{false}; // set for every frame, ours or not + std::atomic dropped_frames_{0}; + uint8_t n_bytes_received_{0}; + uint8_t n_acks_received_{0}; + bool transmitting_{false}; + + // Receive state, interrupt only + uint8_t recv_header_{0}; // kept outside the frame so a dropped frame can still be judged + uint8_t recv_length_{0}; + ReceiverState receiver_state_{ReceiverState::RECEIVER_STATE_IDLE}; + bool recv_eom_{false}; + bool recv_ack_queued_{false}; + bool prev_pin_level_{true}; + + uint8_t address_; // logical address, 0xf when only listening + bool promiscuous_mode_{false}; + bool monitor_mode_{false}; +}; + +} // namespace esphome::hdmi_cec diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3b499594944..0e020975e84 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -154,6 +154,7 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2 #define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT diff --git a/tests/components/hdmi_cec/common.yaml b/tests/components/hdmi_cec/common.yaml new file mode 100644 index 00000000000..5ee1764e2e9 --- /dev/null +++ b/tests/components/hdmi_cec/common.yaml @@ -0,0 +1,18 @@ +hdmi_cec: + id: cec + pin: ${cec_pin} # HDMI pin 13, with HDMI pin 17 as ground + address: 0x5 + promiscuous_mode: true + on_message: + - opcode: 0x8F # Give Device Power Status + then: + - hdmi_cec.send: + destination: !lambda return source; + data: [0x90, 0x01] # Report Power Status: standby + - opcode: 0x8C # Give Device Vendor ID + then: + - lambda: |- + id(cec).send(0xf, {0x87, 0x00, 0x09, 0xb0}); + - data: [0x44, 0x41] # User Control Pressed: Volume Up + then: + - logger.log: "HDMI-CEC Volume Up" diff --git a/tests/components/hdmi_cec/test.bk72xx-ard.yaml b/tests/components/hdmi_cec/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..e4709b1d900 --- /dev/null +++ b/tests/components/hdmi_cec/test.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: P6 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp32-idf.yaml b/tests/components/hdmi_cec/test.esp32-idf.yaml new file mode 100644 index 00000000000..0210a56b6b6 --- /dev/null +++ b/tests/components/hdmi_cec/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO4 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.esp8266-ard.yaml b/tests/components/hdmi_cec/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml diff --git a/tests/components/hdmi_cec/test.rp2040-ard.yaml b/tests/components/hdmi_cec/test.rp2040-ard.yaml new file mode 100644 index 00000000000..7b3f9ffec0e --- /dev/null +++ b/tests/components/hdmi_cec/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + cec_pin: GPIO14 + +packages: + hdmi_cec: !include common.yaml From ea6d5499b3b52ee81ee44e0ece768de3fdc2c6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 19:24:00 -1000 Subject: [PATCH 05/50] [lsm6ds] Add the LSM6DSO and match all chips to their datasheets (#20733) --- esphome/components/lsm6ds/lsm6ds.cpp | 19 ++++++++---- esphome/components/lsm6ds/lsm6ds.h | 28 +++++++++++------ tests/components/lsm6ds/__init__.py | 6 ++++ tests/components/lsm6ds/test_chip_ids.cpp | 38 +++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 tests/components/lsm6ds/__init__.py create mode 100644 tests/components/lsm6ds/test_chip_ids.cpp diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index 0bd14bd0875..e34735a4160 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -27,17 +27,23 @@ static const LogString *gyro_range_to_str(LSM6DSGyroRange range) { } } -static const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { +const LogString *chip_name_from_who_am_i(uint8_t who_am_i) { switch (who_am_i) { - case 0x69: - return LOG_STR("LSMDSO"); - case 0x6A: + case LSM6DS_WHO_AM_I_LSM6DS3: return LOG_STR("LSM6DS3"); + case LSM6DS_WHO_AM_I_LSM6DS3TR_C: + return LOG_STR("LSM6DS3TR-C"); + case LSM6DS_WHO_AM_I_LSM6DSO: + return LOG_STR("LSM6DSO"); default: return nullptr; } } +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i) { + return who_am_i == LSM6DS_WHO_AM_I_LSM6DS3 ? 16 : 256; +} + void LSM6DSComponent::setup() { MotionComponent::setup(); uint8_t who_am_i = 0; @@ -54,6 +60,7 @@ void LSM6DSComponent::setup() { } ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", LOG_STR_ARG(chip_name), who_am_i); this->chip_name_ = chip_name; + this->temperature_sensitivity_ = temperature_sensitivity_from_who_am_i(who_am_i); // 2. Software reset — clears all registers to defaults if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { @@ -185,12 +192,12 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { // Temperature (lazy — only read if a listener is registered) // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when // temperature is not needed. - // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + // Formula: T(°C) = raw / sensitivity + 25.0, sensitivity in LSB/°C per chip (0 LSB at 25 °C) if (!this->temperature_callback_.empty()) { uint8_t raw_t[2]; if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); - float temperature = (temp_raw / 256.0f) + 25.0f; + float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 2cc282ba124..d754b08a252 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -9,15 +9,19 @@ namespace esphome::lsm6ds { // ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; -static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS -static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS -static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC -static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 -static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable -static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA -static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB -static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) -static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB +// WHO_AM_I values; the LSM6DS3 (DocID026899) and LSM6DSO (DS12140) use the same registers as the TR-C here +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3 = 0x69; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DS3TR_C = 0x6A; +static constexpr uint8_t LSM6DS_WHO_AM_I_LSM6DSO = 0x6C; // also the LSM6DSO32, whose accel ranges differ (unsupported) +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB // Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) static const uint8_t LSM6DS_BURST_LEN = 12; @@ -83,6 +87,11 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── +/// Chip name for a WHO_AM_I value, or nullptr when the chip is not supported. +const LogString *chip_name_from_who_am_i(uint8_t who_am_i); +/// Temperature sensitivity in LSB/°C for a supported WHO_AM_I value, from the chip's datasheet. +uint16_t temperature_sensitivity_from_who_am_i(uint8_t who_am_i); + class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; @@ -105,6 +114,7 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + uint16_t temperature_sensitivity_{256}; // LSB/°C: 16 on the LSM6DS3, 256 on the others LazyCallbackManager temperature_callback_{}; }; diff --git a/tests/components/lsm6ds/__init__.py b/tests/components/lsm6ds/__init__.py new file mode 100644 index 00000000000..3b260b767b0 --- /dev/null +++ b/tests/components/lsm6ds/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # lsm6ds declares i2c on its motion platform, which the harness does not walk + manifest.dependencies = manifest.dependencies + ["i2c", "motion"] diff --git a/tests/components/lsm6ds/test_chip_ids.cpp b/tests/components/lsm6ds/test_chip_ids.cpp new file mode 100644 index 00000000000..4b990d69b90 --- /dev/null +++ b/tests/components/lsm6ds/test_chip_ids.cpp @@ -0,0 +1,38 @@ +#include + +#include + +#include "esphome/components/lsm6ds/lsm6ds.h" + +namespace esphome::lsm6ds::testing { + +// WHO_AM_I values and temperature sensitivities from ST's datasheets: +// LSM6DS3 DocID026899, LSM6DS3TR-C DocID030071, LSM6DSO DS12140 + +static const char *name_of(uint8_t who_am_i) { + const LogString *name = chip_name_from_who_am_i(who_am_i); + return name == nullptr ? nullptr : LOG_STR_ARG(name); +} + +TEST(LSM6DSChipIds, Lsm6ds3) { + EXPECT_STREQ(name_of(0x69), "LSM6DS3"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x69), 16); +} + +TEST(LSM6DSChipIds, Lsm6ds3trC) { + EXPECT_STREQ(name_of(0x6A), "LSM6DS3TR-C"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6A), 256); +} + +TEST(LSM6DSChipIds, Lsm6dso) { + EXPECT_STREQ(name_of(0x6C), "LSM6DSO"); + EXPECT_EQ(temperature_sensitivity_from_who_am_i(0x6C), 256); +} + +TEST(LSM6DSChipIds, UnknownIsRejected) { + EXPECT_EQ(name_of(0x00), nullptr); + EXPECT_EQ(name_of(0x6B), nullptr); + EXPECT_EQ(name_of(0xFF), nullptr); +} + +} // namespace esphome::lsm6ds::testing From 8d963bde5b87bde4e4e2f7642a2226917d3cdde8 Mon Sep 17 00:00:00 2001 From: Pawel Puterla Date: Thu, 15 Oct 2026 08:02:33 +0200 Subject: [PATCH 06/50] [openthread] Add ESP32 OpenThread Border Router support (#18104) Co-authored-by: Copilot App <223556219+Copilot@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 Co-authored-by: J. Nick Koston --- .github/workflows/ci.yml | 2 +- esphome/components/const/__init__.py | 1 + .../ethernet/ethernet_component_esp32.cpp | 10 +- esphome/components/mdns/__init__.py | 15 +- esphome/components/mdns/mdns_component.h | 4 +- esphome/components/mdns/mdns_esp32.cpp | 10 +- esphome/components/network/util.cpp | 30 ++ esphome/components/network/util.h | 11 +- esphome/components/openthread/__init__.py | 260 +++++++++-- esphome/components/openthread/const.py | 1 + esphome/components/openthread/openthread.cpp | 89 +++- esphome/components/openthread/openthread.h | 70 ++- .../components/openthread/openthread_esp.cpp | 438 +++++++++++++++--- .../wifi/wifi_component_esp_idf.cpp | 10 +- esphome/core/defines.h | 9 +- sdkconfig.defaults.esp32c6 | 1 + sdkconfig.defaults.esp32s3 | 25 + .../mbedtls_tls_openthread_border_router.yaml | 24 + tests/component_tests/esp32/test_esp32.py | 5 +- .../mdns/test_service_enable_disable.py | 13 + .../openthread/config/border_router.yaml | 24 + .../openthread/config/border_router_rcp.yaml | 27 ++ tests/component_tests/openthread/test_init.py | 6 +- .../openthread/test_openthread.py | 324 +++++++++++++ .../test-border-router.esp32-c5-idf.yaml | 20 + .../test-border-router.esp32-c6-idf.yaml | 20 + .../openthread/test-rcp.esp32-idf.yaml | 19 + .../openthread/test-rcp.esp32-s3-idf.yaml | 20 + 28 files changed, 1329 insertions(+), 159 deletions(-) create mode 100644 tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml create mode 100644 tests/component_tests/openthread/config/border_router.yaml create mode 100644 tests/component_tests/openthread/config/border_router_rcp.yaml create mode 100644 tests/component_tests/openthread/test_openthread.py create mode 100644 tests/components/openthread/test-border-router.esp32-c5-idf.yaml create mode 100644 tests/components/openthread/test-border-router.esp32-c6-idf.yaml create mode 100644 tests/components/openthread/test-rcp.esp32-idf.yaml create mode 100644 tests/components/openthread/test-rcp.esp32-s3-idf.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33534f1f63a..d97352f65f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1003,7 +1003,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM --grep USE_OPENTHREAD tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 49f55ab47f0..dcf40d2a07c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BLUE_CHANNEL = "blue_channel" +CONF_BORDER_ROUTER = "border_router" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1568d37b114..d0df293fd4e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ETHERNET) && defined(USE_ESP32) +#include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -731,14 +732,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(this->eth_netif_, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index bc431599248..8c28603e78c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv @@ -188,10 +189,16 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def _openthread_srp_registers_services() -> bool: + """Without border_router, the OpenThread SRP client registers the services instead of mDNS.""" + openthread_config = CORE.config.get(CONF_OPENTHREAD) + return openthread_config is not None and CONF_BORDER_ROUTER not in openthread_config + + def request_service_enable_disable() -> bool: """Request MDNSComponent::set_service_enabled() support. - ESP32 only, not with OpenThread. Returns True when the + ESP32 only, not with the OpenThread SRP client. Returns True when the USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. Public API for external components. Do not remove. @@ -201,7 +208,7 @@ def request_service_enable_disable() -> bool: mdns_config is None or mdns_config[CONF_DISABLED] or not CORE.is_esp32 - or CONF_OPENTHREAD in CORE.config + or _openthread_srp_registers_services() ): return False cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") @@ -288,13 +295,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # With both wifi and ethernet, the _esphomelib "network" TXT value follows the - # connected interface. OpenThread hands its services to the SRP client once, + # connected interface. The OpenThread SRP client registers the services once, # so it keeps the fixed value. if ( CONF_API in CORE.config and CONF_WIFI in CORE.config and CONF_ETHERNET in CORE.config - and CONF_OPENTHREAD not in CORE.config + and not _openthread_srp_registers_services() ): from esphome.components import network diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 06d10d2d818..1ff92f07f34 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -126,8 +126,8 @@ class MDNSComponent final : public Component #ifndef USE_MDNS_STORE_SERVICES #error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" #endif -#ifdef USE_OPENTHREAD -#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#ifdef USE_OPENTHREAD_SRP +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with the OpenThread SRP client" #endif /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index d84a2551202..7953eea3091 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { ESPHOME_LOG_TAG(TAG, "mdns"); -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP static esp_err_t add_service(const MDNSService &service) { // Stack buffer for up to 16 txt records, heap fallback for more SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); @@ -30,7 +30,7 @@ static esp_err_t add_service(const MDNSService &service) { #endif static void register_esp32(MDNSComponent *comp, StaticVector &services) { -#ifdef USE_OPENTHREAD +#ifdef USE_OPENTHREAD_SRP // OpenThread handles service registration via SRP client // Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent #else @@ -62,7 +62,7 @@ static void register_esp32(MDNSComponent *comp, StaticVectoris_ready()) { @@ -87,7 +87,7 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr ESP_LOGW(TAG, "Service %s not found", service_type); return false; } -#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD_SRP #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT bool MDNSComponent::publish_network_txt_(const char *network) { @@ -110,7 +110,7 @@ void MDNSComponent::setup() { } void MDNSComponent::on_shutdown() { -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 11485fdcf03..f0a4c59dca8 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -2,10 +2,40 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #ifdef USE_NETWORK namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +ESPHOME_LOG_TAG(TAG, "network"); + +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses) { + esp_ip6_addr_t ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + size_t count = esp_netif_get_all_ip6(netif, ip6s); + // Slot 0 holds the IPv4 address + if (count > addresses.size() - 1) { + // Warn once per interface + static esp_netif_t *warned[3]{}; + for (auto &slot : warned) { + if (slot == netif) { + break; + } + if (slot == nullptr) { + slot = netif; + ESP_LOGW(TAG, "%s has %zu IPv6 addresses, only %zu are reported", esp_netif_get_desc(netif), count, + addresses.size() - 1); + break; + } + } + count = addresses.size() - 1; + } + for (size_t i = 0; i < count; i++) { + addresses[i + 1] = IPAddress(&ip6s[i]); + } +} +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 57c5a66833b..7b3cceaea21 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -18,9 +18,17 @@ #ifdef USE_OPENTHREAD #include "esphome/components/openthread/openthread.h" #endif +#ifdef USE_ESP32 +#include "esp_netif.h" +#endif namespace esphome::network { +#if defined(USE_ESP32) && USE_NETWORK_IPV6 +/// Copies the IPv6 addresses of an esp_netif into `addresses[1..]`, warning once when some do not fit. +void fill_ip6_addresses(esp_netif_t *netif, IPAddresses &addresses); +#endif + // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that uses a previous interface for NAT). @@ -44,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return true; #endif -#ifdef USE_OPENTHREAD +#if defined(USE_OPENTHREAD) && !defined(USE_OPENTHREAD_BORDER_ROUTER) + // A border router is reached over its Wi-Fi backbone, so the Thread link alone does not count if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected()) return true; #endif diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2c278f12f49..181bcea1d55 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,22 +1,33 @@ import re from typing import Any -from esphome import automation +from esphome import automation, pins import esphome.codegen as cg +from esphome.components import wifi +from esphome.components.const import CONF_BORDER_ROUTER from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32H4, VARIANT_ESP32H21, + VARIANT_ESP32S2, + VARIANT_ESP32S3, VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, only_on_variant, + request_software_coexistence, require_mbedtls_ecp, + require_mbedtls_tls, require_mbedtls_tls_extras, require_mbedtls_tls_server, + require_vfs_dir, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage @@ -26,13 +37,25 @@ from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, CONF_CHANNEL, CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, CONF_FRAMEWORK, + CONF_HARDWARE_UART, CONF_ID, + CONF_INVERTED, CONF_LOG_LEVEL, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, CONF_OUTPUT_POWER, + CONF_RESET_PIN, + CONF_RX_PIN, + CONF_TX_PIN, CONF_USE_ADDRESS, + CONF_WIFI, KEY_CORE, KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, @@ -61,6 +84,7 @@ from .const import ( CONF_PAN_ID, CONF_POLL_PERIOD, CONF_PSKC, + CONF_RCP, CONF_SRP_ID, CONF_TLV, OPENTHREAD_CONFIG_PREFIX, @@ -71,10 +95,6 @@ DOMAIN = "openthread" AUTO_LOAD = ["network"] -# Wi-fi / Bluetooth / Thread coexistence isn't implemented at this time -# TODO: Doesn't conflict with wifi if you're using another ESP as an RCP (radio coprocessor), but this isn't implemented yet -CONFLICTS_WITH = ["wifi"] - IDF_TO_OT_LOG_LEVEL = { "NONE": "NONE", "ERROR": "CRIT", @@ -89,6 +109,43 @@ CONF_DEVICE_TYPES = [ "MTD", ] +# Variants with both Wi-Fi and an 802.15.4 radio +NATIVE_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32C5, + VARIANT_ESP32C6, +} +# Variants with Wi-Fi, for an external RCP +RCP_BORDER_ROUTER_VARIANTS = { + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +} +# ESP-IDF options a border router needs on top of the plain OpenThread ones +BORDER_ROUTER_SDKCONFIG_OPTIONS: dict[str, bool | int] = { + "CONFIG_OPENTHREAD_PLATFORM_NETIF": True, + "CONFIG_OPENTHREAD_BORDER_ROUTER": True, + # The advertising proxy registers the Thread devices' services with mDNS; the SDK default of 10 fills up fast + "CONFIG_MDNS_MAX_SERVICES": 20, + "CONFIG_LWIP_IPV6_FORWARD": True, + "CONFIG_LWIP_IPV6_NUM_ADDRESSES": 12, + "CONFIG_LWIP_MULTICAST_PING": True, + "CONFIG_LWIP_NETIF_STATUS_CALLBACK": True, + "CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT": True, + "CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT": True, + "CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM": True, + "CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM": True, + "CONFIG_MDNS_MULTIPLE_INSTANCE": True, + "CONFIG_MBEDTLS_CMAC_C": True, + "CONFIG_MBEDTLS_SSL_PROTO_DTLS": True, + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE": True, + "CONFIG_MBEDTLS_ECJPAKE_C": True, +} OT_HEADER_DIR = "openthread" OT_HEADER_FILE = "esphome_ot_config.h" OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") @@ -108,10 +165,18 @@ def _validate_txpower(value: Any) -> int | float: return value # Unsupported, fail later with clear error +def _get_rcp(config: ConfigType) -> ConfigType | None: + return config.get(CONF_BORDER_ROUTER, {}).get(CONF_RCP) + + def set_sdkconfig_options(config: ConfigType) -> None: - # and expose options for using SPI/UART RCPs - add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", True) + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) is not None + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_NATIVE", not rcp) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_RADIO_SPINEL_UART", rcp) + if not rcp: + add_idf_sdkconfig_option("CONFIG_IEEE802154_ENABLED", True) # There is a conflict if the logger's uart also uses the default UART, which is seen as a watchdog failure on "ot_cli" add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CLI", False) @@ -123,45 +188,55 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; - # setting one under sdkconfig_options keeps TLS in the build automatically. - # The crypto platform uses AES-CCM and deterministic ECDSA directly. - require_mbedtls_tls_server() + # OpenThread's SRP and border-router code uses these mbedTLS crypto features directly. require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") ) + # Widens the TLS role only where TLS is compiled in: a commissioner or border agent enabled + # through sdkconfig_options needs the DTLS server side too + require_mbedtls_tls_server() + if border_router: + require_mbedtls_tls() if not config.get(CONF_TLV): - if pan_id := config.get(CONF_PAN_ID): + if (pan_id := config.get(CONF_PAN_ID)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) if channel := config.get(CONF_CHANNEL): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_CHANNEL", channel) - if network_key := config.get(CONF_NETWORK_KEY): + if (network_key := config.get(CONF_NETWORK_KEY)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_MASTERKEY", f"{network_key:032x}" ) - if network_name := config.get(CONF_NETWORK_NAME): + if (network_name := config.get(CONF_NETWORK_NAME)) is not None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_NAME", network_name) if (ext_pan_id := config.get(CONF_EXT_PAN_ID)) is not None: add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:X}".lower() + "CONFIG_OPENTHREAD_NETWORK_EXTPANID", f"{ext_pan_id:016x}" ) if (mesh_local_prefix := config.get(CONF_MESH_LOCAL_PREFIX)) is not None: add_idf_sdkconfig_option( "CONFIG_OPENTHREAD_MESH_LOCAL_PREFIX", f"{mesh_local_prefix}".lower() ) if (pskc := config.get(CONF_PSKC)) is not None: - add_idf_sdkconfig_option( - "CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:X}".lower() - ) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PSKC", f"{pskc:032x}") - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", True) - add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_DNS64_CLIENT", not border_router) + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT", not border_router) + if not border_router: + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_SRP_CLIENT_MAX_SERVICES", 5) + + if border_router: + # Border routing follows the Wi-Fi link + wifi.request_wifi_connect_state_listener() + for name, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + add_idf_sdkconfig_option(name, value) + if not rcp: + # The native 802.15.4 radio shares the RF frontend with Wi-Fi + request_software_coexistence() # TODO: Add support for synchronized sleepy end devices (SSED) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) @@ -196,6 +271,30 @@ openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) + +def _validate_reset_pin(value: object) -> ConfigType: + value = {CONF_NUMBER: value} if not isinstance(value, dict) else dict(value) + value.setdefault(CONF_INVERTED, True) + return pins.internal_gpio_output_pin_schema(value) + + +# The pin registry rejects a pin used twice, so RX, TX and reset need no check of their own +_RCP_SCHEMA = cv.Schema( + { + cv.Required(CONF_RX_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_TX_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_BAUD_RATE, default=460800): cv.int_range(min=9600), + cv.Optional(CONF_RESET_PIN): _validate_reset_pin, + } +) + +_BORDER_ROUTER_SCHEMA = cv.Schema({cv.Optional(CONF_RCP): _RCP_SCHEMA}) + + +def _validate_border_router(value: object) -> ConfigType: + return _BORDER_ROUTER_SCHEMA({} if value is None else value) + + _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, @@ -227,21 +326,44 @@ def _validate(config: ConfigType) -> ConfigType: raise cv.Invalid( f"{CONF_POLL_PERIOD} can only be used with {CONF_DEVICE_TYPE}: MTD" ) + if CONF_BORDER_ROUTER in config and device_type != "FTD": + raise cv.Invalid("OpenThread Border Router requires 'device_type: FTD'.") return config -def _require_vfs_select(config: ConfigType) -> ConfigType: - """Register VFS select requirement during config validation.""" +def _require_vfs(config: ConfigType) -> ConfigType: + """Register VFS requirements during config validation.""" # OpenThread uses esp_vfs_eventfd which requires VFS select support (ESP32 only) if CORE.is_esp32: require_vfs_select() + if _get_rcp(config) is not None: + require_vfs_dir() return config def _validate_platform(config: ConfigType) -> ConfigType: if CORE.using_zephyr: return config + # Older 5.5 releases keep the first lock mutex when a timed wait for the second one runs out + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 5), + extra_message="OpenThread requires ESP-IDF 5.5.5 or newer; older 5.5 releases can deadlock the OpenThread lock", + )(config) + if CONF_BORDER_ROUTER in config: + variant = get_esp32_variant() + if _get_rcp(config) is not None: + if variant not in RCP_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router RCP mode requires a Wi-Fi-capable ESP32 variant." + ) + elif variant not in NATIVE_BORDER_ROUTER_VARIANTS: + raise cv.Invalid( + "OpenThread Border Router with a native radio requires an ESP32 variant " + "with both Wi-Fi and IEEE 802.15.4 support. " + "Configure 'rcp:' to use an external OpenThread Radio Co-Processor." + ) + return config return only_on_variant( supported=[ VARIANT_ESP32C5, @@ -288,8 +410,8 @@ def _validate_tlv_hex(value: Any) -> str: raise cv.Invalid("TLV must have an even number of hex characters") try: raw = bytes.fromhex(s) - except ValueError as e: - raise cv.Invalid(f"TLV must be valid hex: {e}") from e + except ValueError as err: + raise cv.Invalid(f"TLV must be valid hex: {err}") from err if len(raw) > 254: # sizeof(otOperationalDatasetTlvs::mTlvs) raise cv.Invalid(f"TLV too long ({len(raw)} bytes, max 254)") return s @@ -301,6 +423,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(OpenThreadComponent), cv.GenerateID(CONF_SRP_ID): cv.declare_id(OpenThreadSrpComponent), cv.GenerateID(CONF_MDNS_ID): cv.use_id(MDNSComponent), + cv.Optional(CONF_BORDER_ROUTER): _validate_border_router, cv.Optional(CONF_DEVICE_TYPE, default="FTD"): cv.one_of( *CONF_DEVICE_TYPES, upper=True ), @@ -324,11 +447,11 @@ CONFIG_SCHEMA = cv.All( cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), _validate_platform, _validate, - _require_vfs_select, + _require_vfs, ) -def _final_validate(_: ConfigType) -> None: +def _final_validate(config: ConfigType) -> ConfigType: full_config = fv.full_config.get() network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): @@ -337,6 +460,43 @@ def _final_validate(_: ConfigType) -> None: "Please set `enable_ipv6: true` in the `network` configuration." ) + wifi_config = full_config.get(CONF_WIFI) + if CONF_BORDER_ROUTER not in config: + if wifi_config is not None: + raise cv.Invalid( + "OpenThread can only be used with Wi-Fi when 'border_router:' is configured." + ) + else: + if wifi_config is None: + raise cv.Invalid("OpenThread Border Router requires a Wi-Fi STA backbone.") + if CONF_AP in wifi_config: + raise cv.Invalid( + "OpenThread Border Router does not support Wi-Fi AP or fallback AP mode." + ) + if not wifi_config.get(CONF_NETWORKS): + raise cv.Invalid( + "OpenThread Border Router requires at least one configured Wi-Fi STA network." + ) + if not wifi_config[CONF_ENABLE_ON_BOOT]: + raise cv.Invalid( + "OpenThread Border Router requires Wi-Fi 'enable_on_boot: true'." + ) + if _get_rcp(config) is not None: + if full_config.get("uart") is not None: + raise cv.Invalid( + "OpenThread RCP uses ESP-IDF's dedicated Spinel UART driver and " + "cannot currently be combined with the ESPHome UART component." + ) + if ( + (logger_config := full_config.get(CONF_LOGGER)) is not None + and logger_config.get(CONF_HARDWARE_UART) == "UART1" + and logger_config.get(CONF_BAUD_RATE, 0) > 0 + ): + raise cv.Invalid( + "OpenThread RCP reserves UART1; configure the logger to use USB " + "or another UART." + ) + if ( (esp32_config := full_config.get(PLATFORM_ESP32)) is not None and (fw_config := esp32_config.get(CONF_FRAMEWORK)) is not None @@ -346,6 +506,8 @@ def _final_validate(_: ConfigType) -> None: ot_log_level = IDF_TO_OT_LOG_LEVEL.get(log_level, log_level) add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_LOG_LEVEL_{ot_log_level}", True) + return config + FINAL_VALIDATE_SCHEMA = _final_validate @@ -361,6 +523,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: + border_router = CONF_BORDER_ROUTER in config + rcp = _get_rcp(config) + # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") @@ -368,24 +533,47 @@ async def to_code(config: ConfigType) -> None: require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") + if border_router: + cg.add_define("USE_OPENTHREAD_BORDER_ROUTER") + else: + # The SRP client registers the mDNS services with the Thread network + cg.add_define("USE_OPENTHREAD_SRP") + if rcp is not None: + cg.add_define("USE_OPENTHREAD_RCP_UART") if config.get(CONF_FORCE_DATASET): cg.add_define("USE_OPENTHREAD_FORCE_DATASET") if tlv := config.get(CONF_TLV): cg.add_define("USE_OPENTHREAD_TLVS", tlv) - # OpenThread SRP needs access to mDNS services after setup - enable_mdns_storage() + if not border_router: + # OpenThread SRP needs access to mDNS services after setup + enable_mdns_storage() - ot = cg.new_Pvariable(config[CONF_ID]) + if rcp is None: + ot = cg.new_Pvariable(config[CONF_ID]) + else: + reset_pin = ( + await cg.gpio_pin_expression(rcp[CONF_RESET_PIN]) + if CONF_RESET_PIN in rcp + else cg.nullptr + ) + ot = cg.new_Pvariable( + config[CONF_ID], + rcp[CONF_BAUD_RATE], + rcp[CONF_RX_PIN], + rcp[CONF_TX_PIN], + reset_pin, + ) add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) - srp = cg.new_Pvariable(config[CONF_SRP_ID]) - mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) - cg.add(srp.set_mdns(mdns_component)) - await cg.register_component(srp, config) + if not border_router: + mdns_component = await cg.get_variable(config[CONF_MDNS_ID]) + srp = cg.new_Pvariable(config[CONF_SRP_ID]) + cg.add(srp.set_mdns(mdns_component)) + await cg.register_component(srp, config) if (output_power := config.get(CONF_OUTPUT_POWER)) is not None: cg.add(ot.set_output_power(output_power)) diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index bd9c8cfa1c7..b5481e7b7aa 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -9,6 +9,7 @@ CONF_NETWORK_KEY = "network_key" CONF_PAN_ID = "pan_id" CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" +CONF_RCP = "rcp" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c4ab27689e7..92599603bef 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,6 +16,11 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_err.h" +#include "esp_openthread_border_router.h" +#endif + ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -25,6 +30,14 @@ OpenThreadComponent *global_openthread_component = // NOLINT(cppcoreguidelines- OpenThreadComponent::OpenThreadComponent() { global_openthread_component = this; } +#ifdef USE_OPENTHREAD_RCP_UART +OpenThreadComponent::OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, + GPIOPin *rcp_reset_pin) + : rcp_baud_rate_(rcp_baud_rate), rcp_rx_pin_(rcp_rx_pin), rcp_tx_pin_(rcp_tx_pin), rcp_reset_pin_(rcp_reset_pin) { + global_openthread_component = this; +} +#endif + void OpenThreadComponent::dump_config() { ESP_LOGCONFIG(TAG, "Open Thread:"); #if CONFIG_OPENTHREAD_FTD @@ -43,6 +56,24 @@ void OpenThreadComponent::dump_config() { if (this->output_power_.has_value()) { ESP_LOGCONFIG(TAG, " Output power: %" PRId8 "dBm", *this->output_power_); } +#ifdef USE_OPENTHREAD_RCP_UART + ESP_LOGCONFIG(TAG, + " Radio: External RCP\n" + " Transport: UART1 at %" PRIu32 " baud\n" + " RX Pin: GPIO%u\n" + " TX Pin: GPIO%u", + this->rcp_baud_rate_, this->rcp_rx_pin_, this->rcp_tx_pin_); + if (this->rcp_reset_pin_ != nullptr) { + LOG_PIN(" RCP Reset Pin: ", this->rcp_reset_pin_); + } +#else + ESP_LOGCONFIG(TAG, " Radio: Native 802.15.4"); +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + ESP_LOGCONFIG(TAG, " Border Router: ENABLED\n" + " Backbone: Wi-Fi STA\n" + " Experimental: YES"); +#endif } void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) { @@ -87,6 +118,7 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } +#ifdef USE_OPENTHREAD_SRP void OpenThreadComponent::defer_factory_reset_external_callback() { ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); this->defer([this]() { this->factory_reset_external_callback_(); }); @@ -125,7 +157,13 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { - if (global_openthread_component->is_failed()) { + // A start-up failure on the OpenThread task reaches the component as a deferred mark_failed(); + // the task itself may already be gone, and acquire() would abort instead of waiting + if (global_openthread_component->is_failed() +#ifdef USE_ESP32 + || global_openthread_component->is_task_exited() +#endif + ) { this->mark_failed(); return; } @@ -234,10 +272,11 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } +#endif + bool OpenThreadComponent::teardown() { - // Nothing was started - if (this->is_failed()) - return true; + // A setup() failure marks the stage complete itself; a failure with the task running, such as a + // border router that did not come up or an RCP that stays silent, still needs the stop below switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -248,31 +287,48 @@ bool OpenThreadComponent::teardown() { ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); return false; } - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); + // The OpenThread task may have completed on its own in the meantime; never move back from there + TeardownStage expected = TeardownStage::TEARDOWN_STAGE_NOT_STARTED; + if (!this->teardown_stage_.compare_exchange_strong(expected, TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS)) { + break; + } otInstance *instance = lock.get_instance(); + // Stop mirroring address changes onto the netif. Every multicast group and address the + // stack drops from here on would otherwise become an MLD message that is sent, through + // the event loop, after the clean-up has deleted the glue's packet queue, which aborts. + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGW(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + } + this->border_router_started_ = false; + } +#endif +#ifdef USE_OPENTHREAD_SRP + ESP_LOGV(TAG, "Clear SRP"); otSrpClientClearHostAndServices(instance); otSrpClientBuffersFreeAllServices(instance); +#endif if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable Thread during teardown"); } if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); } - // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on - // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; ESP_LOGV(TAG, "Stop OpenThread"); - int error = this->openthread_stop_(); - if (error != 0) { + if (int error = this->openthread_stop_(); error != 0) { + // Nothing will mark the stage complete for us; do not wait out the teardown timeout ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Unreachable today; teardown is synchronous on both platforms. Kept for a future - // graceful-exit path, or a platform whose teardown() cannot be made synchronous. + // On ESP32 the OpenThread task is leaving its main loop and cleaning up; it marks the + // stage complete when done. Zephyr completes synchronously in openthread_stop_(). break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -281,13 +337,17 @@ bool OpenThreadComponent::teardown() { return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED; } -void OpenThreadComponent::on_factory_reset(std::function callback) { +void OpenThreadComponent::on_factory_reset(const std::function &callback) { +#ifndef USE_OPENTHREAD_SRP + // Nothing registered with an SRP server to remove first + callback(); +#else if (this->is_failed()) { // No SRP registration to remove; reset right away callback(); return; } - this->factory_reset_external_callback_ = std::move(callback); + this->factory_reset_external_callback_ = callback; ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; InstanceLock lock = InstanceLock::acquire(); @@ -299,6 +359,7 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { return; } ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +#endif } void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 8599304c89a..4041e21894e 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -4,7 +4,11 @@ #include "esphome/components/mdns/mdns_component.h" #include "esphome/components/network/ip_address.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esphome/components/wifi/wifi_component.h" +#endif #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include #include @@ -25,22 +29,47 @@ enum class TeardownStage : uint8_t { TEARDOWN_STAGE_COMPLETED, }; +#ifdef USE_OPENTHREAD_BORDER_ROUTER +class OpenThreadComponent final : public Component, public wifi::WiFiConnectStateListener { +#else class OpenThreadComponent final : public Component { +#endif public: OpenThreadComponent(); +#ifdef USE_OPENTHREAD_RCP_UART + OpenThreadComponent(uint32_t rcp_baud_rate, uint8_t rcp_rx_pin, uint8_t rcp_tx_pin, GPIOPin *rcp_reset_pin); +#endif ~OpenThreadComponent(); void dump_config() override; void setup() override; +#ifdef USE_OPENTHREAD_BORDER_ROUTER + void loop() override; + /// Border routing follows the Wi-Fi backbone; loop() does the work and sleeps in between + void on_wifi_connect_state(StringRef ssid, std::span bssid) override { this->enable_loop(); } +#endif bool teardown() override; - float get_setup_priority() const override { return setup_priority::WIFI; } + float get_setup_priority() const override { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + // After every network interface, whichever order network.priority gives them: the backbone netif has to exist + return setup_priority::BEFORE_CONNECTION; +#else + return setup_priority::WIFI; +#endif + } bool is_connected() const { return this->connected_; } - /// Returns true once esp_openthread_start() has completed and the OT lock is usable. + /// Returns true once the OpenThread stack is initialized and the OT lock is usable. bool is_lock_initialized() const { return this->lock_initialized_; } +#ifdef USE_ESP32 + /// True once the OpenThread task has torn the stack down; its lock never comes back + bool is_task_exited() const { return this->task_exited_; } +#endif network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); - void on_factory_reset(std::function callback); + void on_factory_reset(const std::function &callback); +#ifdef USE_OPENTHREAD_SRP void defer_factory_reset_external_callback(); +#endif /// Returns nullptr when no explicit use_address is configured and the address is /// derived at runtime from the device name (see network::get_use_address_to()). @@ -55,25 +84,54 @@ class OpenThreadComponent final : public Component { void set_output_power(int8_t output_power) { this->output_power_ = output_power; } void set_connected(bool connected) { this->connected_ = connected; } static void on_state_changed(otChangedFlags flags, void *context); +#ifdef USE_OPENTHREAD_RCP_UART + static void rcp_failure_handler(); +#endif protected: /** Apply Link Mode settings (incl poll period). * Callers running outside the OpenThread task must hold InstanceLock. */ void apply_linkmode_(otInstance *instance); +#ifdef USE_ESP32 + void ot_main_(); + void mark_task_failed_(const LogString *reason); +#endif +#ifdef USE_OPENTHREAD_RCP_UART + void reset_rcp_(bool recovery_attempt); +#endif std::optional get_omr_address_(InstanceLock &lock); otInstance *get_openthread_instance_(); int openthread_stop_(); +#ifdef USE_OPENTHREAD_SRP std::function factory_reset_external_callback_; +#endif #if CONFIG_OPENTHREAD_MTD uint32_t poll_period_{0}; #endif std::optional output_power_{}; std::atomic lock_initialized_{false}; - // Only ever written from teardown(), on the main task -- no atomic needed. - TeardownStage teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; + std::atomic teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; std::atomic connected_{false}; +#ifdef USE_ESP32 + std::atomic task_exited_{false}; +#endif +#ifdef USE_OPENTHREAD_RCP_UART + uint32_t rcp_baud_rate_{0}; + uint8_t rcp_rx_pin_{0}; + uint8_t rcp_tx_pin_{0}; + GPIOPin *rcp_reset_pin_{nullptr}; + // Both only touched on the OpenThread task, by the failure handler + uint8_t rcp_reset_attempts_{0}; + uint32_t last_rcp_reset_ms_{0}; + static constexpr uint32_t RCP_RESET_FORGET_MS = 10 * 60 * 1000; +#endif +#ifdef USE_OPENTHREAD_BORDER_ROUTER + std::atomic mainloop_running_{false}; + bool border_router_started_{false}; + bool backbone_up_{false}; // what the stack was told about the infra interface last +#endif private: // Stores a pointer to a string literal (static storage duration). @@ -83,6 +141,7 @@ class OpenThreadComponent final : public Component { extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_OPENTHREAD_SRP class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } @@ -101,6 +160,7 @@ class OpenThreadSrpComponent final : public Component { std::vector> memory_pool_; void *pool_alloc_(size_t size); }; +#endif // RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the // guard may or may not own the lock (try_acquire can fail), so check it with diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index de1295fa26a..e1194272973 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -1,12 +1,29 @@ #include "esphome/core/defines.h" #if defined(USE_OPENTHREAD) && defined(USE_ESP32) +#include +#include +#include +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include +#endif #include "openthread.h" +#ifdef USE_OPENTHREAD_BORDER_ROUTER +#include "esp_openthread_border_router.h" +#include "esphome/components/wifi/wifi_component.h" +#ifndef USE_OPENTHREAD_RCP_UART +#include "esp_coexist.h" +#endif +#endif +#ifdef USE_OPENTHREAD_RCP_UART +#include "esp_openthread_spinel.h" +#endif #include "esp_log.h" #include "esp_openthread.h" #include "esp_openthread_lock.h" - +#include "esp_openthread_types.h" #include "esp_task_wdt.h" +#include "esphome/components/network/util.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -16,77 +33,170 @@ #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { void OpenThreadComponent::setup() { - // Used eventfds: - // * netif - // * ot task queue - // * radio driver +#ifdef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_t *backbone_netif = wifi::global_wifi_component->get_esp_netif_sta(); + if (backbone_netif == nullptr) { + ESP_LOGE(TAG, "Wi-Fi STA backbone netif is not initialized"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + return; + } + esp_openthread_set_backbone_netif(backbone_netif); + wifi::global_wifi_component->add_connect_state_listener(this); +#endif + + // Used eventfds: netif, ot task queue, radio driver, and one more for a border router on the + // native radio (the UART Spinel transport has none) +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + constexpr size_t max_eventfds = 4; +#else + constexpr size_t max_eventfds = 3; +#endif esp_vfs_eventfd_config_t eventfd_config = { - .max_fds = 3, + .max_fds = max_eventfds, }; // Network interface setup handled by network component. - // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_init() // aborts without it, so fail this component instead. if (global_preferences->nvs_handle == 0) { ESP_LOGE(TAG, "NVS unavailable"); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; this->mark_failed(); return; } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); - esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), - .platform_config = { - .radio_config = - { - .radio_mode = RADIO_MODE_NATIVE, - .radio_uart_config = {}, - }, - .host_config = - { - // There is a conflict between esphome's logger which also - // claims the usb serial jtag device. - // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, - // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), - }, - .port_config = - { - .storage_partition_name = "nvs", - .netif_queue_size = 10, - .task_queue_size = 10, - }, - }}; + // Own task instead of esp_openthread_start(): the dataset activation has to run on the task + // that holds the OpenThread task-switching lock, and a border router must not become the + // default netif + if (xTaskCreate( + [](void *arg) { + static_cast(arg)->ot_main_(); + vTaskDelete(nullptr); + }, + CONFIG_OPENTHREAD_TASK_NAME, CONFIG_OPENTHREAD_TASK_SIZE, this, CONFIG_OPENTHREAD_TASK_PRIORITY, + nullptr) != pdPASS) { + ESP_LOGE(TAG, "Failed to create OpenThread task"); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + this->mark_failed(); + } +} + +void OpenThreadComponent::ot_main_() { + esp_openthread_platform_config_t config = { + .radio_config = + { +#ifdef USE_OPENTHREAD_RCP_UART + .radio_mode = RADIO_MODE_UART_RCP, + .radio_uart_config = + { + .port = UART_NUM_1, + .uart_config = + { + .baud_rate = static_cast(this->rcp_baud_rate_), + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .rx_flow_ctrl_thresh = 0, + .source_clk = UART_SCLK_DEFAULT, + }, + .rx_pin = static_cast(this->rcp_rx_pin_), + .tx_pin = static_cast(this->rcp_tx_pin_), + }, +#else + .radio_mode = RADIO_MODE_NATIVE, + .radio_uart_config = {}, +#endif + }, + .host_config = + { + // There is a conflict between esphome's logger which also + // claims the usb serial jtag device. + // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, + // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), + }, + .port_config = + { + .storage_partition_name = "nvs", + .netif_queue_size = 10, + .task_queue_size = 10, + }, + }; + +#ifdef USE_OPENTHREAD_RCP_UART + // Without a reset pin there is nothing to do on a failure beyond the Spinel driver's own recovery + if (this->rcp_reset_pin_ != nullptr) { + this->rcp_reset_pin_->setup(); + esp_openthread_register_rcp_failure_handler(OpenThreadComponent::rcp_failure_handler); + esp_openthread_set_coprocessor_reset_failure_callback(OpenThreadComponent::rcp_failure_handler); + this->reset_rcp_(false); + } +#endif + + ESP_ERROR_CHECK(esp_openthread_init(&config)); + + // Initialize the esp_netif bindings + esp_netif_config_t netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(); + esp_netif_t *netif = esp_netif_new(&netif_config); + assert(netif != nullptr); + ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(&config))); +#ifndef USE_OPENTHREAD_BORDER_ROUTER + esp_netif_set_default_netif(netif); +#endif + + ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - ESP_ERROR_CHECK(esp_openthread_start(&config)); - // Mark lock as initialized so InstanceLock callers know it's safe to acquire. - // Must be set after esp_openthread_start() which creates the internal semaphore. - this->lock_initialized_ = true; - // Fetch OT instance once to avoid repeated call into OT stack otInstance *instance = esp_openthread_get_instance(); - { - InstanceLock lock = InstanceLock::acquire(); + bool launch_mainloop = true; +#if defined(USE_OPENTHREAD_BORDER_ROUTER) && !defined(USE_OPENTHREAD_RCP_UART) + // The native 802.15.4 radio shares the RF frontend with Wi-Fi. Coexistence has to be on before + // the Thread network starts below, or Wi-Fi scanning and association can fail outright. + if (const esp_err_t err = esp_coex_wifi_i154_enable(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable Wi-Fi/802.15.4 coexistence: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Coexistence could not be enabled")); + launch_mainloop = false; + } +#endif + { + // One lock for the whole bring-up, so the main loop (actions, teardown, the SRP component) + // cannot call into the stack half way through. The lock is recursive: hold it before + // telling the other tasks that it exists, then let the guard take over. + esp_openthread_lock_acquire(portMAX_DELAY); + this->lock_initialized_ = true; + InstanceLock lock = InstanceLock::acquire(); + esp_openthread_lock_release(); + +#ifndef USE_OPENTHREAD_BORDER_ROUTER this->apply_linkmode_(instance); +#endif if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); } } + ESP_LOGI(TAG, "Activating dataset..."); otOperationalDatasetTlvs dataset = {}; - + bool stored_dataset = false; #ifndef USE_OPENTHREAD_FORCE_DATASET // Check if openthread has a valid dataset from a previous execution - otError error = otDatasetGetActiveTlvs(instance, &dataset); - if (error != OT_ERROR_NONE) { + if (otDatasetGetActiveTlvs(instance, &dataset) != OT_ERROR_NONE) { // Make sure the length is 0 so we fallback to the configuration dataset.mLength = 0; } else { + stored_dataset = true; ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); } #endif @@ -94,54 +204,219 @@ void OpenThreadComponent::setup() { #ifdef USE_OPENTHREAD_TLVS if (dataset.mLength == 0) { // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start - size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; - if (len > sizeof(dataset.mTlvs)) { - ESP_LOGW(TAG, "TLV buffer too small, truncating"); - len = sizeof(dataset.mTlvs); - } + constexpr size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + static_assert(len <= sizeof(dataset.mTlvs), "OpenThread dataset TLV is too long"); parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); dataset.mLength = len; } #endif - // Pass the existing dataset, or NULL which will use the preprocessor definitions - ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + if (dataset.mLength > 0 && !otDatasetIsValid(&dataset, true)) { + if (stored_dataset) { + ESP_LOGE(TAG, "Stored OpenThread Active Operational Dataset is invalid; set force_dataset: true to replace it"); + } else { + ESP_LOGE(TAG, "Configured OpenThread Active Operational Dataset TLV is invalid"); + } + this->mark_task_failed_(LOG_STR("Invalid dataset")); + launch_mainloop = false; + } else if (launch_mainloop) { + // esp_openthread_auto_start() enables IPv6, which (for Border Router builds) opens a UDP socket + // via otPlatUdpSocket(). That path releases and re-acquires the OpenThread task-switching lock, + // which asserts unless this task holds the OpenThread lock, as it does here. + if (const esp_err_t err = esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to start OpenThread: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Start failed")); + launch_mainloop = false; + } + } - // Register state change callback to update connected_ reactively instead of polling - otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); - if (ot_err != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + if (launch_mainloop) { + // Register state change callback to update connected_ reactively instead of polling + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to register state change callback: %d", ot_err); + // Without this callback connected_ would never update, so the component would look + // healthy forever while never actually reporting the device as connected. + this->mark_task_failed_(LOG_STR("State callback could not be registered")); + launch_mainloop = false; + } } } - ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); + if (launch_mainloop) { +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = true; +#endif + const esp_err_t err = esp_openthread_launch_mainloop(); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + this->mainloop_running_ = false; +#endif + if (err != ESP_OK) { + ESP_LOGE(TAG, "OpenThread main loop failed: %s", esp_err_to_name(err)); + this->mark_task_failed_(LOG_STR("Main loop failed")); + } + } + + // Clean up. Taking the lock once more lets every guard finish; the stack is stopped and the + // lock withdrawn while it is held. + esp_openthread_lock_acquire(portMAX_DELAY); + // Best effort: after a normal teardown all of this is done already + otIp6SetAddressCallback(instance, nullptr, nullptr); +#ifdef USE_OPENTHREAD_BORDER_ROUTER + if (this->border_router_started_) { + (void) esp_openthread_border_router_deinit(); + this->border_router_started_ = false; + } +#endif + (void) otThreadSetEnabled(instance, false); + (void) otIp6SetEnabled(instance, false); + // Nothing runs the main loop any more, so the stack's reaction to the disables (the border + // agent closing its sockets, for one) is run here, with the lock held + for (uint8_t i = 0; i < 16 && otTaskletsArePending(instance); i++) { + otTaskletsProcess(instance); + } + this->lock_initialized_ = false; + this->connected_ = false; + this->task_exited_ = true; + esp_openthread_lock_release(); + esp_openthread_netif_glue_deinit(); + esp_netif_destroy(netif); + // Finalizing the instance closes the sockets the stack still holds, which hands the task + // switching lock back and forth, so it has to be held here; deinit destroys it at the end + esp_openthread_lock_acquire(portMAX_DELAY); + esp_openthread_deinit(); + esp_vfs_eventfd_unregister(); + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } -int OpenThreadComponent::openthread_stop_() { - // Clean up - reset lock flag before deinit destroys the semaphore - this->lock_initialized_ = false; - int error = esp_openthread_stop(); - // Mark complete even on failure: we're already mid-shutdown/reboot, so there's no - // recovery path to retry into -- leaving the stage stuck would only burn the full - // teardown timeout for no benefit. - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; - return error; +// Runs on the OpenThread task; the component state belongs to the main loop, and the log goes +// out from there too, where an abort that follows on the task cannot swallow it +void OpenThreadComponent::mark_task_failed_(const LogString *reason) { + this->defer([this, reason]() { + ESP_LOGE(TAG, "OpenThread task failed: %s", LOG_STR_ARG(reason)); + this->mark_failed(); + }); } +#ifdef USE_OPENTHREAD_RCP_UART +void OpenThreadComponent::rcp_failure_handler() { + if (global_openthread_component == nullptr) { + ESP_LOGE(TAG, "RCP failure reported after OpenThread component was torn down"); + return; + } + global_openthread_component->reset_rcp_(true); +} + +void OpenThreadComponent::reset_rcp_(bool recovery_attempt) { + if (recovery_attempt) { + static constexpr uint8_t MAX_RESET_ATTEMPTS = 5; + uint8_t attempt = this->rcp_reset_attempts_; + if (attempt > MAX_RESET_ATTEMPTS) { + return; // Gave up earlier; the driver keeps reporting the failure + } + // An RCP that ran long enough since its last reset gets its attempts back; one that keeps failing does not + const uint32_t now = millis(); + if (attempt != 0 && now - this->last_rcp_reset_ms_ > RCP_RESET_FORGET_MS) { + attempt = 0; + } + this->rcp_reset_attempts_ = ++attempt; + if (attempt > MAX_RESET_ATTEMPTS) { + ESP_LOGE(TAG, "RCP failed to recover after %u reset attempts", MAX_RESET_ATTEMPTS); + this->mark_task_failed_(LOG_STR("RCP does not recover")); + return; + } + ESP_LOGW(TAG, "RCP failure detected, reset attempt %u of %u", attempt, MAX_RESET_ATTEMPTS); + this->last_rcp_reset_ms_ = now; + } + this->rcp_reset_pin_->digital_write(true); + vTaskDelay(pdMS_TO_TICKS(10)); + this->rcp_reset_pin_->digital_write(false); + vTaskDelay(pdMS_TO_TICKS(30)); +} +#endif + +int OpenThreadComponent::openthread_stop_() { return esp_openthread_mainloop_exit(); } + network::IPAddresses OpenThreadComponent::get_ip_addresses() { network::IPAddresses addresses; - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - esp_netif_t *netif = esp_netif_get_default_netif(); - count = esp_netif_get_all_ip6(netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); +#if USE_NETWORK_IPV6 + // The task's clean-up destroys the netif under the lock, so the netif is only read while it is held; + // the OpenThread task releases the lock between events, so the wait is short + auto lock = InstanceLock::try_acquire(10); + if (!lock) { + ESP_LOGV(TAG, "Instance lock busy, no addresses this time"); + return addresses; } + if (esp_netif_t *netif = esp_openthread_get_netif(); netif != nullptr) { + network::fill_ip6_addresses(netif, addresses); + } +#endif return addresses; } +#ifdef USE_OPENTHREAD_BORDER_ROUTER +static constexpr uint32_t BORDER_ROUTER_RETRY_MS = 10000; + +void OpenThreadComponent::loop() { + // Thread starts on its own. Border routing starts once the Wi-Fi backbone has an address. When the + // backbone goes away the stack is told so it pauses routing on that interface; when it comes back + // the border router is created afresh, since the port's infra interface keeps the socket and the + // addresses of the backbone as it was. The Wi-Fi listener enables the loop on every link change + if (!this->mainloop_running_) { + return; + } + const bool backbone_up = wifi::global_wifi_component->is_connected(); + if (backbone_up == this->backbone_up_) { + this->disable_loop(); + return; + } + // Never wait for the lock on the main loop; the OpenThread task releases it between events + auto lock = InstanceLock::try_acquire(0); + if (!lock) { + return; + } + auto retry_later = [this](const LogString *reason) { + this->status_set_error(reason); + this->disable_loop(); + this->set_timeout(BORDER_ROUTER_RETRY_MS, [this]() { this->enable_loop(); }); + }; + if (!backbone_up) { + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone lost, border routing pauses"); + esp_netif_t *backbone = esp_openthread_get_backbone_netif(); + // A refused report is not retried: the border router is created afresh when the backbone returns + if (otPlatInfraIfStateChanged(lock.get_instance(), esp_netif_get_netif_impl_index(backbone), false) != + OT_ERROR_NONE) { + ESP_LOGW(TAG, "Stack did not take the backbone loss, routes stay until Wi-Fi returns"); + } + } + this->backbone_up_ = false; + this->disable_loop(); + return; + } + if (this->border_router_started_) { + ESP_LOGI(TAG, "Wi-Fi backbone back, border routing restarts"); + // The old router stays marked as started until it is gone, so a retry tears it down first + if (const esp_err_t err = esp_openthread_border_router_deinit(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to deinitialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router deinit failed")); + return; + } + this->border_router_started_ = false; + } + if (const esp_err_t err = esp_openthread_border_router_init(); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize OpenThread Border Router: %s", esp_err_to_name(err)); + retry_later(LOG_STR("Border router init failed")); + return; + } + this->status_clear_error(); + this->border_router_started_ = true; + this->backbone_up_ = true; + ESP_LOGI(TAG, "Border routing started"); + this->disable_loop(); +} +#endif + // not thread safe, only use in read-only use cases otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } @@ -149,7 +424,23 @@ InstanceLock InstanceLock::try_acquire(int delay) { if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } - return InstanceLock(esp_openthread_lock_acquire(delay)); + if (!esp_openthread_lock_acquire(delay)) { + return InstanceLock(false); + } + // The clean-up may have released the lock to a waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + return InstanceLock(false); + } + return InstanceLock(true); +} + +// The lock went away with the task, so waiting for it could never end +static void abort_if_task_exited() { + if (global_openthread_component->is_task_exited()) { + ESP_LOGE(TAG, "OpenThread task has exited, cannot acquire instance lock"); + abort(); + } } InstanceLock InstanceLock::acquire() { @@ -160,15 +451,16 @@ InstanceLock InstanceLock::acquire() { abort(); } // A failed setup() never creates the lock; say so now instead of after the wait below - if (global_openthread_component->is_failed()) { + if (global_openthread_component->is_failed() && !global_openthread_component->is_lock_initialized()) { ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); abort(); } // Wait for the lock to be created before attempting to acquire it. - // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). + // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init(). constexpr uint32_t lock_init_timeout_ms = 10000; uint32_t start = millis(); while (!global_openthread_component->is_lock_initialized()) { + abort_if_task_exited(); if (millis() - start > lock_init_timeout_ms) { ESP_LOGE(TAG, "OpenThread lock not initialized after %" PRIu32 "ms, aborting", lock_init_timeout_ms); abort(); @@ -177,8 +469,14 @@ InstanceLock InstanceLock::acquire() { esp_task_wdt_reset(); } while (!esp_openthread_lock_acquire(100)) { + abort_if_task_exited(); esp_task_wdt_reset(); } + // The clean-up may have released the lock to this waiter just before deinit destroys it + if (global_openthread_component->is_task_exited()) { + esp_openthread_lock_release(); + abort_if_task_exited(); + } return InstanceLock(true); } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d308ad2b977..c8e1df12f31 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,4 +1,5 @@ #include "wifi_component.h" +#include "esphome/components/network/util.h" #ifdef USE_WIFI #ifdef USE_ESP32 @@ -863,14 +864,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { addresses[0] = network::IPAddress(&ip.ip); } #if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } + network::fill_ip6_addresses(s_sta_netif, addresses); #endif /* USE_NETWORK_IPV6 */ return addresses; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e020975e84..79ea459bad8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -574,13 +574,20 @@ #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD +#define USE_OPENTHREAD_SRP #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32S3) +// Border router with a UART RCP; a native-radio border router (C5/C6) adds only the coexistence call +#define USE_OPENTHREAD +#define USE_OPENTHREAD_BORDER_ROUTER +#define USE_OPENTHREAD_RCP_UART +#endif #if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_ESPECTRE #endif -#ifndef USE_OPENTHREAD +#ifndef USE_OPENTHREAD_SRP #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #ifdef USE_ETHERNET #define USE_MDNS_ACTIVE_INTERFACE_LISTENER diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 63dbeffd772..8d5ddfc39f8 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -7,6 +7,7 @@ CONFIG_IEEE802154_ENABLED=y CONFIG_OPENTHREAD_ENABLED=y CONFIG_OPENTHREAD_RADIO_NATIVE=y +CONFIG_OPENTHREAD_FTD=y # zigbee CONFIG_ZB_ENABLED=y diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 index 15b97eb1b41..5204d95db52 100644 --- a/sdkconfig.defaults.esp32s3 +++ b/sdkconfig.defaults.esp32s3 @@ -10,3 +10,28 @@ CONFIG_TINYUSB_CDC_ENABLED=y CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_RX_BUFSIZE=256 CONFIG_TINYUSB_CDC_TX_BUFSIZE=256 + +# OpenThread Border Router with external UART RCP. +CONFIG_OPENTHREAD_ENABLED=y +CONFIG_OPENTHREAD_FTD=y +CONFIG_OPENTHREAD_PLATFORM_NETIF=y +CONFIG_OPENTHREAD_BORDER_ROUTER=y +CONFIG_OPENTHREAD_RADIO_SPINEL_UART=y +CONFIG_OPENTHREAD_CLI=n +CONFIG_OPENTHREAD_CONSOLE_ENABLE=n +CONFIG_OPENTHREAD_DIAG=n +CONFIG_OPENTHREAD_DNS64_CLIENT=n +CONFIG_OPENTHREAD_SRP_CLIENT=n +CONFIG_LWIP_IPV6_FORWARD=y +CONFIG_LWIP_IPV6_NUM_ADDRESSES=12 +CONFIG_LWIP_MULTICAST_PING=y +CONFIG_LWIP_NETIF_STATUS_CALLBACK=y +CONFIG_LWIP_HOOK_IP6_ROUTE_DEFAULT=y +CONFIG_LWIP_HOOK_ND6_GET_GW_DEFAULT=y +CONFIG_LWIP_HOOK_IP6_INPUT_CUSTOM=y +CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_CUSTOM=y +CONFIG_MDNS_MULTIPLE_INSTANCE=y +CONFIG_MBEDTLS_CMAC_C=y +CONFIG_MBEDTLS_SSL_PROTO_DTLS=y +CONFIG_MBEDTLS_KEY_EXCHANGE_ECJPAKE=y +CONFIG_MBEDTLS_ECJPAKE_C=y diff --git a/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml new file mode 100644 index 00000000000..a6f8877e31b --- /dev/null +++ b/tests/component_tests/esp32/config/mbedtls_tls_openthread_border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + ext_pan_id: 0xd63e8e3e495ebbc3 + pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 + mesh_local_prefix: fd53:145f:ed22:ad81::/64 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index bd8c099c09f..3cde877f7ad 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2240,9 +2240,10 @@ def test_mbedtls_tls_openthread_requires_server_and_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required.""" - generate_main(component_config_path("mbedtls_tls_openthread.yaml")) + """The Border Router needs TLS/DTLS server support and OpenThread crypto extras.""" + generate_main(component_config_path("mbedtls_tls_openthread_border_router.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] + assert mbedtls.tls_required is True assert mbedtls.tls_server_required is True assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py index eacf722f177..ecd3cdeb92a 100644 --- a/tests/component_tests/mdns/test_service_enable_disable.py +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -38,6 +38,19 @@ def test_esp32_adds_define_and_keeps_services_stored( assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() +def test_esp32_border_router_adds_define( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_config( + set_core_config, + PlatformFramework.ESP32_IDF, + {"mdns": {CONF_DISABLED: False}, "openthread": {"border_router": {}}}, + ) + + assert mdns.request_service_enable_disable() is True + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + @pytest.mark.parametrize( "platform_framework", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], diff --git a/tests/component_tests/openthread/config/border_router.yaml b/tests/component_tests/openthread/config/border_router.yaml new file mode 100644 index 00000000000..04e033f55ab --- /dev/null +++ b/tests/component_tests/openthread/config/border_router.yaml @@ -0,0 +1,24 @@ +esphome: + name: openthread-border-router + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: {} + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/config/border_router_rcp.yaml b/tests/component_tests/openthread/config/border_router_rcp.yaml new file mode 100644 index 00000000000..26204f9719b --- /dev/null +++ b/tests/component_tests/openthread/config/border_router_rcp.yaml @@ -0,0 +1,27 @@ +esphome: + name: openthread-border-router-rcp + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +network: + enable_ipv6: true + +wifi: + ssid: test + password: password + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + network_name: TestNetwork + channel: 15 + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x0123 + ext_pan_id: 0x00000000000000AB + pskc: 0x000000000000000000000000000000CD + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index c0e155ec7e3..96aeb92ffaa 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -15,7 +15,7 @@ from esphome.components.esp32 import ( 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.const import KEY_FRAMEWORK_VERSION, PlatformFramework, Toolchain from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -42,7 +42,9 @@ def test_credentials_are_sensitive( """Thread credentials are tagged for redaction without changing their value or the hexadecimal integer type used to build sdkconfig options.""" set_core_config( - PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)}, + platform_data={KEY_VARIANT: VARIANT_ESP32C6}, ) validated = CONFIG_SCHEMA(config) assert isinstance(validated[field], Sensitive) diff --git a/tests/component_tests/openthread/test_openthread.py b/tests/component_tests/openthread/test_openthread.py new file mode 100644 index 00000000000..11b6854b5a9 --- /dev/null +++ b/tests/component_tests/openthread/test_openthread.py @@ -0,0 +1,324 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome.components.const import CONF_BORDER_ROUTER +from esphome.components.esp32 import ( + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32S3, +) +from esphome.components.esp32.const import ( + KEY_BOARD, + KEY_ESP32, + KEY_IDF_VERSION, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, +) +from esphome.components.openthread import ( + _RCP_SCHEMA, + BORDER_ROUTER_SDKCONFIG_OPTIONS, + RCP_BORDER_ROUTER_VARIANTS, + _final_validate, + _validate, + _validate_platform, +) +from esphome.components.openthread.const import CONF_DEVICE_TYPE, CONF_RCP +import esphome.config_validation as cv +from esphome.const import ( + CONF_AP, + CONF_BAUD_RATE, + CONF_ENABLE_IPV6, + CONF_ENABLE_ON_BOOT, + CONF_ID, + CONF_INVERTED, + CONF_LOGGER, + CONF_NETWORKS, + CONF_NUMBER, + CONF_OPENTHREAD, + CONF_RX_PIN, + CONF_TX_PIN, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_FRAMEWORK_VERSION, + PlatformFramework, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG_DIR = Path(__file__).parent / "config" +CONF_NETWORK = "network" +DEVICE_TYPE_FTD = "FTD" +DEVICE_TYPE_MTD = "MTD" +RCP_PINS = {CONF_RX_PIN: 18, CONF_TX_PIN: 17} + + +@pytest.fixture +def otbr_config() -> ConfigType: + return { + CONF_ID: "openthread_component", + CONF_BORDER_ROUTER: {}, + CONF_DEVICE_TYPE: DEVICE_TYPE_FTD, + } + + +@pytest.fixture +def full_config(otbr_config: ConfigType) -> ConfigType: + return { + CONF_OPENTHREAD: otbr_config, + CONF_NETWORK: {CONF_ENABLE_IPV6: True}, + CONF_WIFI: { + CONF_ENABLE_ON_BOOT: True, + CONF_NETWORKS: [{"ssid": "test"}], + }, + } + + +def _set_esp32_idf_core( + set_core_config: SetCoreConfigCallable, + variant: str = VARIANT_ESP32C6, + idf_version: cv.Version | None = None, +) -> None: + if idf_version is None: + idf_version = cv.Version(5, 5, 5) + set_core_config( + PlatformFramework.ESP32_IDF, + core_data={KEY_FRAMEWORK_VERSION: idf_version}, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_IDF_VERSION: idf_version, + KEY_VARIANT: variant, + KEY_SDKCONFIG_OPTIONS: {}, + }, + ) + + +def _run_final_validation( + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + token = fv.full_config.set(full_config) + try: + _final_validate(otbr_config) + finally: + fv.full_config.reset(token) + + +def test_plain_openthread_uses_the_srp_client( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "no_custom_header.yaml") + + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_SRP" in defines + assert "USE_OPENTHREAD_BORDER_ROUTER" not in defines + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_OPENTHREAD_SRP_CLIENT"] + is True + ) + + +def test_border_router_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + generate_main(CONFIG_DIR / "border_router.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + for option, value in BORDER_ROUTER_SDKCONFIG_OPTIONS.items(): + assert sdkconfig[option] == value + assert sdkconfig["CONFIG_SW_COEXIST_ENABLE"] is True + assert sdkconfig["CONFIG_OPENTHREAD_SRP_CLIENT"] is False + assert sdkconfig["CONFIG_OPENTHREAD_DNS64_CLIENT"] is False + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_MASTERKEY"] + == "00112233445566778899aabbccddeeff" + ) + assert sdkconfig["CONFIG_OPENTHREAD_NETWORK_EXTPANID"] == "00000000000000ab" + assert ( + sdkconfig["CONFIG_OPENTHREAD_NETWORK_PSKC"] + == "000000000000000000000000000000cd" + ) + defines = {define.name for define in CORE.defines} + assert "USE_OPENTHREAD_BORDER_ROUTER" in defines + assert "USE_OPENTHREAD_SRP" not in defines + + +def test_border_router_rcp_codegen( + generate_main: Callable[[str | Path], str], +) -> None: + cpp_main = generate_main(CONFIG_DIR / "border_router_rcp.yaml") + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_NATIVE"] is False + assert sdkconfig["CONFIG_OPENTHREAD_RADIO_SPINEL_UART"] is True + assert sdkconfig["CONFIG_VFS_SUPPORT_DIR"] is True + assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is not True + assert any(define.name == "USE_OPENTHREAD_RCP_UART" for define in CORE.defines) + assert "OpenThreadComponent(460800, 18, 17, nullptr)" in cpp_main + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + ( + lambda config, _: config.pop(CONF_WIFI), + "requires a Wi-Fi STA backbone", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_NETWORKS: []}), + "requires at least one configured", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_ENABLE_ON_BOOT: False}), + "enable_on_boot: true", + ), + ( + lambda config, _: config[CONF_WIFI].update({CONF_AP: {}}), + "does not support Wi-Fi AP", + ), + ( + lambda config, _: config[CONF_NETWORK].update({CONF_ENABLE_IPV6: False}), + "requires IPv6 to be enabled", + ), + ], +) +def test_border_router_rejects_incompatible_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + mutate: Any, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config) + mutate(full_config, otbr_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +def test_border_router_requires_ftd( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config) + otbr_config[CONF_DEVICE_TYPE] = DEVICE_TYPE_MTD + otbr_config[CONF_USE_ADDRESS] = "test.local" + + with pytest.raises(cv.Invalid, match="device_type: FTD"): + _validate(otbr_config) + + +def test_native_border_router_rejects_variant_without_ieee802154( + set_core_config: SetCoreConfigCallable, otbr_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid, match="both Wi-Fi and IEEE 802.15.4"): + _validate_platform(otbr_config) + + +def test_requires_idf_5_5_5(set_core_config: SetCoreConfigCallable) -> None: + _set_esp32_idf_core(set_core_config, idf_version=cv.Version(5, 5, 2)) + + with pytest.raises(cv.Invalid, match="at least framework version 5.5.5"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +def test_rejects_arduino(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="incompatible with ESP32 using arduino"): + _validate_platform({CONF_DEVICE_TYPE: DEVICE_TYPE_FTD}) + + +@pytest.mark.parametrize("variant", sorted(RCP_BORDER_ROUTER_VARIANTS)) +def test_border_router_rcp_accepts_wifi_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + assert _validate_platform(otbr_config) is otbr_config + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32H2, VARIANT_ESP32H4]) +def test_border_router_rcp_rejects_variants_without_wifi( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + + with pytest.raises(cv.Invalid, match="requires a Wi-Fi-capable ESP32 variant"): + _validate_platform(otbr_config) + + +@pytest.mark.parametrize( + ("extra_config", "message"), + [ + ({"uart": [{}]}, "cannot currently be combined"), + ( + {CONF_LOGGER: {"hardware_uart": "UART1", "baud_rate": 115200}}, + "reserves UART1", + ), + ], +) +def test_border_router_rcp_rejects_uart_conflicts( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, + extra_config: ConfigType, + message: str, +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + otbr_config[CONF_BORDER_ROUTER][CONF_RCP] = RCP_PINS + full_config.update(extra_config) + + with pytest.raises(cv.Invalid, match=message): + _run_final_validation(otbr_config, full_config) + + +@pytest.mark.parametrize( + "rcp_config", + [ + {**RCP_PINS, CONF_BAUD_RATE: 0}, + {**RCP_PINS, CONF_BAUD_RATE: 9599}, + {CONF_RX_PIN: {CONF_NUMBER: 18, CONF_INVERTED: True}, CONF_TX_PIN: 17}, + ], +) +def test_rcp_schema_rejects_bad_options( + set_core_config: SetCoreConfigCallable, rcp_config: ConfigType +) -> None: + _set_esp32_idf_core(set_core_config, VARIANT_ESP32S3) + + with pytest.raises(cv.Invalid): + _RCP_SCHEMA(rcp_config) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C5, VARIANT_ESP32C6]) +def test_native_border_router_accepts_wifi_ieee802154_variants( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + variant: str, +) -> None: + _set_esp32_idf_core(set_core_config, variant) + + assert _validate_platform(otbr_config) is otbr_config + + +def test_border_router_accepts_supported_config( + set_core_config: SetCoreConfigCallable, + otbr_config: ConfigType, + full_config: ConfigType, +) -> None: + _set_esp32_idf_core(set_core_config) + + _run_final_validation(otbr_config, full_config) diff --git a/tests/components/openthread/test-border-router.esp32-c5-idf.yaml b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c5-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-border-router.esp32-c6-idf.yaml b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml new file mode 100644 index 00000000000..c3558b16f9d --- /dev/null +++ b/tests/components/openthread/test-border-router.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR Test" + password: "test-password" + +openthread: + border_router: {} + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 + +button: + - platform: factory_reset + name: Factory Reset diff --git a/tests/components/openthread/test-rcp.esp32-idf.yaml b/tests/components/openthread/test-rcp.esp32-idf.yaml new file mode 100644 index 00000000000..b784f54ff9d --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-idf.yaml @@ -0,0 +1,19 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 diff --git a/tests/components/openthread/test-rcp.esp32-s3-idf.yaml b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml new file mode 100644 index 00000000000..6d643ff91e6 --- /dev/null +++ b/tests/components/openthread/test-rcp.esp32-s3-idf.yaml @@ -0,0 +1,20 @@ +packages: + openthread: !include common.yaml + +wifi: + ssid: "OTBR RCP Test" + password: "test-password" + +openthread: + border_router: + rcp: + rx_pin: GPIO18 + tx_pin: GPIO17 + reset_pin: GPIO16 + channel: 15 + network_name: ESPHome-OTBR + network_key: 0x00112233445566778899AABBCCDDEEFF + pan_id: 0x1234 + ext_pan_id: 0x00AD00BE00C0CAFE + pskc: 0x004810E2315100AFD6BC9215A6BFAC53 + mesh_local_prefix: fd00:db8:a0:0::/64 From ecd5980c5d47acfc5ebdfb2d453101a96bb5506f Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Thu, 15 Oct 2026 01:41:37 -0500 Subject: [PATCH 07/50] [epaper_spi] Pass bits per pixel and plane count to the base constructor (#20683) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/epaper_spi/epaper_spi.h | 21 +++++--- .../components/epaper_spi/epaper_spi_4bpp.h | 4 +- .../epaper_spi/epaper_spi_inkplate2.h | 5 +- .../epaper_spi/epaper_spi_jd79660.h | 7 +-- .../components/epaper_spi/epaper_spi_mono.h | 7 +-- .../epaper_spi/epaper_spi_ssd1677.cpp | 6 +-- .../epaper_spi/epaper_spi_ssd1677.h | 4 +- .../epaper_spi/epaper_spi_ssd1677_gray4.h | 6 +-- .../epaper_spi/epaper_spi_t133a01.h | 4 +- .../components/epaper_spi/epaper_spi_uc8179.h | 5 +- .../epaper_spi/epaper_waveshare_bwr.h | 4 +- .../components/epaper_spi/epaper_weact_3c.h | 4 +- .../epaper_spi/display/test_buffer_layout.cpp | 49 +++++++++++++++++++ 14 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_buffer_layout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 7b962d59ea8..d8ead388183 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -468,7 +468,7 @@ void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, cons 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 || + 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; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 384b9cb0181..37f9a1449a2 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -38,15 +38,18 @@ class EPaperBase : public Display, spi::DATA_RATE_2MHZ> { public: EPaperBase(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence = nullptr, - size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY) + size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY, + uint8_t bits_per_pixel = 1, uint8_t planes = 1) : name_(name), width_(width), + row_width_((width * bits_per_pixel + 7) / 8), height_(height), + bits_per_pixel_(bits_per_pixel), + planes_(planes), init_sequence_(init_sequence), init_sequence_length_(init_sequence_length), - display_type_(display_type) { - this->row_width_ = (this->width_ + 7) / 8; // width of a row in bytes - } + display_type_(display_type), + buffer_length_((size_t) this->row_width_ * height * planes) {} void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } @@ -134,8 +137,8 @@ class EPaperBase : public Display, 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; } + // Whether the buffer is a single plane of 1 bit per pixel, as draw_pixel_at() writes it + bool buffer_is_mono_() const { return this->bits_per_pixel_ == 1 && this->planes_ == 1; } /** * Methods that must be implemented by concrete classes to control the display @@ -173,13 +176,15 @@ class EPaperBase : public Display, // properties initialised in the constructor const char *name_; uint16_t width_; - uint16_t row_width_; // width of a row in bytes + uint16_t row_width_; // width of a row of one plane in bytes uint16_t height_; + uint8_t bits_per_pixel_; + uint8_t planes_; // the buffer holds this many planes of row_width_ * height_ bytes, one after another const uint8_t *init_sequence_; size_t init_sequence_length_; DisplayType display_type_; - size_t buffer_length_{}; + size_t buffer_length_; size_t current_data_index_{}; // used by data transfer to track progress split_buffer::SplitBuffer buffer_{}; GPIOPin *dc_pin_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h index 6eebe24c91f..3843752f858 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.h +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -15,9 +15,7 @@ class EPaper4bpp : public EPaperBase { public: EPaper4bpp(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = width * height / 2; // 2 pixels per byte - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void fill(Color color) override; void clear() override; diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h index 657eca47590..09926db4ad8 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.h +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -9,10 +9,7 @@ class EPaperInkplate2 final : public EPaperBase { public: EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. - this->buffer_length_ = this->row_width_ * this->height_ * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 1, 2) {} void fill(Color color) override; void clear() override; diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.h b/esphome/components/epaper_spi/epaper_spi_jd79660.h index 4e488fe93ec..522950dc77b 100644 --- a/esphome/components/epaper_spi/epaper_spi_jd79660.h +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.h @@ -27,12 +27,9 @@ class EPaperJD79660 final : public EPaperBase { public: EPaperJD79660(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *fast_update, uint16_t fast_update_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR), + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 2), fast_update_(fast_update), - fast_update_length_(fast_update_length) { - this->row_width_ = (width + 3) / 4; // Fix base class calc (2bpp instead of 1bpp) - this->buffer_length_ = this->row_width_ * height; - } + fast_update_length_(fast_update_length) {} void fill(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 0f570149de4..4dc54ae60ba 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,13 +9,10 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { - this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up - } + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} 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 a79bbc3dc91..ffc5d65a1ed 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,9 +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; - // 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(); + // A 1 bit per pixel 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, 2 bits per pixel) packs the plane in plane_row() instead. + 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 b8c93a1ecb8..d8481b712c7 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -23,8 +23,8 @@ namespace esphome::epaper_spi { class EPaperSSD1677 : public EPaperMono { public: EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) - : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {} void setup() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index f21ab3eb069..e44fda37c2a 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -29,16 +29,12 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { public: EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { - this->row_width_ = (width + 3) / 4; // 4 pixels per byte - this->buffer_length_ = (size_t) this->row_width_ * height; - } + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE, 2) {} void fill(Color color) override; 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(); } void plane_row(size_t y, uint8_t *out) override; diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 5d356f90b01..7c14876bafd 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -22,9 +22,7 @@ class EPaperT133A01 : public EPaperBase { public: EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { - this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { this->cs_pin_ = cs; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index b12ed54ef6d..fda4297802b 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -32,12 +32,9 @@ class EPaperUC8179 final : public EPaperBase { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} 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/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index a090faa14d2..d34f05cbb2b 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -22,9 +22,7 @@ class EPaperWaveshareBWR : public EPaperBase { public: EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} void fill(Color color) override; diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index a31c2be8176..d3c89851962 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -18,9 +18,7 @@ class EPaperWeAct3C : public EPaperBase { public: EPaperWeAct3C(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = this->row_width_ * height * 2; - } + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} void fill(Color color) override; void clear() override; diff --git a/tests/components/epaper_spi/display/test_buffer_layout.cpp b/tests/components/epaper_spi/display/test_buffer_layout.cpp new file mode 100644 index 00000000000..34ffecc28e5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_buffer_layout.cpp @@ -0,0 +1,49 @@ +#include + +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" +#include "esphome/components/epaper_spi/epaper_waveshare_bwr.h" +#include "esphome/components/epaper_spi/epaper_weact_3c.h" + +namespace esphome::epaper_spi::testing { +namespace { + +template class Layout : public Driver { + public: + Layout(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {} + uint16_t row_width() const { return this->row_width_; } + size_t buffer_length() const { return this->buffer_length_; } + bool is_mono() const { return this->buffer_is_mono_(); } +}; + +/// Each driver's bits per pixel and plane count give the row width and buffer size its drawing code expects, and only +/// a single 1 bit per pixel plane takes the direct drawing paths. +TEST(EPaperBufferLayout, SizesFollowBitsPerPixelAndPlanes) { + Layout mono(21, 3); + EXPECT_EQ(mono.row_width(), 3); + EXPECT_EQ(mono.buffer_length(), 9u); + EXPECT_TRUE(mono.is_mono()); + + Layout gray(21, 3); + EXPECT_EQ(gray.row_width(), 6); + EXPECT_EQ(gray.buffer_length(), 18u); + EXPECT_FALSE(gray.is_mono()); + + Layout t133(20, 3); + EXPECT_EQ(t133.row_width(), 10); + EXPECT_EQ(t133.buffer_length(), 30u); + EXPECT_FALSE(t133.is_mono()); + + Layout weact(21, 3); + EXPECT_EQ(weact.row_width(), 3); + EXPECT_EQ(weact.buffer_length(), 18u); + EXPECT_FALSE(weact.is_mono()); + + Layout bwr(21, 3); + EXPECT_EQ(bwr.buffer_length(), 18u); + EXPECT_FALSE(bwr.is_mono()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From bb0837d814a04769b7b7ff7c9634c970a4d3e299 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:44:23 +1100 Subject: [PATCH 08/50] [lvgl] Implement menu widget (#18022) --- esphome/components/lvgl/__init__.py | 3 + esphome/components/lvgl/defines.py | 18 + esphome/components/lvgl/lvgl_esphome.cpp | 7 + esphome/components/lvgl/lvgl_esphome.h | 6 + esphome/components/lvgl/widgets/menu.py | 326 ++++++++++++++ .../lvgl/config/menu_test.yaml | 81 ++++ tests/component_tests/lvgl/test_menu.py | 407 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 50 +++ tests/components/lvgl/validate.host.yaml | 42 +- 9 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 esphome/components/lvgl/widgets/menu.py create mode 100644 tests/component_tests/lvgl/config/menu_test.yaml create mode 100644 tests/component_tests/lvgl/test_menu.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6806eab0cf3..810d15f7005 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -112,6 +112,7 @@ from .widgets import ( set_obj_properties, ) from .widgets.keyboard import attach_textareas +from .widgets.menu import add_root_back_button_triggers, validate_menu_pages # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -333,6 +334,7 @@ def final_validation(config_list): path = global_config.get_path_for_id(name) widget_conf = global_config.get_config_for_path(path[:-1]) widget_type.final_validate(name, conf, widget_conf, path[1:]) + validate_menu_pages(global_config) def lv_conf_path_flag(lv_conf_h_file: Path) -> str: @@ -513,6 +515,7 @@ async def to_code(configs): await finish_list_triggers() await generate_triggers() + await add_root_back_button_triggers() await generate_align_tos(configs[0]) await attach_textareas() for config in configs: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 69e15bdeca6..203a75b0c8e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -25,6 +25,7 @@ KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" +KEY_MENU_SET_PAGE_ACTIONS = "menu_set_page_actions" KEY_NAMED_STYLES = "named_styles" KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" @@ -172,6 +173,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_menu_set_page_actions() -> list: + return _get_data(KEY_MENU_SET_PAGE_ACTIONS, []) + + def get_list_triggers() -> dict: return _get_data(KEY_LIST_TRIGGERS, {}) @@ -682,6 +687,12 @@ LV_MENU_MODES = LvConstant( "BOTTOM_FIXED", ) +LV_MENU_ROOT_BACK_BUTTON_MODES = LvConstant( + "LV_MENU_ROOT_BACK_BUTTON_", + "DISABLED", + "ENABLED", +) + LV_CHART_TYPES = ( "NONE", "LINE", @@ -762,6 +773,7 @@ CONF_GRID_ROW_ALIGN = "grid_row_align" CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" +CONF_HEADER_STYLE = "header_style" CONF_HOME = "home" CONF_IMAGE = "image" CONF_INDICATORS = "indicators" @@ -778,6 +790,7 @@ CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" CONF_MAPPING = "mapping" +CONF_MENU = "menu" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" @@ -788,6 +801,7 @@ CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" +CONF_ON_ROOT_BACK_BUTTON_CLICK = "on_root_back_button_click" CONF_ON_SELECT = "on_select" CONF_OPA = "opa" CONF_NEXT = "next" @@ -810,6 +824,7 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" +CONF_ROOT_PAGE = "root_page" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -817,9 +832,12 @@ CONF_SCROLL_DIR = "scroll_dir" CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" +CONF_SECTIONS = "sections" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" +CONF_SIDEBAR_PAGE = "sidebar_page" +CONF_SIDEBAR_HEADER_STYLE = "sidebar_header_style" CONF_SPIN_TIME = "spin_time" CONF_SRC = "src" CONF_START_ANGLE = "start_angle" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 0b375404d74..9bc83800051 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -1009,6 +1009,13 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { } #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e) { + return lv_event_get_code(e) == LV_EVENT_CLICKED && + lv_menu_back_button_is_root(menu, static_cast(lv_event_get_target(e))); +} +#endif // USE_LVGL_MENU + lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { auto *indev = lv_indev_get_act(); if (indev == nullptr) { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 75f926d865e..35e5335c10e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -143,6 +143,12 @@ lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index); lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); #endif // USE_LVGL_GRADIENT +#ifdef USE_LVGL_MENU +// True if `e` is a click on `menu`'s back button while already at the root page -- the one +// case LVGL's own docs say the app must handle itself (there is no default behavior). +bool lv_menu_back_button_is_root_click(lv_obj_t *menu, lv_event_t *e); +#endif // USE_LVGL_MENU + // Parent class for things that wrap an LVGL object class LvCompound { public: diff --git a/esphome/components/lvgl/widgets/menu.py b/esphome/components/lvgl/widgets/menu.py new file mode 100644 index 00000000000..e32a7e9ba35 --- /dev/null +++ b/esphome/components/lvgl/widgets/menu.py @@ -0,0 +1,326 @@ +from esphome import automation +from esphome.automation import Trigger +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_BUTTON, CONF_ID, CONF_PAGES, CONF_TRIGGER_ID +from esphome.final_validate import FinalValidateConfig +from esphome.types import ConfigType + +from ..automation import action_to_code +from ..defines import ( + CONF_HEADER_MODE, + CONF_HEADER_STYLE, + CONF_IMAGE, + CONF_MAIN, + CONF_MENU, + CONF_ON_ROOT_BACK_BUTTON_CLICK, + CONF_PAGE, + CONF_ROOT_BACK_BTN, + CONF_ROOT_PAGE, + CONF_SECTIONS, + CONF_SIDEBAR_HEADER_STYLE, + CONF_SIDEBAR_PAGE, + CONF_TITLE, + LV_MENU_MODES, + LV_MENU_ROOT_BACK_BUTTON_MODES, + TYPE_FLEX, + get_menu_set_page_actions, + get_widget_map, + literal, +) +from ..lv_validation import lv_text +from ..lvcode import ( + EVENT_ARG, + LambdaContext, + LocalVariable, + LvConditional, + lv, + lv_add, + lv_expr, + lv_obj, +) +from ..schemas import container_schema, part_schema +from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr, lvgl_ns +from . import ( + Widget, + WidgetType, + get_widget_, + get_widgets, + set_obj_properties, + widget_to_code, +) +from .obj import obj_spec + +# Local to the "menu" widget only -- not a shared config concept. +CONF_SIDEBAR = "sidebar" + +# Tag types, only used to distinguish declare_id()/use_id() checks for pages and +# sections -- like tileview's lv_tileview_tile_t they carry no extra behaviour, but +# they must have their own names: id type checks compare the type name, so reusing +# `lv_obj_t` here would let any object be passed where a page is required. The +# generated code always declares the variable as `lv_obj_t *`. +lv_menu_page_t = LvType("lv_menu_page_t") +lv_menu_section_t = LvType("lv_menu_section_t") + +lv_menu_t = LvType( + "lv_menu_t", + largs=[(lv_obj_t_ptr, "page")], + lvalue=lambda w: lv_expr.menu_get_cur_main_page(w.obj), + has_on_value=True, +) + + +class MenuSectionType(WidgetType): + """ + A grouped, bordered set of rows inside a menu page (lv_menu_section_create). + Only valid as a direct child of a menu page, so it's not exposed in the + generic `widgets:` menu -- reached only via a page's `sections:` key. + """ + + def __init__(self): + super().__init__( + "menu_section", lv_menu_section_t, (CONF_MAIN,), {}, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + return lv_expr.menu_section_create(parent) + + +menu_section_spec = MenuSectionType() + +MENU_PAGE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TITLE): lv_text, + cv.Optional(CONF_SECTIONS): cv.ensure_list( + container_schema( + menu_section_spec, + {cv.GenerateID(): cv.declare_id(lv_menu_section_t)}, + ) + ), + } +) + + +class MenuPageType(WidgetType): + """ + A page of the menu (lv_menu_page_create), shown one at a time via + `lvgl.menu.set_page`. Like tabview's tabs/tileview's tiles, pages are only + ever declared via the menu's own `pages:` key, not the generic `widgets:` + menu -- reachable content, not a freestanding widget type. + """ + + def __init__(self): + super().__init__( + "menu_page", lv_menu_page_t, (CONF_MAIN,), MENU_PAGE_SCHEMA, is_mock=True + ) + + async def obj_creator(self, parent: cg.MockObj, config: dict): + title = config.get(CONF_TITLE) + return lv_expr.menu_page_create( + parent, await lv_text.process(title) if title is not None else cg.nullptr + ) + + async def on_create(self, var: cg.MockObj, config: dict): + # Runs before the page's ungrouped `widgets:` are added, so sections come first. + for section_conf in config.get(CONF_SECTIONS, ()): + await widget_to_code(section_conf, menu_section_spec, var) + return var + + +menu_page_spec = MenuPageType() + +MENU_SCHEMA = cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list( + container_schema( + menu_page_spec, {cv.GenerateID(): cv.declare_id(lv_menu_page_t)} + ) + ), + cv.Length(min=1), + ), + cv.Required(CONF_ROOT_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_HEADER_MODE, default="top_fixed"): LV_MENU_MODES.one_of, + cv.Optional( + CONF_ROOT_BACK_BTN, default="disabled" + ): LV_MENU_ROOT_BACK_BUTTON_MODES.one_of, + cv.Optional(CONF_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_SIDEBAR_HEADER_STYLE): part_schema(obj_spec.parts), + cv.Optional(CONF_ON_ROOT_BACK_BUTTON_CLICK): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template())} + ), + } +) + + +class MenuType(WidgetType): + def __init__(self): + super().__init__( + CONF_MENU, lv_menu_t, (CONF_MAIN,), schema=MENU_SCHEMA, modify_schema={} + ) + + def get_uses(self): + return CONF_BUTTON, CONF_IMAGE, TYPE_FLEX + + def validate(self, value: ConfigType) -> ConfigType: + # The sidebar header only exists once a sidebar page has been set. + if CONF_SIDEBAR_HEADER_STYLE in value and CONF_SIDEBAR_PAGE not in value: + raise cv.Invalid( + f"'{CONF_SIDEBAR_HEADER_STYLE}' requires '{CONF_SIDEBAR_PAGE}'", + path=[CONF_SIDEBAR_HEADER_STYLE], + ) + # Runs before the schema, so the default has not been applied yet and an + # invalid value is left for the schema to report. + back_btn = ( + str(value.get(CONF_ROOT_BACK_BTN, "disabled")) + .upper() + .removeprefix(LV_MENU_ROOT_BACK_BUTTON_MODES.prefix) + ) + if CONF_ON_ROOT_BACK_BUTTON_CLICK in value and back_btn != "ENABLED": + raise cv.Invalid( + f"'{CONF_ON_ROOT_BACK_BUTTON_CLICK}' requires " + f"'{CONF_ROOT_BACK_BTN}: enabled'", + path=[CONF_ON_ROOT_BACK_BUTTON_CLICK], + ) + return value + + async def to_code(self, w: Widget, config: dict): + await w.set_property( + "mode_header", await LV_MENU_MODES.process(config[CONF_HEADER_MODE]) + ) + await w.set_property( + "mode_root_back_button", + await LV_MENU_ROOT_BACK_BUTTON_MODES.process(config[CONF_ROOT_BACK_BTN]), + ) + for page_conf in config[CONF_PAGES]: + await widget_to_code(page_conf, menu_page_spec, w.obj) + + root_page = await get_widget_(config[CONF_ROOT_PAGE]) + lv.menu_set_page(w.obj, root_page.obj) + if sidebar_id := config.get(CONF_SIDEBAR_PAGE): + sidebar_page = await get_widget_(sidebar_id) + lv.menu_set_sidebar_page(w.obj, sidebar_page.obj) + + if header_style := config.get(CONF_HEADER_STYLE): + with LocalVariable( + "menu_header", lv_obj_t, rhs=lv_expr.menu_get_main_header(w.obj) + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), header_style) + if sidebar_header_style := config.get(CONF_SIDEBAR_HEADER_STYLE): + with LocalVariable( + "menu_sidebar_header", + lv_obj_t, + rhs=lv_expr.menu_get_sidebar_header(w.obj), + ) as hdr: + await set_obj_properties(Widget(hdr, obj_spec), sidebar_header_style) + + +menu_spec = MenuType() + + +def _menu_page_owners(global_config: FinalValidateConfig) -> dict[str, tuple]: + """ + Map every declared menu page id to the config path of the menu that owns it. + + A page id is always declared inside its menu's `pages:` list, so the owning menu + is three steps up the path (`id` -> list index -> `pages`). + """ + return { + str(declared_id): tuple(path[:-3]) + for declared_id, path in global_config.declare_ids + if declared_id.type is lv_menu_page_t + } + + +def validate_menu_pages(global_config: FinalValidateConfig) -> None: + """ + Check that a menu only ever refers to pages of its own. + + `lv_menu_page_create()` makes a page a child of one specific menu, so handing a + page of another menu to `root_page:`, `sidebar_page:` or `lvgl.menu.set_page` + can only misbehave at run time. It also hangs code generation when the other + menu is defined later, since the waiting menu's page never appears. + """ + owners = _menu_page_owners(global_config) + for menu_path in set(owners.values()): + menu_config = global_config.get_config_for_path(list(menu_path)) + for key in (CONF_ROOT_PAGE, CONF_SIDEBAR_PAGE): + page_id = menu_config.get(key) + if page_id is not None and owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'{page_id}' is not a page of this menu; " + f"'{key}' must name one of the menu's own 'pages:'", + # Paths reported from final validation are relative to the + # component config, so drop the leading domain name. + path=[*menu_path[1:], key], + ) + for config in get_menu_set_page_actions(): + menu_id = config[CONF_ID] + page_id = config[CONF_PAGE] + menu_path = tuple(global_config.get_path_for_id(menu_id)[:-1]) + if owners.get(str(page_id)) != menu_path: + raise cv.Invalid( + f"'lvgl.menu.set_page' page '{page_id}' is not a page of menu '{menu_id}'" + ) + + +async def add_root_back_button_triggers() -> None: + """ + Wire up on_root_back_button_click for every menu, once all widgets exist. + Actions like `lvgl.widget.hide` on the menu itself need the widget map fully + populated, so this must run as a deferred pass rather than inline in + MenuType.to_code -- doing it inline deadlocks the code-generation coroutine + scheduler. + """ + # Snapshot: build_automation() can create widgets (e.g. `lvgl.list.add`). + for w in list(get_widget_map().values()): + if not isinstance(w.type, MenuType): + continue + for conf in w.config.get(CONF_ON_ROOT_BACK_BUTTON_CLICK, ()): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [], conf) + async with LambdaContext(EVENT_ARG, where=conf[CONF_TRIGGER_ID]) as context: + with LvConditional( + lvgl_ns.lv_menu_back_button_is_root_click(w.obj, literal("event")) + ): + lv_add(trigger.trigger()) + lv_obj.add_event_cb( + w.obj, await context.get_lambda(), LV_EVENT.CLICKED, cg.nullptr + ) + + +def _record_set_page_action(config: ConfigType) -> ConfigType: + """Remember the action so validate_menu_pages() can check the page's owner once + the whole config is known.""" + get_menu_set_page_actions().append(config) + return config + + +@automation.register_action( + "lvgl.menu.set_page", + ObjUpdateAction, + cv.All( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_menu_t), + cv.Required(CONF_PAGE): cv.use_id(lv_menu_page_t), + cv.Optional(CONF_SIDEBAR, default=False): cv.boolean, + } + ), + _record_set_page_action, + ), + synchronous=True, +) +async def menu_set_page(config, action_id, template_arg, args): + widget = await get_widgets(config) + page = await get_widget_(config[CONF_PAGE]) + sidebar = config[CONF_SIDEBAR] + + async def do_set_page(w: Widget): + if sidebar: + lv.menu_set_sidebar_page(w.obj, page.obj) + else: + lv.menu_set_page(w.obj, page.obj) + + return await action_to_code(widget, do_set_page, action_id, template_arg, args) diff --git a/tests/component_tests/lvgl/config/menu_test.yaml b/tests/component_tests/lvgl/config/menu_test.yaml new file mode 100644 index 00000000000..df0faeb1bc3 --- /dev/null +++ b/tests/component_tests/lvgl/config/menu_test.yaml @@ -0,0 +1,81 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + widgets: + - menu: + id: test_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: main_page + sidebar_page: nav_page + header_style: + bg_color: 0xFF0000 + sidebar_header_style: + bg_color: 0x00FF00 + on_root_back_button_click: + - logger.log: "root back clicked" + on_value: + - logger.log: "page changed" + pages: + - id: main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: advanced_page + - id: advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug" + - id: nav_page + title: "Navigate" + widgets: + - button: + id: nav_button + text: "Go" + on_click: + - lvgl.menu.set_page: + id: test_menu + page: main_page + sidebar: true diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py new file mode 100644 index 00000000000..287dd927cea --- /dev/null +++ b/tests/component_tests/lvgl/test_menu.py @@ -0,0 +1,407 @@ +"""Tests for the LVGL ``menu`` widget: schema validation for the ``menu``/ +``menu_page``/``menu_section`` widget types and the ``lvgl.menu.set_page`` +action, and the code they generate. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome.__main__ import generate_cpp_contents +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.menu import MENU_PAGE_SCHEMA, MENU_SCHEMA +from esphome.config import load_config, read_config +import esphome.config_validation as cv +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# The menu widget's own schema +# --------------------------------------------------------------------------- + + +class TestMenuSchema: + def test_pages_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['pages'\]" + ): + MENU_SCHEMA({"root_page": "main"}) + + def test_root_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['root_page'\]" + ): + MENU_SCHEMA({"pages": [{"id": "main"}]}) + + def test_sidebar_page_optional(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert "sidebar_page" not in config + + def test_header_mode_and_root_back_btn_defaults(self) -> None: + config = MENU_SCHEMA({"pages": [{"id": "main"}], "root_page": "main"}) + assert config["header_mode"] == "LV_MENU_HEADER_TOP_FIXED" + assert config["root_back_btn"] == "LV_MENU_ROOT_BACK_BUTTON_DISABLED" + + def test_header_mode_accepts_bare_choice(self) -> None: + config = MENU_SCHEMA( + { + "pages": [{"id": "main"}], + "root_page": "main", + "header_mode": "bottom_fixed", + } + ) + assert config["header_mode"] == "LV_MENU_HEADER_BOTTOM_FIXED" + + +# --------------------------------------------------------------------------- +# A menu page's own schema (title optional, sections optional) +# --------------------------------------------------------------------------- + + +class TestMenuPageSchema: + def test_empty_page_is_valid(self) -> None: + config = MENU_PAGE_SCHEMA({}) + assert "title" not in config + assert "sections" not in config + + def test_title_optional(self) -> None: + config = MENU_PAGE_SCHEMA({"title": "Settings"}) + assert config["title"] == "Settings" + + def test_section_id_is_optional(self) -> None: + """cv.GenerateID() means a section's id: is optional, unlike a page's.""" + config = MENU_PAGE_SCHEMA({"sections": [{}]}) + assert config["sections"][0]["id"].is_declaration + + +# --------------------------------------------------------------------------- +# lvgl.menu.set_page action schema +# --------------------------------------------------------------------------- + + +class TestMenuSetPageActionSchema: + @property + def schema(self): + return ACTION_REGISTRY["lvgl.menu.set_page"].raw_schema + + def test_id_and_page_required(self) -> None: + with pytest.raises( + cv.Invalid, match=r"required key not provided @ data\['page'\]" + ): + self.schema({"id": "my_menu"}) + + def test_sidebar_defaults_false(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page"}) + assert config["sidebar"] is False + + def test_sidebar_can_be_set_true(self) -> None: + config = self.schema({"id": "my_menu", "page": "my_page", "sidebar": True}) + assert config["sidebar"] is True + + +# --------------------------------------------------------------------------- +# Pages belong to exactly one menu +# --------------------------------------------------------------------------- + +# Boilerplate around the widgets under test: an LVGL instance needs a display. +MENU_CONFIG_TEMPLATE = """ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: +{widgets} +""" + + +def _config_errors(tmp_path: Path, widgets: str) -> list[str]: + """Validate a config built from the given `widgets:` block and return the + validation errors as text.""" + config_path = tmp_path / "menu.yaml" + config_path.write_text(MENU_CONFIG_TEMPLATE.format(widgets=widgets)) + original_path = CORE.config_path + try: + CORE.config_path = config_path + return [str(err) for err in load_config({}).errors] + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_root_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_empty_pages_list_is_rejected(tmp_path: Path) -> None: + """Without this, a menu with no pages of its own escapes the page-owner check.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_b_page + pages: [] + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("length of value must be at least 1" in err for err in errors) + + +def test_sidebar_header_style_without_sidebar_page_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_header_style: + bg_color: 0x101010 + pages: + - id: menu_a_page +""", + ) + assert any( + "'sidebar_header_style' requires 'sidebar_page'" in err for err in errors + ) + + +def test_root_back_button_click_needs_root_back_button_enabled(tmp_path: Path) -> None: + """With the default `root_back_btn: disabled` the button never appears, so the + trigger could never fire.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + on_root_back_button_click: + - logger.log: "never" + pages: + - id: menu_a_page +""", + ) + assert any( + "'on_root_back_button_click' requires 'root_back_btn: enabled'" in err + for err in errors + ) + + +def test_sidebar_page_of_another_menu_is_rejected(tmp_path: Path) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + sidebar_page: menu_b_page + pages: + - id: menu_a_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any("'menu_b_page' is not a page of this menu" in err for err in errors) + + +def test_set_page_action_with_a_page_of_another_menu_is_rejected( + tmp_path: Path, +) -> None: + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_page + pages: + - id: menu_a_page + widgets: + - button: + text: "Go" + on_click: + - lvgl.menu.set_page: + id: menu_a + page: menu_b_page + - menu: + id: menu_b + root_page: menu_b_page + pages: + - id: menu_b_page +""", + ) + assert any( + "page 'menu_b_page' is not a page of menu 'menu_a'" in err for err in errors + ) + + +def test_section_id_is_not_accepted_where_a_page_is_required(tmp_path: Path) -> None: + """The page and section tag types are distinct, so a section id can't be + passed off as a page.""" + errors = _config_errors( + tmp_path, + """\ + - menu: + id: menu_a + root_page: menu_a_section + pages: + - id: menu_a_page + sections: + - id: menu_a_section +""", + ) + assert any( + "ID 'menu_a_section' of type lv_menu_section_t doesn't inherit from " + "lv_menu_page_t" in err + for err in errors + ) + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared menu-widget YAML config once per + module -- see test_widget_state.py for why this is module-scoped and + inlines the generate_main fixture logic rather than depending on it. + """ + config_path = Path(request.fspath).parent / "config" / "menu_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_menu_created_with_header_and_back_button_modes(main_cpp: str) -> None: + assert "test_menu = lv_menu_create(lvgl_id->get_screen_active());" in main_cpp + assert "lv_menu_set_mode_header(test_menu, LV_MENU_HEADER_TOP_FIXED);" in main_cpp + assert ( + "lv_menu_set_mode_root_back_button(test_menu, LV_MENU_ROOT_BACK_BUTTON_ENABLED);" + in main_cpp + ) + + +def test_page_created_with_title(main_cpp: str) -> None: + assert 'main_page = lv_menu_page_create(test_menu, "Settings");' in main_cpp + assert 'advanced_page = lv_menu_page_create(test_menu, "Advanced");' in main_cpp + + +def test_section_created_under_its_page_not_the_menu(main_cpp: str) -> None: + assert "wifi_section = lv_menu_section_create(main_page);" in main_cpp + assert "wifi_switch = lv_switch_create(wifi_section);" in main_cpp + + +def test_ungrouped_widget_created_directly_under_page(main_cpp: str) -> None: + assert "advanced_button = lv_button_create(main_page);" in main_cpp + + +def test_sections_created_before_ungrouped_widgets(main_cpp: str) -> None: + assert main_cpp.index("wifi_section = lv_menu_section_create(main_page);") < ( + main_cpp.index("advanced_button = lv_button_create(main_page);") + ) + + +def test_root_page_and_sidebar_page_set_at_boot(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, main_page);" in main_cpp + assert "lv_menu_set_sidebar_page(test_menu, nav_page);" in main_cpp + + +def test_header_style_applied_via_main_header_accessor(main_cpp: str) -> None: + assert ( + "lv_obj_t *menu_header_VAR_ = lv_menu_get_main_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_header_VAR_, lv_color_make(255, 0, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_sidebar_header_style_applied_via_sidebar_header_accessor( + main_cpp: str, +) -> None: + assert ( + "lv_obj_t *menu_sidebar_header_VAR_ = lv_menu_get_sidebar_header(test_menu);\n" + "lv_obj_set_style_bg_color(menu_sidebar_header_VAR_, lv_color_make(0, 255, 0), LV_PART_MAIN);" + ) in main_cpp + + +def test_root_back_button_click_guarded_by_helper(main_cpp: str) -> None: + """The on_root_back_button_click trigger must only fire when the guard + helper (which wraps lv_menu_back_button_is_root) returns true. + """ + assert ( + "lv_obj_add_event_cb(test_menu, [](lv_event_t * event) -> void {" + ) in main_cpp + assert ( + "if (lvgl::lv_menu_back_button_is_root_click(test_menu, event)) {\n" + " trigger_id->trigger();\n" + " }" + ) in main_cpp + assert "}, LV_EVENT_CLICKED, nullptr);" in main_cpp + + +def test_set_page_action_targets_main_by_default(main_cpp: str) -> None: + assert "lv_menu_set_page(test_menu, advanced_page);" in main_cpp + + +def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: + assert "lv_menu_set_sidebar_page(test_menu, main_page);" in main_cpp + + +def test_on_value_passes_current_main_page(main_cpp: str) -> None: + assert ( + "->trigger(lv_menu_get_cur_main_page(test_menu), event);\n" + " \n" + "}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr);" + ) in main_cpp diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 952dd5f50f1..3ac6e6a81d6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1777,6 +1777,56 @@ lvgl: - label: text: "2,2" + - menu: + id: settings_menu + header_mode: top_fixed + root_back_btn: enabled + root_page: settings_main_page + sidebar_page: settings_nav_page + header_style: + bg_color: 0x202020 + sidebar_header_style: + bg_color: 0x101010 + on_root_back_button_click: + - logger.log: "settings menu closed at root" + on_value: + - lambda: |- + ESP_LOGD("lvgl", "Menu page %p", page); + pages: + - id: settings_main_page + title: "Settings" + sections: + - id: wifi_section + widgets: + - label: + text: "Wifi" + - switch: + id: wifi_switch + widgets: + - button: + id: open_advanced_button + text: "Advanced" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_advanced_page + - id: settings_advanced_page + title: "Advanced" + widgets: + - label: + text: "Debug options" + - id: settings_nav_page + title: "Navigate" + widgets: + - button: + id: nav_to_main_button + text: "Go to Settings" + on_click: + - lvgl.menu.set_page: + id: settings_menu + page: settings_main_page + sidebar: true + # Sized from its text font, with a colour given as an id - color_picker: id: lv_color_picker_1 diff --git a/tests/components/lvgl/validate.host.yaml b/tests/components/lvgl/validate.host.yaml index 77db404a6ec..afdb89b9499 100644 --- a/tests/components/lvgl/validate.host.yaml +++ b/tests/components/lvgl/validate.host.yaml @@ -1,5 +1,5 @@ esphome: - name: lvgl-list-validate + name: lvgl-validate host: @@ -15,6 +15,46 @@ display: lvgl: displays: sdl0 widgets: + # A menu with no sidebar_page (optional), no page titles (optional) and a + # single empty page (no sections, no widgets) as its root. + - menu: + id: menu_a + root_page: menu_a_empty_page + pages: + - id: menu_a_empty_page + # A second, independent menu -- exercises sidebar_page, multiple + # sections, and a page with both sections and ungrouped widgets. + - menu: + id: menu_b + header_mode: bottom_fixed + root_back_btn: enabled + root_page: menu_b_page + sidebar_page: menu_b_nav_page + pages: + - id: menu_b_page + title: "Settings" + sections: + - id: menu_b_section_1 + widgets: + - label: {text: "Wifi"} + - id: menu_b_section_2 + widgets: + - label: {text: "Bluetooth"} + - switch: {} + widgets: + - button: + text: "Advanced" + on_click: + - lvgl.menu.set_page: {id: menu_b, page: menu_b_page} + - id: menu_b_nav_page + widgets: + - button: + text: "Nav" + on_click: + - lvgl.menu.set_page: + id: menu_b + page: menu_b_page + sidebar: true # Two independent lists, each with their own on_add/on_remove and, for list_a, # more than one automation under the same trigger key -- checks that the # per-list trigger bookkeeping is keyed correctly and doesn't require exactly From 6a50f5c166c3882110086db3b783c5721d9d2ff7 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:46:40 +1100 Subject: [PATCH 09/50] [apc_proteous] Cover component for APC Proteous gate motors (#13403) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/apc_proteous/__init__.py | 3 + .../apc_proteous/apc_proteous_cover.cpp | 242 ++++++++++ .../apc_proteous/apc_proteous_cover.h | 52 +++ esphome/components/apc_proteous/cover.py | 33 ++ tests/components/apc_proteous/common.h | 60 +++ tests/components/apc_proteous/common.yaml | 5 + .../cover/apc_proteous_cover_test.cpp | 415 ++++++++++++++++++ .../apc_proteous/test.esp32-idf.yaml | 3 + 9 files changed, 814 insertions(+) create mode 100644 esphome/components/apc_proteous/__init__.py create mode 100644 esphome/components/apc_proteous/apc_proteous_cover.cpp create mode 100644 esphome/components/apc_proteous/apc_proteous_cover.h create mode 100644 esphome/components/apc_proteous/cover.py create mode 100644 tests/components/apc_proteous/common.h create mode 100644 tests/components/apc_proteous/common.yaml create mode 100644 tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp create mode 100644 tests/components/apc_proteous/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index ae813fa1be2..5e7c2eae794 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,7 @@ esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix esphome/components/apc1/* @ademuri +esphome/components/apc_proteous/* @clydebarrow esphome/components/apds9306/* @aodrenah esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core diff --git a/esphome/components/apc_proteous/__init__.py b/esphome/components/apc_proteous/__init__.py new file mode 100644 index 00000000000..0f837d3b9d9 --- /dev/null +++ b/esphome/components/apc_proteous/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["uart"] +DOMAIN = "apc_proteous" diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp new file mode 100644 index 00000000000..f6a8432faa0 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -0,0 +1,242 @@ +#include "apc_proteous_cover.h" +#include "esphome/core/log.h" +#include "esphome/core/application.h" + +namespace esphome::apc_proteous { + +ESPHOME_LOG_TAG(TAG, "apc_proteous.cover"); +static constexpr const char *START_CMD = "*1\r"; // start/stop toggle +static constexpr const char *OPEN_CMD = "*6\r"; +static constexpr const char *CLOSE_CMD = "*7\r"; +static constexpr const char *QUERY_S = "?s\r"; +static constexpr const char *QUERY_X = "?x\r"; + +// Depending on calibration the reported position may stop a little short of the physical limits +// (e.g. 99% at the fully open reed switch), so readings this close to an end count as that end. +static constexpr uint8_t ENDPOINT_MARGIN = 2; + +using namespace esphome::cover; + +void APCProteousCover::setup() { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(this); + } else { + this->position = 0.5f; + } +} + +void APCProteousCover::loop() { + uint8_t data; + while (this->available() > 0) { + if (!this->read_byte(&data)) { + continue; + } + if (data == '\r') { + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_); + this->parse_response_(); + this->rx_len_ = 0; + } + } else if (data != '\n') { + if (this->rx_len_ >= MAX_RESPONSE_LEN) { + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->rx_len_ = 0; + } + this->rx_buffer_[this->rx_len_++] = static_cast(data); + } + } +} + +void APCProteousCover::parse_response_() { + // Expected format: "?s=XX" or "?x=XX" where XX is a hex value + if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') { + ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_); + return; + } + + const char *hex_str = this->rx_buffer_ + 3; + char *end_ptr; + uint8_t value = static_cast(strtol(hex_str, &end_ptr, 16)); + if (end_ptr == hex_str) { + ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_); + return; + } + + bool state_changed = false; + char type = this->rx_buffer_[1]; + if (type == 's') { + // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) + CoverOperation new_operation = COVER_OPERATION_IDLE; + if (value & 0x01) { + new_operation = (value & 0x02) ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; + } + if (this->current_operation != new_operation) { + this->current_operation = new_operation; + state_changed = true; + } + // Motion in the commanded direction acknowledges the pending command + if ((this->pending_command_ == OPEN_CMD && new_operation == COVER_OPERATION_OPENING) || + (this->pending_command_ == CLOSE_CMD && new_operation == COVER_OPERATION_CLOSING)) { + this->clear_pending_(); + } + // A partial move is over once the acknowledged motion has stopped, wherever it stopped, so an + // old target cannot halt a movement started from the wall button or the remote later on + if (new_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + this->target_position_.reset(); + } + ESP_LOGV(TAG, "s-status: 0x%02X (operation=%d)", value, new_operation); + } else if (type == 'x') { + // Position percentage, snapped to the ends so is_open/is_closed can latch despite calibration offsets + float new_position; + if (value + ENDPOINT_MARGIN >= 100) { + new_position = COVER_OPEN; + } else if (value <= ENDPOINT_MARGIN) { + new_position = COVER_CLOSED; + } else { + new_position = value / 100.0f; + } + if (this->position != new_position) { + this->position = new_position; + state_changed = true; + // Until the controller has confirmed motion in the commanded direction, current_operation may + // still show the previous one, so a reversing partial move must not be judged against it + if (this->target_position_.has_value() && this->pending_command_ == nullptr && + ((this->current_operation == COVER_OPERATION_OPENING && this->position >= *this->target_position_) || + (this->current_operation == COVER_OPERATION_CLOSING && this->position <= *this->target_position_))) { + ESP_LOGD(TAG, "Target position %.2f reached", *this->target_position_); + this->stop_cmd_(App.get_loop_component_start_time()); + } + } + ESP_LOGV(TAG, "x-status: %d%% (position=%.2f)", value, this->position); + } + + if (state_changed) { + this->publish_state(); + } +} + +void APCProteousCover::clear_pending_() { + this->pending_command_ = nullptr; + this->command_retries_ = 0; +} + +void APCProteousCover::retry_pending_command_(uint32_t now) { + if (this->pending_command_ == nullptr || (now - this->pending_command_time_) < COMMAND_ACK_MS) { + return; + } + bool is_open_cmd = this->pending_command_ == OPEN_CMD; + // Nothing to resend once the gate is at the commanded end + if ((is_open_cmd && this->position >= COVER_OPEN) || (!is_open_cmd && this->position <= COVER_CLOSED)) { + this->clear_pending_(); + return; + } + const LogString *label = is_open_cmd ? LOG_STR("open") : LOG_STR("close"); + if (this->command_retries_ >= MAX_COMMAND_RETRIES) { + ESP_LOGW(TAG, "Gate did not acknowledge %s command after %u retries", LOG_STR_ARG(label), this->command_retries_); + this->clear_pending_(); + this->target_position_.reset(); + return; + } + this->command_retries_++; + ESP_LOGD(TAG, "Gate did not respond, resending %s command (retry %u)", LOG_STR_ARG(label), this->command_retries_); + this->pending_command_time_ = now; + this->write_command_(this->pending_command_, now); +} + +void APCProteousCover::update() { + const uint32_t now = App.get_loop_component_start_time(); + this->retry_pending_command_(now); + + // Let the command and its echo clear the line before the next query + if ((now - this->last_command_tx_) < COMMAND_QUIET_MS) { + return; + } + // The line is idle, so anything left in the buffer is an unterminated echo + if (this->rx_len_ != 0) { + this->rx_buffer_[this->rx_len_] = '\0'; + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_); + this->rx_len_ = 0; + } + + const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; + ESP_LOGVV(TAG, "tx: %s", query); + this->write_str(query); + this->query_s_next_ = !this->query_s_next_; +} + +void APCProteousCover::dump_config() { + LOG_COVER("", "APC Proteous Cover", this); + LOG_UPDATE_INTERVAL(this); +} + +CoverTraits APCProteousCover::get_traits() { + auto traits = CoverTraits(); + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void APCProteousCover::write_command_(const char *cmd, uint32_t now) { + ESP_LOGVV(TAG, "tx: %s", cmd); + this->last_command_tx_ = now; + this->write_str(cmd); +} + +void APCProteousCover::send_command_(const char *cmd) { + // The published state comes only from status reads; this just records the command so a stop + // can cancel it before the controller reports motion, and so it can be resent if dropped. + // The commands are idempotent, so only skip one when the gate is already moving that way. + CoverOperation operation = cmd == OPEN_CMD ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING; + if (this->current_operation == operation) { + return; + } + ESP_LOGD(TAG, "Sending %s command", + operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("open") : LOG_STR_LITERAL("close")); + const uint32_t now = App.get_loop_component_start_time(); + this->pending_command_ = cmd; + this->pending_command_time_ = now; + this->command_retries_ = 0; + this->write_command_(cmd, now); +} + +void APCProteousCover::stop_cmd_(uint32_t now) { + // START_CMD is a start/stop toggle, so it is only sent while the gate is moving, or while a + // movement command is in flight that the controller has not reported yet; otherwise it would start the gate. + if (this->current_operation == COVER_OPERATION_IDLE && this->pending_command_ == nullptr) { + ESP_LOGD(TAG, "Cover already idle, ignoring stop command"); + return; + } + ESP_LOGD(TAG, "Sending stop command"); + this->write_command_(START_CMD, now); + this->clear_pending_(); + this->target_position_.reset(); +} + +void APCProteousCover::control(const CoverCall &call) { + if (call.get_stop()) { + this->stop_cmd_(App.get_loop_component_start_time()); + } else if (call.get_position().has_value()) { + float target = *call.get_position(); + ESP_LOGD(TAG, "Target position: %.2f, current: %.2f", target, this->position); + // There is no position command: a partial target is reached by moving towards it and stopping + // once the reported position gets there + this->target_position_.reset(); + if (target == COVER_OPEN) { + this->send_command_(OPEN_CMD); + } else if (target == COVER_CLOSED) { + this->send_command_(CLOSE_CMD); + } else if (target != this->position) { + this->target_position_ = target; + this->send_command_(target > this->position ? OPEN_CMD : CLOSE_CMD); + } + } else if (call.get_toggle()) { + this->clear_pending_(); + this->target_position_.reset(); + this->write_command_(START_CMD, App.get_loop_component_start_time()); + } +} + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h new file mode 100644 index 00000000000..03c8eb5c4d1 --- /dev/null +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -0,0 +1,52 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/cover/cover.h" + +namespace esphome::apc_proteous { + +class APCProteousCover : public cover::Cover, public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void update() override; + void dump_config() override; + + cover::CoverTraits get_traits() override; + + protected: + void control(const cover::CoverCall &call) override; + void parse_response_(); + void send_command_(const char *cmd); + void write_command_(const char *cmd, uint32_t now); + void stop_cmd_(uint32_t now); + void retry_pending_command_(uint32_t now); + void clear_pending_(); + + // The longest valid frame is "?s=XX"; anything longer is line noise + static constexpr size_t MAX_RESPONSE_LEN = 16; + + // A movement command is acknowledged once the controller reports motion in the commanded + // direction; resend it this often, up to MAX_COMMAND_RETRIES times, since the serial link + // occasionally drops one. The stop toggle is never resent because a spurious toggle could start the gate. + static constexpr uint32_t COMMAND_ACK_MS = 2000; + static constexpr uint8_t MAX_COMMAND_RETRIES = 3; + + // The controller echoes every command, sometimes without a terminator. A query sent too soon + // after a command collides with that echo, so polling pauses for this long after each command. + static constexpr uint32_t COMMAND_QUIET_MS = 250; + + // Set only for a partial target; the gate is stopped when the reported position reaches it + optional target_position_{}; + // Last movement command sent and not yet acknowledged; lets a stop cancel it and drives the retries + const char *pending_command_{nullptr}; + uint32_t pending_command_time_{0}; + uint32_t last_command_tx_{0}; + char rx_buffer_[MAX_RESPONSE_LEN + 1]; + uint8_t rx_len_{0}; + uint8_t command_retries_{0}; + bool query_s_next_{true}; +}; + +} // namespace esphome::apc_proteous diff --git a/esphome/components/apc_proteous/cover.py b/esphome/components/apc_proteous/cover.py new file mode 100644 index 00000000000..4c38589580c --- /dev/null +++ b/esphome/components/apc_proteous/cover.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import cover, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +apc_proteous_ns = cg.esphome_ns.namespace("apc_proteous") +APCProteousCover = apc_proteous_ns.class_( + "APCProteousCover", cover.Cover, cg.PollingComponent, uart.UARTDevice +) + +CONFIG_SCHEMA = ( + cover.cover_schema(APCProteousCover) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.polling_component_schema("500ms")) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await cover.new_cover(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h new file mode 100644 index 00000000000..a3baa5131de --- /dev/null +++ b/tests/components/apc_proteous/common.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/apc_proteous/apc_proteous_cover.h" + +namespace esphome::apc_proteous::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(const char *data) { this->rx.insert(this->rx.end(), data, data + strlen(data)); } + std::string tx_string() const { return std::string(this->tx.begin(), this->tx.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +class TestableAPCProteousCover : public APCProteousCover { + public: + using APCProteousCover::COMMAND_ACK_MS; + using APCProteousCover::COMMAND_QUIET_MS; + using APCProteousCover::MAX_COMMAND_RETRIES; + using APCProteousCover::MAX_RESPONSE_LEN; + using APCProteousCover::command_retries_; + using APCProteousCover::last_command_tx_; + using APCProteousCover::pending_command_; + using APCProteousCover::pending_command_time_; + using APCProteousCover::rx_len_; + using APCProteousCover::target_position_; +}; + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/common.yaml b/tests/components/apc_proteous/common.yaml new file mode 100644 index 00000000000..1e1217c04ca --- /dev/null +++ b/tests/components/apc_proteous/common.yaml @@ -0,0 +1,5 @@ +cover: + - platform: apc_proteous + id: apc_proteous_gate + name: Gate + device_class: gate diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp new file mode 100644 index 00000000000..566701338b3 --- /dev/null +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -0,0 +1,415 @@ +#include + +#include "esphome/core/application.h" + +#include "../common.h" + +namespace esphome::apc_proteous::testing { + +using cover::COVER_CLOSED; +using cover::COVER_OPEN; +using cover::COVER_OPERATION_CLOSING; +using cover::COVER_OPERATION_IDLE; +using cover::COVER_OPERATION_OPENING; + +namespace { +class APCProteousCoverTest : public ::testing::Test { + protected: + void SetUp() override { + this->cover_.set_uart_parent(&this->uart_); + this->cover_.setup(); + // setup() may restore a saved position, so start every test from a known state. + this->cover_.position = 0.5f; + this->cover_.current_operation = COVER_OPERATION_IDLE; + this->cover_.add_on_state_callback([this]() { this->publishes_++; }); + this->end_quiet_period_(); + } + + void feed_(const char *data) { + this->uart_.push_rx(data); + this->cover_.loop(); + } + + void end_quiet_period_() { + this->cover_.last_command_tx_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_QUIET_MS - 1; + } + + // Pretend the pending command was sent long enough ago for a retry to be due. + void expire_ack_window_() { + this->cover_.pending_command_time_ = + App.get_loop_component_start_time() - TestableAPCProteousCover::COMMAND_ACK_MS - 1; + this->end_quiet_period_(); + } + + void send_position_(float position) { this->cover_.make_call().set_position(position).perform(); } + + MockUARTComponent uart_; + TestableAPCProteousCover cover_; + int publishes_{0}; +}; +} // namespace + +TEST_F(APCProteousCoverTest, TraitsSupportPositionStopAndToggle) { + auto traits = this->cover_.get_traits(); + EXPECT_TRUE(traits.get_supports_position()); + EXPECT_TRUE(traits.get_supports_stop()); + EXPECT_TRUE(traits.get_supports_toggle()); + EXPECT_FALSE(traits.get_is_assumed_state()); +} + +// s-status bit 0 is "operating", bit 1 is the direction (set when closing). +TEST_F(APCProteousCoverTest, SStatusSetsOperation) { + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?s=03\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_CLOSING); + EXPECT_EQ(this->publishes_, 2); + + this->feed_("?s=02\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 3); +} + +TEST_F(APCProteousCoverTest, UnchangedStatusDoesNotPublish) { + this->feed_("?s=00\r"); + EXPECT_EQ(this->publishes_, 0); + this->feed_("?x=32\r"); + EXPECT_EQ(this->publishes_, 0); +} + +// x-status is the position in percent, sent as hex. +TEST_F(APCProteousCoverTest, XStatusSetsPosition) { + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + EXPECT_EQ(this->publishes_, 1); + + this->feed_("?x=4B\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.75f); + EXPECT_EQ(this->publishes_, 2); +} + +TEST_F(APCProteousCoverTest, XStatusNearEndsSnapsToEndpoints) { + this->feed_("?x=62\r"); // 98% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_OPEN); + this->feed_("?x=02\r"); // 2% + EXPECT_FLOAT_EQ(this->cover_.position, COVER_CLOSED); + this->feed_("?x=03\r"); // 3% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.03f); + this->feed_("?x=61\r"); // 97% is outside the margin + EXPECT_FLOAT_EQ(this->cover_.position, 0.97f); +} + +TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { + this->feed_("?x\r"); // too short + this->feed_("!x=10\r"); // wrong start character + this->feed_("?x:10\r"); // missing '=' + this->feed_("?x=zz\r"); // not hex + this->feed_("?q=10\r"); // unknown type + EXPECT_FLOAT_EQ(this->cover_.position, 0.5f); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); + EXPECT_EQ(this->cover_.rx_len_, 0); +} + +TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { + this->feed_("\n?x=\n19\r\n"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +TEST_F(APCProteousCoverTest, FrameSplitAcrossReadsIsAssembled) { + this->feed_("?x="); + EXPECT_EQ(this->publishes_, 0); + this->feed_("19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); +} + +// Noise without a carriage return must not grow the buffer without limit, nor stop the +// next frame from being read. +TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { + this->feed_("0123456789ABCDEF"); + this->feed_("?x=19\r"); + EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); + + this->feed_("0123456789ABCDEF0123456789ABCDEF"); + EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); +} + +TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, CloseSendsCloseCommand) { + this->cover_.make_call().set_command_close().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_NE(this->cover_.pending_command_, nullptr); +} + +// The published state only changes once the controller reports it. +TEST_F(APCProteousCoverTest, CommandDoesNotPublishState) { + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); + EXPECT_EQ(this->publishes_, 0); +} + +TEST_F(APCProteousCoverTest, OpenIsNotResentWhileAlreadyOpening) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CloseIsNotResentWhileAlreadyClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_close().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// An open command reverses a closing gate. +TEST_F(APCProteousCoverTest, OpenIsSentWhileClosing) { + this->feed_("?s=03\r"); + this->cover_.make_call().set_command_open().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +// The stop command toggles start/stop, so sending it to an idle gate would start it. +TEST_F(APCProteousCoverTest, StopIsIgnoredWhenIdle) { + this->cover_.make_call().set_command_stop().perform(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenMoving) { + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A stop straight after an open must cancel it, even before the controller reports motion. +TEST_F(APCProteousCoverTest, StopCancelsUnacknowledgedCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, StopClearsPartialTarget) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->cover_.make_call().set_command_stop().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, ToggleSendsStartCommand) { + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, ToggleClearsPendingCommandAndTarget) { + this->send_position_(0.8f); + this->uart_.tx.clear(); + this->cover_.make_call().set_command_toggle().perform(); + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +TEST_F(APCProteousCoverTest, PartialPositionAboveCurrentOpens) { + this->send_position_(0.8f); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionBelowCurrentCloses) { + this->send_position_(0.2f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); +} + +TEST_F(APCProteousCoverTest, PartialPositionAtCurrentSendsNothing) { + this->send_position_(0.5f); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenOpeningReachesTarget) { + this->send_position_(0.7f); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + + this->feed_("?x=3C\r"); // 60%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=46\r"); // 70% + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + // The gate keeps moving until the controller reports it idle; the stop is not repeated. + this->uart_.tx.clear(); + this->feed_("?x=48\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, StopIsSentWhenClosingReachesTarget) { + this->send_position_(0.3f); + this->feed_("?s=03\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, short of the target + EXPECT_TRUE(this->uart_.tx.empty()); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// A full open runs to the limit switch, so the position must not trigger a stop. +TEST_F(APCProteousCoverTest, FullOpenIsNotStoppedByPosition) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=50\r"); + this->feed_("?x=64\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +// A full open replaces an earlier partial target. +TEST_F(APCProteousCoverTest, FullOpenCancelsPartialTarget) { + this->send_position_(0.7f); + this->cover_.make_call().set_command_open().perform(); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + this->feed_("?s=01\r"); + this->uart_.tx.clear(); + this->feed_("?x=46\r"); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, ReportedMotionAcknowledgesCommand) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?s=03\r"); // motion in the wrong direction does not count + EXPECT_NE(this->cover_.pending_command_, nullptr); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedBeforeAckTimeout) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, UnacknowledgedCommandIsRetried) { + this->cover_.make_call().set_command_close().perform(); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + EXPECT_EQ(this->cover_.command_retries_, 1); +} + +TEST_F(APCProteousCoverTest, RetriesStopAfterLimit) { + this->cover_.make_call().set_command_open().perform(); + for (int i = 0; i < TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "*6\r"); + } + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_EQ(this->cover_.command_retries_, 0); +} + +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenRetriesRunOut) { + this->send_position_(0.8f); + for (int i = 0; i <= TestableAPCProteousCover::MAX_COMMAND_RETRIES; i++) { + this->expire_ack_window_(); + this->cover_.update(); + } + EXPECT_EQ(this->cover_.pending_command_, nullptr); + EXPECT_FALSE(this->cover_.target_position_.has_value()); +} + +// While a reversing command is unconfirmed, the reported direction is still the old one and must +// not be compared with the target. +TEST_F(APCProteousCoverTest, ReversingPartialMoveWaitsForConfirmedDirection) { + this->feed_("?s=01\r"); + this->send_position_(0.3f); + EXPECT_EQ(this->uart_.tx_string(), "*7\r"); + this->uart_.tx.clear(); + + this->feed_("?x=28\r"); // 40%, still reported as opening + EXPECT_TRUE(this->uart_.tx.empty()); + + this->feed_("?s=03\r"); + this->feed_("?x=1C\r"); // 28%, past the target + EXPECT_EQ(this->uart_.tx_string(), "*1\r"); +} + +// The gate reports idle right after a command, before it starts moving; that must not drop the target. +TEST_F(APCProteousCoverTest, PartialTargetIsKeptWhileCommandIsUnconfirmed) { + this->send_position_(0.8f); + this->feed_("?s=00\r"); + EXPECT_TRUE(this->cover_.target_position_.has_value()); +} + +// A partial move that stops short of its target must not stop a later move started from the remote. +TEST_F(APCProteousCoverTest, PartialTargetIsClearedWhenConfirmedMoveStops) { + this->send_position_(0.8f); + this->feed_("?s=01\r"); + this->feed_("?s=00\r"); + EXPECT_FALSE(this->cover_.target_position_.has_value()); + + this->uart_.tx.clear(); + this->feed_("?s=01\r"); + this->feed_("?x=55\r"); // 85% + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(APCProteousCoverTest, CommandIsNotRetriedOnceAtEndpoint) { + this->cover_.make_call().set_command_open().perform(); + this->feed_("?x=64\r"); + this->uart_.tx.clear(); + this->expire_ack_window_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string().find("*6\r"), std::string::npos); + EXPECT_EQ(this->cover_.pending_command_, nullptr); +} + +TEST_F(APCProteousCoverTest, UpdateAlternatesQueries) { + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?x\r"); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { + this->cover_.make_call().set_command_open().perform(); + this->uart_.tx.clear(); + this->cover_.update(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->end_quiet_period_(); + this->cover_.update(); + EXPECT_EQ(this->uart_.tx_string(), "?s\r"); +} + +// A command echo left without a terminator must not merge with the next response. +TEST_F(APCProteousCoverTest, UpdateDiscardsStaleEcho) { + this->feed_("*6"); + EXPECT_NE(this->cover_.rx_len_, 0); + this->cover_.update(); + EXPECT_EQ(this->cover_.rx_len_, 0); + this->feed_("?s=01\r"); + EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); +} + +} // namespace esphome::apc_proteous::testing diff --git a/tests/components/apc_proteous/test.esp32-idf.yaml b/tests/components/apc_proteous/test.esp32-idf.yaml new file mode 100644 index 00000000000..954a2def240 --- /dev/null +++ b/tests/components/apc_proteous/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + apc_proteous: !include common.yaml From 62e3c25c83206d114ea9cf85a28ada7d8012cf09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 21:50:47 -1000 Subject: [PATCH 10/50] [lvgl] Fix the menu test after lambdas gained a line reset (#20798) --- tests/component_tests/lvgl/test_menu.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/component_tests/lvgl/test_menu.py b/tests/component_tests/lvgl/test_menu.py index 287dd927cea..b496075e934 100644 --- a/tests/component_tests/lvgl/test_menu.py +++ b/tests/component_tests/lvgl/test_menu.py @@ -6,6 +6,7 @@ action, and the code they generate. from __future__ import annotations from pathlib import Path +import re import pytest @@ -400,8 +401,10 @@ def test_set_page_action_targets_sidebar_when_flagged(main_cpp: str) -> None: def test_on_value_passes_current_main_page(main_cpp: str) -> None: - assert ( - "->trigger(lv_menu_get_cur_main_page(test_menu), event);\n" - " \n" - "}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr);" - ) in main_cpp + callback = re.search( + r"add_event_cb\(test_menu, (.*?)\}, LV_EVENT_VALUE_CHANGED, lvgl::lv_update_event, nullptr\);", + main_cpp, + re.DOTALL, + ) + assert callback is not None + assert "->trigger(lv_menu_get_cur_main_page(test_menu), event);" in callback[1] From 7b08e738d3340aa793627b713597c63517f3d093 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 10:13:58 +0200 Subject: [PATCH 11/50] [fusb302b] Add sensors, contract text sensor and automations (#20796) --- esphome/components/fusb302b/__init__.py | 98 +++++++++++- esphome/components/fusb302b/automation.h | 55 +++++++ esphome/components/fusb302b/fusb302b.cpp | 181 ++++++++++++++++++++++- esphome/components/fusb302b/fusb302b.h | 73 ++++++++- esphome/components/fusb302b/pd.cpp | 9 +- esphome/components/fusb302b/pd.h | 6 +- tests/components/fusb302b/common.yaml | 23 +++ tests/components/fusb302b/pd_test.cpp | 42 ++++++ 8 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 esphome/components/fusb302b/automation.h diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 212ef057e94..c9dd24463b4 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,19 +1,43 @@ -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import i2c, sensor, text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.const import ( + CONF_CURRENT, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_ON_CONNECT, + CONF_ON_DISCONNECT, + CONF_ON_ERROR, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) from esphome.types import ConfigType +AUTO_LOAD = ["sensor", "text_sensor"] CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" +CONF_CONTRACT = "contract" +CONF_ON_POWER_READY = "on_power_ready" +CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" fusb302b_ns = cg.esphome_ns.namespace("fusb302b") FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) +ConnectForwarder = fusb302b_ns.class_("ConnectForwarder") +DisconnectForwarder = fusb302b_ns.class_("DisconnectForwarder") +PowerReadyForwarder = fusb302b_ns.class_("PowerReadyForwarder") +ErrorForwarder = fusb302b_ns.class_("ErrorForwarder") + # USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the # requested voltage is chosen. validate_voltage = cv.int_range(min=5, max=20) @@ -24,6 +48,29 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FUSB302B), cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + 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, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), + cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + cv.Optional(CONF_ON_REQUEST_FAILED): automation.validate_automation({}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -31,6 +78,24 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "add_on_state_callback", forwarder=ConnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, "add_on_state_callback", forwarder=DisconnectForwarder + ), + automation.CallbackAutomation( + CONF_ON_POWER_READY, "add_on_state_callback", forwarder=PowerReadyForwarder + ), + automation.CallbackAutomation( + CONF_ON_ERROR, "add_on_state_callback", forwarder=ErrorForwarder + ), + automation.CallbackAutomation( + CONF_ON_REQUEST_FAILED, "add_on_request_failed_callback" + ), +) + async def to_code(config: ConfigType) -> None: interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) @@ -38,3 +103,30 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +automation.register_apply_action( + "fusb302b.request_voltage", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(FUSB302B), + cv.Required(CONF_VOLTAGE): cv.templatable(validate_voltage), + }, + key=CONF_VOLTAGE, + ), + automation.ApplyField(CONF_VOLTAGE, "request_voltage", cg.int_), +) + +automation.register_apply_condition( + "fusb302b.is_connected", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(FUSB302B)}), + "is_connected()", +) diff --git a/esphome/components/fusb302b/automation.h b/esphome/components/fusb302b/automation.h new file mode 100644 index 00000000000..4d1ce39db25 --- /dev/null +++ b/esphome/components/fusb302b/automation.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "esphome/core/automation.h" +#include "pd.h" + +namespace esphome::fusb302b { + +/// Triggers when a source is attached, including after recovering from a communication error. +struct ConnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (is_connected_state(state) && !is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when the source is removed, or is no longer known to be attached because communication failed. +struct DisconnectForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (!is_connected_state(state) && is_connected_state(previous)) + this->automation->trigger(); + } +}; + +/// Triggers when negotiation is finished: a new PD contract is in place, or the source does not speak PD. +struct PowerReadyForwarder { + Automation<> *automation; + void operator()(PdState state, PdState /*previous*/) const { + if (state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT) + this->automation->trigger(); + } +}; + +/// Triggers when communication with the chip fails. +struct ErrorForwarder { + Automation<> *automation; + void operator()(PdState state, PdState previous) const { + if (state == PdState::PD_STATE_ERROR && previous != PdState::PD_STATE_ERROR) + this->automation->trigger(); + } +}; + +static_assert(sizeof(ConnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(DisconnectForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(PowerReadyForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); +static_assert(sizeof(ErrorForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v); + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 6cffbf114f7..479981f8cac 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -2,6 +2,10 @@ #ifdef USE_ESP32 +#include +#include + +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "fusb302b_registers.h" @@ -13,6 +17,7 @@ ESPHOME_LOG_TAG(TAG, "fusb302b"); // Task notification bits static constexpr uint32_t NOTIFY_IRQ = 1 << 0; static constexpr uint32_t NOTIFY_STOP = 1 << 1; +static constexpr uint32_t NOTIFY_REQUEST = 1 << 2; static constexpr uint32_t TASK_STACK_SIZE = 4096; static constexpr UBaseType_t TASK_PRIORITY = 18; @@ -71,6 +76,97 @@ void FUSB302B::setup() { this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } +void FUSB302B::queue_event_(uint8_t event) { + const uint8_t head = this->event_queue_head_.load(std::memory_order_relaxed); + if (static_cast(head - this->event_queue_tail_.load(std::memory_order_acquire)) < EVENT_QUEUE_SIZE) { + this->event_queue_[head % EVENT_QUEUE_SIZE] = event; + this->event_queue_head_.store(head + 1, std::memory_order_release); + } else { + if (event == EVENT_REQUEST_FAILED) + this->dropped_request_failures_.fetch_add(1, std::memory_order_relaxed); + this->event_queue_overflow_ = true; + } + this->enable_loop_soon_any_context(); +} + +void FUSB302B::on_state_changed() { + const PdState state = this->state_; + if (state == this->queued_state_) { + // Only the contract changed + this->enable_loop_soon_any_context(); + return; + } + this->queued_state_ = state; + this->queue_event_(static_cast(state)); +} + +void FUSB302B::on_request_failed() { this->queue_event_(EVENT_REQUEST_FAILED); } + +void FUSB302B::loop() { + const uint32_t contract = this->contract_; + if (contract != this->published_contract_) { + this->published_contract_ = contract; + this->publish_contract_(PackedContract{contract}); + } + + uint8_t tail = this->event_queue_tail_.load(std::memory_order_relaxed); + const uint8_t head = this->event_queue_head_.load(std::memory_order_acquire); + while (tail != head) { + const uint8_t event = this->event_queue_[tail % EVENT_QUEUE_SIZE]; + this->event_queue_tail_.store(++tail, std::memory_order_release); + if (event == EVENT_REQUEST_FAILED) { + this->request_failed_callback_.call(); + } else { + this->publish_state_(static_cast(event)); + } + } + if (this->event_queue_overflow_.exchange(false)) { + ESP_LOGW(TAG, "Events arrived faster than the main loop handled them; some state triggers were skipped"); + // Drop the queued states before reading the state, so no older state is replayed after it. Request failures + // are only counted, so every one of them still fires. + uint8_t failures = this->dropped_request_failures_.exchange(0, std::memory_order_relaxed); + const uint8_t end = this->event_queue_head_.load(std::memory_order_acquire); + for (; tail != end; tail++) { + if (this->event_queue_[tail % EVENT_QUEUE_SIZE] == EVENT_REQUEST_FAILED) + failures++; + } + this->event_queue_tail_.store(end, std::memory_order_release); + this->publish_state_(this->state_); + for (; failures > 0; failures--) + this->request_failed_callback_.call(); + } + // Re-enabled by on_state_changed() or on_request_failed() from the PHY task + this->disable_loop(); +} + +void FUSB302B::publish_state_(PdState state) { + if (state == this->published_state_) + return; + const PdState previous = this->published_state_; + this->published_state_ = state; + this->state_callback_.call(state, previous); +} + +void FUSB302B::publish_contract_(PackedContract contract) { +#ifdef USE_SENSOR + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(contract.voltage()); + if (this->current_sensor_ != nullptr) + this->current_sensor_->publish_state(contract.current()); +#endif +#ifdef USE_TEXT_SENSOR + if (this->contract_text_sensor_ != nullptr) { + if (!contract.is_set()) { + this->contract_text_sensor_->publish_state("Detached"); + } else { + char buf[24]; + snprintf(buf, sizeof(buf), "%.2fA @ %.0fV", contract.current(), contract.voltage()); + this->contract_text_sensor_->publish_state(buf); + } + } +#endif +} + void FUSB302B::dump_config() { ESP_LOGCONFIG(TAG, "FUSB302B:\n" @@ -78,6 +174,13 @@ void FUSB302B::dump_config() { this->get_request_voltage()); LOG_I2C_DEVICE(this); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Contract", this->contract_text_sensor_); +#endif } void FUSB302B::on_shutdown() { @@ -87,6 +190,23 @@ void FUSB302B::on_shutdown() { xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); } +void FUSB302B::request_voltage(int voltage) { + if (voltage < MIN_REQUEST_VOLTAGE || voltage > MAX_REQUEST_VOLTAGE) { + ESP_LOGW(TAG, "Requested %d V is outside %u-%u V, clamping", voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + this->set_request_voltage(static_cast(std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE))); + if (this->is_failed()) { + ESP_LOGW(TAG, "Cannot request %u V, the component failed", this->get_request_voltage()); + // loop() no longer runs, so fire the trigger from the scheduler. Calling it here directly would recurse if the + // automation requests a voltage again. + App.scheduler.set_timeout(this, 0, [this]() { this->request_failed_callback_.call(); }); + return; + } + // Before setup() the voltage is simply used at the first attach + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_REQUEST, eSetBits); +} + void FUSB302B::run_task_() { // Give the source time to settle after power-up before the first CC measurement vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); @@ -94,7 +214,8 @@ void FUSB302B::run_task_() { while (true) { uint32_t wait_ms = CC_POLL_INTERVAL_MS; if (this->attached_) { - const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_(); + const bool busy = this->waiting_for_source_caps_ || this->request_pending_ || this->retry_request_pending_ || + this->check_ams_(); wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; } uint32_t bits = 0; @@ -104,6 +225,10 @@ void FUSB302B::run_task_() { while (true) vTaskSuspend(nullptr); } + if (bits & NOTIFY_REQUEST) { + // When unattached, the new voltage is simply used at the next attach + this->request_pending_ = this->attached_; + } // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is // already low has no falling edge: keep servicing while the pin stays asserted. @@ -118,6 +243,11 @@ void FUSB302B::run_task_() { } this->check_transition_timeout_(); this->check_request_retry_(); + // Waits for a pending Wait retry to end in a contract or a failure, so the two sequences never overlap + if (this->request_pending_ && !this->retry_request_pending_ && !this->check_ams_()) { + this->request_pending_ = false; + this->start_negotiation_(); + } this->check_source_caps_(); } } @@ -341,6 +471,7 @@ void FUSB302B::detach_() { ESP_LOGD(TAG, "Detached (VBUS lost)"); this->attached_ = false; this->waiting_for_source_caps_ = false; + this->request_pending_ = false; this->reset_pd_(); this->clear_contract_(); this->set_state_(PdState::PD_STATE_DISCONNECTED); @@ -348,7 +479,8 @@ void FUSB302B::detach_() { void FUSB302B::handle_hard_reset_() { ESP_LOGD(TAG, "Hard reset received"); - // The source returns to Type-C default power and sends its capabilities again + // The source returns to Type-C default power and sends its capabilities again, using the current request voltage + this->request_pending_ = false; // Also flushes the RX FIFO, so the caller has nothing left to read if (!this->reset_pd_()) { this->enter_error_(); @@ -364,17 +496,49 @@ void FUSB302B::enter_error_() { ESP_LOGW(TAG, "Communication with the chip failed"); this->attached_ = false; this->waiting_for_source_caps_ = false; + this->request_pending_ = false; + // Also stops a pending Wait retry, which would otherwise send its request to the failed chip + this->reset_protocol_(); this->clear_contract_(); this->set_state_(PdState::PD_STATE_ERROR); } +void FUSB302B::start_negotiation_() { + ESP_LOGD(TAG, "Requesting source capabilities for %u V", this->get_request_voltage()); + // send_message() logs a failure; the soft reset is the next step either way, so do not wait for an answer + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_GET_SENT); +} + void FUSB302B::send_soft_reset_() { - this->reset_pd_(); - this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET)); + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + if (!this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET))) { + // No answer can come, so do not wait for one + this->source_caps_failed_(); + return; + } // The source answers a soft reset with new source capabilities this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); } +void FUSB302B::source_caps_failed_() { + this->waiting_for_source_caps_ = false; + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) { + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + } else { + // A request made at runtime got no answer. A soft reset keeps the power level, so the contract in place stays. + ESP_LOGW(TAG, "Renegotiation failed, keeping the current contract"); + } + this->on_request_failed(); +} + void FUSB302B::start_source_cap_wait_(SourceCapStep step) { this->waiting_for_source_caps_ = true; this->source_cap_step_ = step; @@ -390,7 +554,10 @@ void FUSB302B::check_source_caps_() { if (elapsed < SOURCE_CAP_WAIT_MS) return; ESP_LOGD(TAG, "No source capabilities received, requesting them"); - this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP)); + if (!this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP))) { + this->send_soft_reset_(); + return; + } this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; break; case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: @@ -402,9 +569,7 @@ void FUSB302B::check_source_caps_() { case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: if (elapsed < SOURCE_CAP_RETRY_MS) return; - ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); - this->waiting_for_source_caps_ = false; - this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + this->source_caps_failed_(); return; } this->source_cap_step_start_ = millis(); diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h index 341d64558df..e63944ebd07 100644 --- a/esphome/components/fusb302b/fusb302b.h +++ b/esphome/components/fusb302b/fusb302b.h @@ -2,6 +2,9 @@ #ifdef USE_ESP32 +#include +#include + #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" @@ -9,6 +12,13 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + #include "pd.h" namespace esphome::fusb302b { @@ -16,16 +26,39 @@ namespace esphome::fusb302b { /// FUSB302B USB Type-C / Power Delivery sink controller. /// /// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing -/// even while the main loop is busy. +/// even while the main loop is busy. The main loop only publishes state changes. class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { public: explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} void setup() override; + void loop() override; void dump_config() override; void on_shutdown() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } + /// Negotiate a new contract with the highest source voltage that does not exceed `voltage`. + /// Values outside 5-20 V are clamped with a warning. + void request_voltage(int voltage); + + /// Callbacks receive each new state in order, with the state before it. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + /// Called when a voltage request was rejected, got no answer, or was dropped because the component failed. + /// A contract already in place stays valid. + template void add_on_request_failed_callback(F &&callback) { + this->request_failed_callback_.add(std::forward(callback)); + } + +#ifdef USE_SENSOR + void set_voltage_sensor(sensor::Sensor *sensor) { this->voltage_sensor_ = sensor; } + void set_current_sensor(sensor::Sensor *sensor) { this->current_sensor_ = sensor; } +#endif +#ifdef USE_TEXT_SENSOR + void set_contract_text_sensor(text_sensor::TextSensor *sensor) { this->contract_text_sensor_ = sensor; } +#endif + protected: enum class SourceCapStep : uint8_t { SOURCE_CAP_STEP_WAIT, @@ -37,10 +70,11 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { static void task_func(void *arg); bool send_message(const PdMsg &msg) override; - void on_state_changed() override {} + void on_state_changed() override; void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } void on_transition_timeout() override { this->send_soft_reset_(); } + void on_request_failed() override; // PHY task only [[noreturn]] void run_task_(); @@ -54,10 +88,16 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { void detach_(); void handle_hard_reset_(); void start_source_cap_wait_(SourceCapStep step); + void start_negotiation_(); void send_soft_reset_(); + void source_caps_failed_(); void check_source_caps_(); void enter_error_(); + void publish_contract_(PackedContract contract); + void publish_state_(PdState state); + void queue_event_(uint8_t event); + InternalGPIOPin *interrupt_pin_; StaticTask task_; @@ -67,6 +107,35 @@ class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { bool attached_{false}; bool waiting_for_source_caps_{false}; bool cc_ambiguous_logged_{false}; + bool request_pending_{false}; + // Last state put in the queue + PdState queued_state_{PdState::PD_STATE_DISCONNECTED}; + + // State changes and request failures from the PHY task, replayed in order by loop() so no event is lost while the + // main loop is blocked. An entry is a PdState value or EVENT_REQUEST_FAILED. The PHY task only writes head, the + // main loop only writes tail. + static constexpr uint8_t EVENT_QUEUE_SIZE = 8; + static constexpr uint8_t EVENT_REQUEST_FAILED = 0xFF; + std::array event_queue_{}; + std::atomic event_queue_head_{0}; + std::atomic event_queue_tail_{0}; + std::atomic event_queue_overflow_{false}; + // Request failures that did not fit in the queue + std::atomic dropped_request_failures_{0}; + + // Main loop only + PdState published_state_{PdState::PD_STATE_DISCONNECTED}; + uint32_t published_contract_{UINT32_MAX}; + + LazyCallbackManager state_callback_; + LazyCallbackManager request_failed_callback_; +#ifdef USE_SENSOR + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *contract_text_sensor_{nullptr}; +#endif }; } // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp index 4b6931b38fa..28d8e35fe9b 100644 --- a/esphome/components/fusb302b/pd.cpp +++ b/esphome/components/fusb302b/pd.cpp @@ -114,8 +114,14 @@ void PowerDelivery::set_ams_(bool active) { } bool PowerDelivery::check_ams_() { - if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) { this->set_ams_(false); + // Every sequence carries a request. In TRANSITION, check_transition_timeout_() handles the missing PS_RDY. + if (this->state_ != PdState::PD_STATE_TRANSITION) { + ESP_LOGW(TAG, "Source did not complete the request in time"); + this->request_failed_(); + } + } return this->active_ams_; } @@ -148,6 +154,7 @@ void PowerDelivery::request_failed_() { // A contract already in place stays valid. Without one, report that negotiation is over at default power. if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + this->on_request_failed(); } void PowerDelivery::check_transition_timeout_() { diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h index 66918ce98da..b9712f1a673 100644 --- a/esphome/components/fusb302b/pd.h +++ b/esphome/components/fusb302b/pd.h @@ -125,6 +125,9 @@ class PowerDelivery { /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; /// the PHY sends a soft reset so the source offers its capabilities again. virtual void on_transition_timeout() = 0; + /// Called when a request ended without a new contract: it was rejected, the source kept answering Wait or did not + /// answer in time, or it could not be sent. A contract already in place stays valid. + virtual void on_request_failed() = 0; void handle_message_(const PdMsg &msg); /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. @@ -137,7 +140,8 @@ class PowerDelivery { /// contract. void set_state_and_contract_(PdState state, const PdContract &contract); void set_ams_(bool active); - /// Returns true while an atomic message sequence is in progress; ends it after a timeout. + /// Returns true while an atomic message sequence is in progress. Ends it after a timeout, which fails the + /// request unless the source already accepted a new contract. bool check_ams_(); /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. void check_transition_timeout_(); diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e684462ba7c..94e5f2da2bc 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -2,3 +2,26 @@ fusb302b: id: pd interrupt_pin: ${interrupt_pin} request_voltage: 20 + contract: + name: PD Contract + voltage: + name: PD Voltage + current: + name: PD Current + on_connect: + - logger.log: "Source attached" + on_disconnect: + - logger.log: "Source removed" + on_power_ready: + - if: + condition: + fusb302b.is_connected: pd + then: + - logger.log: "Power ready" + on_request_failed: + - logger.log: "Voltage request failed" + on_error: + - fusb302b.request_voltage: 5 + - fusb302b.request_voltage: + id: pd + voltage: !lambda "return 9;" diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp index c122d59b039..41e7e1b358a 100644 --- a/tests/components/fusb302b/pd_test.cpp +++ b/tests/components/fusb302b/pd_test.cpp @@ -31,6 +31,7 @@ class FakePhy : public PowerDelivery { int soft_resets{0}; int source_caps{0}; int transition_timeouts{0}; + int request_failures{0}; void receive(uint8_t type, uint8_t id, std::vector objects = {}) { PdMsg msg; @@ -70,6 +71,7 @@ class FakePhy : public PowerDelivery { this->source_caps++; } void on_transition_timeout() override { this->transition_timeouts++; } + void on_request_failed() override { this->request_failures++; } }; TEST(FUSB302BPd, HeaderRoundTrip) { @@ -193,6 +195,38 @@ TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { pd.advance_ms(2001); pd.receive(PD_CNTRL_PS_RDY, 2); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + // The missing PS_RDY is a transition timeout, not a failed request + EXPECT_EQ(pd.request_failures, 0); +} + +TEST(FUSB302BPd, UnansweredFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + EXPECT_TRUE(pd.check_ams_()); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); + // Reported once + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.request_failures, 1); +} + +TEST(FUSB302BPd, MissingPsRdyForSameContractFailsRequest) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + pd.advance_ms(2001); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { @@ -255,9 +289,11 @@ TEST(FUSB302BPd, WaitRetriesStopAtLimit) { pd.check_request_retry_(); } EXPECT_EQ(pd.sent.size(), 11u); + EXPECT_EQ(pd.request_failures, 0); pd.receive(PD_CNTRL_WAIT, 3); EXPECT_FALSE(pd.retry_request_pending_); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); pd.advance_ms(101); pd.check_request_retry_(); EXPECT_EQ(pd.sent.size(), 11u); @@ -273,6 +309,7 @@ TEST(FUSB302BPd, FailedRequestEndsNegotiation) { EXPECT_FALSE(pd.check_ams_()); EXPECT_FALSE(pd.retry_request_pending_); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); pd.receive(PD_CNTRL_ACCEPT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); @@ -312,6 +349,7 @@ TEST(FUSB302BPd, FailedRetriesStopAtLimit) { // The first request, then one attempt for each of the 10 allowed repeats EXPECT_EQ(pd.send_attempts, 11); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { @@ -322,6 +360,7 @@ TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { pd.receive(PD_CNTRL_REJECT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { @@ -333,9 +372,11 @@ TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { pd.receive(PD_CNTRL_PS_RDY, 2); pd.set_request_voltage(9); pd.receive_source_caps(3); + EXPECT_EQ(pd.request_failures, 0); pd.receive(PD_CNTRL_REJECT, 4); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_EQ(pd.request_failures, 1); } TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { @@ -345,6 +386,7 @@ TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { pd.receive(PD_CNTRL_WAIT, 1); EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.request_failures, 0); } TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { From d3faafc578f2ffda58b140092ec1b810d8de8a6b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:49:50 +1100 Subject: [PATCH 12/50] [display] Always treat 888 pixel blocks as b,g,r in memory (#20799) --- esphome/components/display/display.h | 3 +- .../components/display/display_color_utils.h | 3 +- esphome/components/hub75/hub75.cpp | 6 ++-- esphome/components/it8951/it8951.cpp | 5 +--- esphome/components/mipi_dsi/mipi_dsi.cpp | 30 ++++++------------- esphome/components/mipi_dsi/mipi_dsi.h | 3 -- .../display/test_draw_pixels_at.cpp | 8 ++--- 7 files changed, 21 insertions(+), 37 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 1a45c4d6750..f17b7d86f71 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -357,7 +357,8 @@ class Display : public PollingComponent { * \param ptr A pointer to the start of the data to be copied * \param order The ordering of the colors * \param bitness Defines the number of bits and their format for each pixel - * \param big_endian True if 16 bit values are stored big-endian + * \param big_endian True if 16 bit values are stored big-endian. Ignored for 888, which is always b,g,r + * in memory for RGB order, as LVGL and ESPHome images store it. * \param x_offset The initial x-offset into the source buffer. * \param y_offset The initial y-offset into the source buffer. * \param x_pad How many pixels are in each line after the end of the pixels to be copied. diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 3cf4a54999e..bd658986519 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -23,9 +23,8 @@ class ColorUtil { return (ptr[idx] << 8) + ptr[idx + 1]; return ptr[idx] + (ptr[idx + 1] << 8); } else if constexpr (BITNESS == COLOR_BITNESS_888) { + // Always the LVGL RGB888 layout, b,g,r in memory for RGB order; byte order applies to 565 only const size_t idx = index * 3; - if (big_endian) - return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2]; return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); } else { return ptr[index]; diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 226b59a2912..5f2149747e1 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -160,7 +160,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // 24-bit: 3 bytes per pixel, tightly packed format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - // Note: 24-bit is always RGB order in LVGL + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #else ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); return; @@ -169,7 +170,8 @@ void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, co // Non-LVGL mode: standard 24-bit RGB888 format = Hub75PixelFormat::RGB888; bytes_per_pixel = 3; - color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; + // ESPHome RGB order 888 is b,g,r in memory, which hub75 calls BGR + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::BGR : Hub75ColorOrder::RGB; #endif } else { ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 565f6f7f8bb..3612b795b98 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -987,10 +987,7 @@ void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, c } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = - big_endian - ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] - : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); break; } default: diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index a27be568f25..64166012c20 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -297,15 +297,10 @@ void MipiDsi::draw_pixel_at(int x, int y, Color color) { break; } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - this->buffer_[pos * 3] = color.b; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.r; - } else { - this->buffer_[pos * 3] = color.r; - this->buffer_[pos * 3 + 1] = color.g; - this->buffer_[pos * 3 + 2] = color.b; - } + // The panel applies the colour order, so the buffer keeps the standard b,g,r layout + this->buffer_[pos * 3] = color.b; + this->buffer_[pos * 3 + 1] = color.g; + this->buffer_[pos * 3 + 2] = color.r; break; case display::COLOR_BITNESS_332: break; @@ -341,19 +336,12 @@ void MipiDsi::fill(Color color) { } case display::COLOR_BITNESS_888: - if (this->color_mode_ == display::COLOR_ORDER_BGR) { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.b; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.r; - } - } else { - for (size_t i = 0; i != this->width_ * this->height_; i++) { - this->buffer_[i * 3 + 0] = color.r; - this->buffer_[i * 3 + 1] = color.g; - this->buffer_[i * 3 + 2] = color.b; - } + for (size_t i = 0; i != this->width_ * this->height_; i++) { + this->buffer_[i * 3 + 0] = color.b; + this->buffer_[i * 3 + 1] = color.g; + this->buffer_[i * 3 + 2] = color.r; } + break; default: break; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4df22354681..ba3f29ca05a 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -39,8 +39,6 @@ class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} - display::ColorOrder get_color_mode() { return this->color_mode_; } - void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } @@ -102,7 +100,6 @@ class MipiDsi final : public display::Display { uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes bool invert_colors_{}; - display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; display::ColorBitness color_depth_; uint8_t pixel_mode_{}; diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp index c8c32d15145..1278e2462df 100644 --- a/tests/components/display/test_draw_pixels_at.cpp +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -62,8 +62,7 @@ static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool b TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } -TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); } -TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888) { expect_walks_match(COLOR_BITNESS_888, 3, false); } // One known pixel per bitness and byte order, so a decode that swapped bytes or channels // could not pass the walk comparison above by being wrong the same way twice. @@ -81,8 +80,9 @@ TEST(DrawPixelsAt, Golden565BigEndian) { TEST(DrawPixelsAt, Golden565LittleEndian) { expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green } -TEST(DrawPixelsAt, Golden888BigEndian) { - expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30)); +// 888 is always b,g,r in memory for RGB order, whatever the byte order flag says +TEST(DrawPixelsAt, Golden888IgnoresBigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x30, 0x20, 0x10)); } TEST(DrawPixelsAt, Golden888LittleEndian) { expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); From 49b12daa5803b7cbb44a8329e23ba244163507ae Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:55:19 +1100 Subject: [PATCH 13/50] [epaper_spi] Ensure test card gets drawn (#20804) --- esphome/components/epaper_spi/display.py | 1 + .../config/test_card_update_interval.yaml | 22 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/component_tests/epaper_spi/config/test_card_update_interval.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 06aa53acb69..d04ffd33be8 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -203,6 +203,7 @@ def _final_validate(config) -> None: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = update_interval("1min") elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml new file mode 100644 index 00000000000..fb6eeec8d77 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 4b8f5788925..81e197abf81 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates( # init_sequence_length, ...); a length of 0 confirms the empty init # sequence array was generated instead of raising during code generation. assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) + + +def test_test_card_overrides_update_interval( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With nothing else to draw, update_interval is not left at never for the test card.""" + main_cpp = generate_main(component_config_path("test_card_update_interval.yaml")) + + match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp) + assert match is not None + assert int(match.group(1)) != 4294967295 From d924d2daf72766305bdb1c39b1078b00825f52c1 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Thu, 15 Oct 2026 19:15:11 +0200 Subject: [PATCH 14/50] [core] Extend fast value formatting to four decimal places (#20800) Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com> --- esphome/core/helpers.cpp | 8 +-- esphome/core/helpers.h | 4 +- tests/components/core/test_helpers.cpp | 1 + tests/components/core/test_value_accuracy.cpp | 66 ++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 872bb199084..85fb260192c 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -598,7 +598,7 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Fast float-to-string for accuracy_decimals 0-4 (covers sensors and small PID coefficients). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) { @@ -625,9 +625,9 @@ static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // Fast path for accuracy 0-3, finite values whose scaled magnitude fits in uint32_t. - // For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf. - if (accuracy_decimals <= 3 && std::isfinite(value)) { + // Fast path for accuracy 0-4, finite values whose scaled magnitude fits in uint32_t. + // For 4 decimals that's |value| < ~4.29e5; larger totals fall through to snprintf. + if (accuracy_decimals <= 4 && std::isfinite(value)) { const uint32_t mult = small_pow10(accuracy_decimals); if (std::fabs(value) < static_cast(UINT32_MAX) / mult) { return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e628b02ac94..c51ef4a652d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1445,8 +1445,8 @@ inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, con return buf; } -/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float. -inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } +/// Return 10^n for small non-negative n (0-4) as uint32_t, avoiding float. +inline uint32_t small_pow10(int8_t n) { return n == 4 ? 10000 : n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; } /// Minimum buffer size for uint32_to_str: 10 digits + null terminator. static constexpr size_t UINT32_MAX_STR_SIZE = 11; diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index ac71cf82327..67e112d0cf7 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -166,6 +166,7 @@ TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); } TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); } TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); } TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); } +TEST(SmallPow10, Four) { EXPECT_EQ(small_pow10(4), 10000u); } // --- frac_to_str_unchecked() --- diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp index 381a742a9cd..5ffb84e1296 100644 --- a/tests/components/core/test_value_accuracy.cpp +++ b/tests/components/core/test_value_accuracy.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -64,6 +65,67 @@ TEST(ValueAccuracyToBuf, ThreeDecimals) { EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); } +TEST(ValueAccuracyToBuf, FourDecimals) { + EXPECT_EQ(va_to_string(0.0f, 4), "0.0000"); + EXPECT_EQ(va_to_string(0.0001f, 4), "0.0001"); + EXPECT_EQ(va_to_string(0.0030f, 4), "0.0030"); + EXPECT_EQ(va_to_string(-0.0008f, 4), "-0.0008"); + EXPECT_EQ(va_to_string(23.456f, 4), va_reference(23.456f, 4)); +} + +TEST(ValueAccuracyToBuf, FourDecimalPidSteps) { + for (int step = 0; step <= 30; step++) { + float value = step * 0.0001f; + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "step=" << step; + } +} + +TEST(ValueAccuracyToBuf, FourDecimalRoundingBoundaries) { + for (int step = -1000; step <= 1000; step++) { + float midpoint = static_cast((step + 0.5) / 10000.0); + for (float value : {std::nextafter(midpoint, -INFINITY), midpoint, std::nextafter(midpoint, INFINITY)}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)) << "value=" << value; + } + } + for (float value : {0.03125f, -0.03125f, 0.09375f, -0.09375f, 0.99995f, -0.99995f}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalUint32Boundary) { + float limit = static_cast(UINT32_MAX) / 10000; + for (float value : {std::nextafter(limit, 0.0f), limit, std::nextafter(limit, INFINITY), 1.0e6f, + std::numeric_limits::max()}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + EXPECT_EQ(va_to_string(-value, 4), va_reference(-value, 4)); + } +} + +TEST(ValueAccuracyToBuf, FourDecimalSpecialValuesAndFallback) { + for (float value : {-0.0f, -0.00001f, NAN, INFINITY, -INFINITY}) { + EXPECT_EQ(va_to_string(value, 4), va_reference(value, 4)); + } + EXPECT_EQ(va_to_string(0.00001f, 5), "0.00001"); + EXPECT_EQ(va_to_string(-0.00001f, 5), "-0.00001"); +} + +TEST(ValueAccuracyToBuf, FourDecimalBufferAndLength) { + struct { + char before{'B'}; + char buffer[VALUE_ACCURACY_MAX_LEN]; + char after{'A'}; + } guarded; + for (float value : {0.0f, -0.0f, 0.0001f, -0.0030f, 429496.0f, 1.0e6f}) { + size_t len = value_accuracy_to_buf(std::span(guarded.buffer), value, 4); + EXPECT_EQ(guarded.before, 'B'); + EXPECT_EQ(guarded.after, 'A'); + ASSERT_LT(len, VALUE_ACCURACY_MAX_LEN); + EXPECT_EQ(guarded.buffer[len], '\0'); + EXPECT_EQ(strlen(guarded.buffer), len); + EXPECT_EQ(std::string(guarded.buffer, len), va_reference(value, 4)); + } +} + // --- Negative values --- TEST(ValueAccuracyToBuf, NegativeValues) { @@ -121,7 +183,7 @@ TEST(ValueAccuracyToBuf, Rounding) { TEST(ValueAccuracyToBuf, MatchesSnprintf) { float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; - int8_t test_accuracies[] = {0, 1, 2, 3}; + int8_t test_accuracies[] = {0, 1, 2, 3, 4}; for (float value : test_values) { for (int8_t acc : test_accuracies) { @@ -223,7 +285,7 @@ TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) { TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) { const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"}; float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f}; - int8_t accs[] = {0, 1, 2, 3}; + int8_t accs[] = {0, 1, 2, 3, 4}; for (const char *u : units) { for (float v : values) { for (int8_t a : accs) { From 7f342054fe8ead9d7968a7e62e1f9c571ae92abf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:27:19 -1000 Subject: [PATCH 15/50] [wifi] Retry a failed fallback AP once per ap_timeout and start the captive portal once (#20784) --- .../captive_portal/captive_portal.cpp | 3 ++ esphome/components/wifi/wifi_component.cpp | 38 +++++++++++-------- esphome/components/wifi/wifi_component.h | 11 ++++++ .../wifi/wifi_component_esp_idf.cpp | 2 +- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 0aa48bc3ea0..16ebd407625 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -80,6 +80,9 @@ void CaptivePortal::setup() { this->disable_loop(); } void CaptivePortal::start() { + // Each start takes a web server reference that end() returns once + if (this->active_) + return; this->base_->init(); if (!this->initialized_) { this->base_->add_handler_without_auth(this); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 837c0a1a9d6..156ba6e9ffa 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -696,7 +696,8 @@ void WiFiComponent::setup() { void WiFiComponent::start() { ESP_LOGCONFIG(TAG, "Starting"); - this->last_connected_ = millis(); + // The loop's time, not millis(): a wifi.enable from a trigger in this pass must not stamp a time after now + this->set_last_connected_(App.get_loop_component_start_time()); uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; @@ -890,7 +891,7 @@ void WiFiComponent::loop() { this->retry_connect(); } else { this->status_clear_warning(); - this->last_connected_ = now; + this->set_last_connected_(now); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the @@ -946,12 +947,14 @@ void WiFiComponent::loop() { // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { + const uint32_t since_connected = now - this->last_connected_; if (this->ap_timeout_ != 0 && - (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE - // After a pause, or a start that failed, the networks get a full - // ap_timeout before the AP is tried again. - && now - this->ap_changed_ > this->ap_timeout_ + // After a pause, or a start that failed, the networks get a full ap_timeout before the AP is tried again + since_connected > this->ap_timeout_ && now - this->ap_changed_ > this->ap_timeout_ +#else + // Tried in whole ap_timeout slots after the last connection; after UINT8_MAX slots it is no longer tried + this->ap_start_failures_ != UINT8_MAX && since_connected / this->ap_timeout_ > this->ap_start_failures_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); @@ -959,16 +962,14 @@ void WiFiComponent::loop() { this->ap_changed_ = now; #endif this->setup_ap_config_(); - // A portal is only started behind an AP that came up: the portal keeps a reference to the web server - // on every start, and a failed start is tried again later - if (this->ap_setup_) { -#ifdef USE_CAPTIVE_PORTAL - // Reset so we force one full scan after captive portal starts - // (previous scans were filtered because captive portal wasn't active yet) - this->has_completed_scan_after_captive_portal_start_ = false; -#endif - this->start_ap_portal_(); +#ifndef USE_WIFI_AP_EXCLUSIVE + if (!this->ap_setup_) { + // Skip the next slot too, so at least a full ap_timeout passes wherever in its slot this try ran + uint32_t slot = since_connected / this->ap_timeout_ + 1; + this->ap_start_failures_ = slot < UINT8_MAX ? slot : UINT8_MAX; } +#endif + this->start_ap_portal_(); } } #endif // USE_WIFI_AP @@ -1825,7 +1826,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #endif // The fallback and reboot checks later in this pass must see the connection, not the old timestamp - this->last_connected_ = now; + this->set_last_connected_(now); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); @@ -2441,7 +2442,12 @@ bool WiFiComponent::is_ap_portal_active_() { // global_web_server needs no null check: codegen always instantiates WebServer when // USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. void WiFiComponent::start_ap_portal_() { + // Only behind an AP that came up; a fallback AP (networks and an ap_timeout) is retried by loop() + if (!this->ap_setup_) + return; #ifdef USE_CAPTIVE_PORTAL + // Force one full scan now that the portal lists networks (earlier scans were filtered) + this->has_completed_scan_after_captive_portal_start_ = false; if (captive_portal::global_captive_portal != nullptr) captive_portal::global_captive_portal->start(); #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index db4cbae8389..8cfdaaff00c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -1025,6 +1025,13 @@ class WiFiComponent final : public Component { // the disconnect is treated as roaming-related and the attempts counter is preserved. static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds + void set_last_connected_(uint32_t now) { + this->last_connected_ = now; +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_EXCLUSIVE) + this->ap_start_failures_ = 0; +#endif + } + // 4-byte members float output_power_{NAN}; uint32_t action_started_; @@ -1144,6 +1151,10 @@ class WiFiComponent final : public Component { // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up // and only writes it (a reset) while the AP is down, so no lock is needed. uint8_t ap_clients_{0}; +#ifndef USE_WIFI_AP_EXCLUSIVE + // Fallback AP starts that failed since the last connection; each one waits another ap_timeout + uint8_t ap_start_failures_{0}; +#endif #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c8e1df12f31..4b857b98141 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1306,7 +1306,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { this->dpp_listening_ = false; ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); // The reboot timer starts over for the connect attempt, as after a boot with saved credentials - this->last_connected_ = App.get_loop_component_start_time(); + this->set_last_connected_(App.get_loop_component_start_time()); // Saved like portal credentials; from a cooldown this already restarts the retries this->save_wifi_sta(credentials->ssid, credentials->password); if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { From 93dc80d895dca4a542d3b07190d338a2f36e8c80 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:07 -1000 Subject: [PATCH 16/50] [esp8266] Refuse two components that drive hardware timer 1 (#20794) --- esphome/components/esp8266/__init__.py | 2 + esphome/components/esp8266/const.py | 23 ++++++++ esphome/components/hdmi_cec/__init__.py | 5 ++ esphome/components/lightwaverf/__init__.py | 5 ++ esphome/components/opentherm/__init__.py | 5 ++ script/analyze_component_buses.py | 3 + .../components/esp8266/test_timer1.py | 55 +++++++++++++++++++ 7 files changed, 98 insertions(+) create mode 100644 tests/unit_tests/components/esp8266/test_timer1.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 09ced18fd67..c2a827ca35c 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -57,6 +57,7 @@ from .const import ( PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, check_preferences_fit, + check_timer1_users, enable_serial, enable_serial1, esp8266_ns, @@ -540,6 +541,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) CORE.add_job(check_preferences_fit) + CORE.add_job(check_timer1_users) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 177bf540473..673c1e91eac 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,7 @@ CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" +KEY_TIMER1_USERS = "timer1_users" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator @@ -129,6 +130,28 @@ def require_waveform() -> None: require_waveform() """ CORE.data.setdefault(KEY_ESP8266, {})[KEY_WAVEFORM_REQUIRED] = True + use_timer1("the Arduino waveform generator (esp8266_pwm, ac_dimmer)") + + +def use_timer1(owner: str) -> None: + """Record that a component drives hardware timer 1; check_timer1_users() rejects a second one. + + The ESP8266 has one general purpose timer with one interrupt. Two components that program it + take it from each other, so the configuration is refused instead. Components that go through + the Arduino waveform generator share it and register through require_waveform(). + """ + users = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_TIMER1_USERS, set()) + users.add(owner) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_timer1_users() -> None: + """Fail the build when more than one component drives hardware timer 1.""" + users = sorted(CORE.data[KEY_ESP8266].get(KEY_TIMER1_USERS, ())) + if len(users) > 1: + raise EsphomeError( + f"{' and '.join(users)} each need hardware timer 1 of the ESP8266, which only one of them can drive" + ) def enable_serial() -> None: diff --git a/esphome/components/hdmi_cec/__init__.py b/esphome/components/hdmi_cec/__init__.py index c718968e201..68499d933e3 100644 --- a/esphome/components/hdmi_cec/__init__.py +++ b/esphome/components/hdmi_cec/__init__.py @@ -103,6 +103,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # The acknowledge bit is released from hardware timer 1 + use_timer1(DOMAIN) if CORE.is_esp32: # The acknowledge bit is released from a general purpose timer, left out of the build by default include_builtin_idf_component("esp_driver_gptimer") diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 1a4234ceaf0..5cb18878bc3 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import CORE from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType @@ -65,6 +66,10 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + use_timer1(DOMAIN) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index cfcdfe77833..026951be93d 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -75,6 +75,11 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: dict[str, Any]) -> None: + if CORE.is_esp8266: + from esphome.components.esp8266.const import use_timer1 + + # Per hub: every hub programs the timer and the interrupt has one instance pointer + use_timer1(f"{DOMAIN} {config[CONF_ID]}") if CORE.is_esp32: include_builtin_idf_component("esp_driver_gptimer") diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index fee1438636b..fd60b2452c6 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,10 +87,13 @@ ISOLATED_COMPONENTS = { "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", + "hdmi_cec": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with lightwaverf and opentherm", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", + "lightwaverf": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and opentherm", "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", "openthread": "Conflicts with wifi: used by most components", "openthread_info": "Conflicts with wifi: used by most components", + "opentherm": "Drives hardware timer 1 on the ESP8266, which the esp8266 component refuses to share with hdmi_cec and lightwaverf", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", diff --git a/tests/unit_tests/components/esp8266/test_timer1.py b/tests/unit_tests/components/esp8266/test_timer1.py new file mode 100644 index 00000000000..63ded333b0b --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_timer1.py @@ -0,0 +1,55 @@ +"""Two components that program hardware timer 1 are refused.""" + +import asyncio + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + check_timer1_users, + require_waveform, + use_timer1, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + + +def _check() -> None: + asyncio.run(check_timer1_users()) + + +def test_one_user_passes() -> None: + use_timer1("opentherm ot") + use_timer1("opentherm ot") + _check() + + +def test_two_opentherm_hubs_are_refused() -> None: + use_timer1("opentherm ot1") + use_timer1("opentherm ot2") + with pytest.raises(EsphomeError, match="opentherm ot1 and opentherm ot2"): + _check() + + +def test_two_users_are_refused() -> None: + use_timer1("opentherm") + use_timer1("hdmi_cec") + with pytest.raises( + EsphomeError, match="hdmi_cec and opentherm each need hardware timer 1" + ): + _check() + + +def test_waveform_outputs_share_the_timer_but_not_with_a_direct_user() -> None: + require_waveform() # esp8266_pwm + require_waveform() # ac_dimmer + _check() + use_timer1("lightwaverf") + with pytest.raises(EsphomeError, match="waveform generator"): + _check() From c433e96734e826a6a08527f2953e4b7f9e65cb68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:27 -1000 Subject: [PATCH 17/50] [homeassistant] Share the Python list parser between the platforms (#20793) --- .../components/homeassistant/homeassistant.h | 136 ++++++++++++++++++ .../homeassistant/select/__init__.py | 1 + .../select/homeassistant_select.cpp | 118 +-------------- .../homeassistant/test_python_list.cpp | 62 ++++++++ 4 files changed, 203 insertions(+), 114 deletions(-) create mode 100644 esphome/components/homeassistant/homeassistant.h create mode 100644 tests/components/homeassistant/test_python_list.cpp diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h new file mode 100644 index 00000000000..65eb6ef5fd2 --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.h @@ -0,0 +1,136 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`, or of an enum, such as `[]`, where the + * quoted value stands for the item. + * + * Calls on_char for each decoded byte of an item and on_item at the end of each item. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_python_list(StringRef src, C &&on_char, O &&on_item) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + // An enum member is shown as ; the quoted value is the item + const bool enum_member = pos < end && *pos == '<'; + if (enum_member) { + pos++; + while (pos < end && (std::isalnum(static_cast(*pos)) || *pos == '_' || *pos == '.')) + pos++; + if (pos == end || *pos != ':') + return false; + pos++; + skip_spaces(); + } + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + if (enum_member) { + skip_spaces(); + if (pos == end || *pos != '>') + return false; + pos++; + } + on_item(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index e8ad0fc23b8..1bdf0f2cade 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -13,6 +13,7 @@ from .. import ( CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] CONF_MAX_OPTIONS = "max_options" CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp index 4f4fc7a3295..75b77ca7bb8 100644 --- a/esphome/components/homeassistant/select/homeassistant_select.cpp +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -2,6 +2,8 @@ #include +#include "../homeassistant.h" + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/helpers.h" @@ -13,118 +15,6 @@ namespace esphome::homeassistant { ESPHOME_LOG_TAG(TAG, "homeassistant.select"); -namespace { - -/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. -template bool emit_utf8(uint32_t code_point, C &on_char) { - if (code_point == 0 || code_point > 0x10FFFF) - return false; - if (code_point < 0x80) { - on_char(static_cast(code_point)); - } else if (code_point < 0x800) { - on_char(static_cast(0xC0 | (code_point >> 6))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else if (code_point < 0x10000) { - on_char(static_cast(0xE0 | (code_point >> 12))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } else { - on_char(static_cast(0xF0 | (code_point >> 18))); - on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); - on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); - on_char(static_cast(0x80 | (code_point & 0x3F))); - } - return true; -} - -/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as - * `['Low', "It's high", 'caf\xe9']`. - * - * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false - * on malformed input; the callbacks may already have run by then. - */ -template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { - const char *pos = src.c_str(); - const char *const end = pos + src.size(); - auto skip_spaces = [&]() { - while (pos < end && *pos == ' ') - pos++; - }; - - skip_spaces(); - if (pos == end || *pos != '[') - return false; - pos++; - skip_spaces(); - if (pos < end && *pos == ']') { - pos++; - } else { - while (true) { - skip_spaces(); - if (pos == end || (*pos != '\'' && *pos != '"')) - return false; - const char quote = *pos++; - while (true) { - if (pos == end) - return false; - const char c = *pos++; - if (c == quote) - break; - if (c != '\\') { - on_char(c); - continue; - } - if (pos == end) - return false; - const char escape = *pos++; - switch (escape) { - case '\\': - case '\'': - case '"': - on_char(escape); - break; - case 'n': - on_char('\n'); - break; - case 'r': - on_char('\r'); - break; - case 't': - on_char('\t'); - break; - case 'x': - case 'u': - case 'U': { - const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); - if (static_cast(end - pos) < digits) - return false; - auto code_point = parse_hex(pos, digits); - if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) - return false; - pos += digits; - break; - } - default: - return false; - } - } - on_option(); - skip_spaces(); - if (pos == end) - return false; - const char separator = *pos++; - if (separator == ']') - break; - if (separator != ',') - return false; - } - } - skip_spaces(); - return pos == end; -} - -} // namespace - void HomeassistantSelect::setup() { this->options_buffer_ = std::make_unique(this->options_buffer_size_); this->option_list_ = std::make_unique(this->max_options_); @@ -146,7 +36,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t match_len = 0; bool matching = active != nullptr; optional new_active; - bool valid = parse_option_list( + bool valid = parse_python_list( options, [&](char c) { bytes++; @@ -183,7 +73,7 @@ void HomeassistantSelect::options_changed_(StringRef options) { size_t offset = 0; size_t option_start = 0; size_t index = 0; - parse_option_list( + parse_python_list( options, [&](char c) { buffer[offset++] = c; }, [&]() { buffer[offset++] = '\0'; diff --git a/tests/components/homeassistant/test_python_list.cpp b/tests/components/homeassistant/test_python_list.cpp new file mode 100644 index 00000000000..0feaefee060 --- /dev/null +++ b/tests/components/homeassistant/test_python_list.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include + +#include "esphome/components/homeassistant/homeassistant.h" + +namespace esphome::homeassistant::testing { + +namespace { + +struct Parsed { + bool valid; + std::vector items; +}; + +Parsed parse(const char *text) { + Parsed result; + std::string item; + result.valid = parse_python_list( + StringRef(text), [&](char c) { item += c; }, + [&]() { + result.items.push_back(item); + item.clear(); + }); + return result; +} + +} // namespace + +TEST(PythonList, ReadsStringsWithTheirEscapes) { + auto parsed = parse(R"(['Low', "It's high", 'caf\xe9'])"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 3u); + EXPECT_EQ(parsed.items[0], "Low"); + EXPECT_EQ(parsed.items[1], "It's high"); + EXPECT_EQ(parsed.items[2], "caf\xc3\xa9"); +} + +TEST(PythonList, ReadsTheValueOfEnumMembers) { + auto parsed = parse("[, ]"); + ASSERT_TRUE(parsed.valid); + ASSERT_EQ(parsed.items.size(), 2u); + EXPECT_EQ(parsed.items[0], "hs"); + EXPECT_EQ(parsed.items[1], "color_temp"); +} + +TEST(PythonList, AcceptsAnEmptyList) { + auto parsed = parse("[]"); + EXPECT_TRUE(parsed.valid); + EXPECT_TRUE(parsed.items.empty()); +} + +TEST(PythonList, RejectsMalformedLists) { + for (const char *text : {"", "[", "['a'", "['a',", "['a' 'b']", "['a'] x", "[1]", "['a\\q']", "[]", "[, ]", + "[]", "[]", "[]", "[<'a'>]"}) { + EXPECT_FALSE(parse(text).valid) << text; + } +} + +} // namespace esphome::homeassistant::testing From cf4e69273c694b5ffa7b3fc8bafc998be8077b22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 07:28:55 -1000 Subject: [PATCH 18/50] [shelly_dimmer] Keep the STM32 device table in flash (#20643) --- esphome/components/shelly_dimmer/dev_table.h | 189 +++++++++--------- esphome/components/shelly_dimmer/stm32flash.h | 7 +- 2 files changed, 104 insertions(+), 92 deletions(-) diff --git a/esphome/components/shelly_dimmer/dev_table.h b/esphome/components/shelly_dimmer/dev_table.h index 32b4810d7a2..8dee068b607 100644 --- a/esphome/components/shelly_dimmer/dev_table.h +++ b/esphome/components/shelly_dimmer/dev_table.h @@ -21,6 +21,7 @@ #include "esphome/core/defines.h" #ifdef USE_SHD_FIRMWARE_DATA +#include "esphome/core/hal.h" #include "stm32flash.h" namespace esphome::shelly_dimmer { @@ -38,118 +39,128 @@ constexpr uint32_t SZ_256K = 0x00040000; /* * Page-size for page-by-page flash erase. * Arrays are zero terminated; last non-zero value is automatically repeated + * In PROGMEM and read by direct indexing: each entry is an aligned 32-bit load, which is safe from flash on ESP8266 */ /* fixed size pages */ -constexpr uint32_t p_128[] = {SZ_128, 0}; // NOLINT -constexpr uint32_t p_256[] = {SZ_256, 0}; // NOLINT -constexpr uint32_t p_1k[] = {SZ_1K, 0}; // NOLINT -constexpr uint32_t p_2k[] = {SZ_2K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_128[] PROGMEM = {SZ_128, 0}; // NOLINT +alignas(4) constexpr uint32_t p_256[] PROGMEM = {SZ_256, 0}; // NOLINT +alignas(4) constexpr uint32_t p_1k[] PROGMEM = {SZ_1K, 0}; // NOLINT +alignas(4) constexpr uint32_t p_2k[] PROGMEM = {SZ_2K, 0}; // NOLINT /* F2 and F4 page size */ -constexpr uint32_t f2f4[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT +alignas(4) constexpr uint32_t f2f4[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; // NOLINT /* F4 dual bank page size */ -constexpr uint32_t f4db[] = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT - SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; +alignas(4) constexpr uint32_t f4db[] PROGMEM = {SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, SZ_128K, // NOLINT + SZ_128K, SZ_16K, SZ_16K, SZ_16K, SZ_16K, SZ_64K, SZ_128K, 0}; /* F7 page size */ -constexpr uint32_t f7[] = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT +alignas(4) constexpr uint32_t f7[] PROGMEM = {SZ_32K, SZ_32K, SZ_32K, SZ_32K, SZ_128K, SZ_256K, 0}; // NOLINT /* * Device table, corresponds to the "Bootloader device-dependant parameters" * table in ST document AN2606. * Note that the option bytes upper range is inclusive! */ -constexpr stm32_dev_t DEVICES[] = { - /* ID "name" SRAM-address-range FLASH-address-range PPS PSize +alignas(4) constexpr stm32_dev_t DEVICES[] PROGMEM = { + /* ID SRAM-address-range FLASH-address-range PPS PSize Option-byte-addr-range System-mem-addr-range Flags */ /* F0 */ - {0x440, "STM32F030x8/F05xxx", 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x442, "STM32F030xC/F09xxx", 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, F_OBLL}, - {0x444, "STM32F03xx4/6", 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFEC00, 0x1FFFF800, 0}, - {0x445, "STM32F04xxx/F070x6", 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC400, 0x1FFFF800, 0}, - {0x448, "STM32F070xB/F071xx/F72xx", 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFC800, 0x1FFFF800, 0}, + // STM32F030x8/F05xxx + {0x440, 0x20000800, 0x20002000, 0x08000000, 0x08010000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F030xC/F09xxx + {0x442, 0x20001800, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, + F_OBLL}, + // STM32F03xx4/6 + {0x444, 0x20000800, 0x20001000, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFEC00, 0x1FFFF800, 0}, + // STM32F04xxx/F070x6 + {0x445, 0x20001800, 0x20001800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC400, 0x1FFFF800, 0}, + // STM32F070xB/F071xx/F72xx + {0x448, 0x20001800, 0x20004000, 0x08000000, 0x08020000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFC800, 0x1FFFF800, 0}, /* F1 */ - {0x412, "STM32F10xxx Low-density", 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x410, "STM32F10xxx Medium-density", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x414, "STM32F10xxx High-density", 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x420, "STM32F10xxx Medium-density VL", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x428, "STM32F10xxx High-density VL", 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, - {0x418, "STM32F105xx/F107xx", 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFB000, 0x1FFFF800, 0}, - {0x430, "STM32F10xxx XL-density", 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFE000, 0x1FFFF800, 0}, + // STM32F10xxx Low-density + {0x412, 0x20000200, 0x20002800, 0x08000000, 0x08008000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density + {0x410, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density + {0x414, 0x20000200, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx Medium-density VL + {0x420, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F10xxx High-density VL + {0x428, 0x20000200, 0x20008000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32F105xx/F107xx + {0x418, 0x20001000, 0x20010000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFB000, 0x1FFFF800, 0}, + // STM32F10xxx XL-density + {0x430, 0x20000800, 0x20018000, 0x08000000, 0x08100000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFE000, 0x1FFFF800, 0}, /* F2 */ - {0x411, "STM32F2xxxx", 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F2xxxx + {0x411, 0x20002000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F3 */ - {0x432, "STM32F373xx/F378xx", 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFD800, 0x1FFFF800, 0}, - {0x422, "STM32F302xB(C)/F303xB(C)/F358xx", 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x439, "STM32F301xx/F302x4(6/8)/F318xx", 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x438, "STM32F303x4(6/8)/F334xx/F328xx", 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, - {0x446, "STM32F302xD(E)/F303xD(E)/F398xx", 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, - 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F373xx/F378xx + {0x432, 0x20001400, 0x20008000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xB(C)/F303xB(C)/F358xx + {0x422, 0x20001400, 0x2000A000, 0x08000000, 0x08040000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F301xx/F302x4(6/8)/F318xx + {0x439, 0x20001800, 0x20004000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F303x4(6/8)/F334xx/F328xx + {0x438, 0x20001800, 0x20003000, 0x08000000, 0x08010000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, + // STM32F302xD(E)/F303xD(E)/F398xx + {0x446, 0x20001800, 0x20010000, 0x08000000, 0x08080000, 2, p_2k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFD800, 0x1FFFF800, 0}, /* F4 */ - {0x413, "STM32F40xxx/41xxx", 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x419, "STM32F42xxx/43xxx", 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x423, "STM32F401xB(C)", 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x433, "STM32F401xD(E)", 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, - 0x1FFF0000, 0x1FFF7800, 0}, - {0x458, "STM32F410xx", 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x431, "STM32F411xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x421, "STM32F446xx", 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, - {0x434, "STM32F469xx", 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, - 0x1FFF7800, 0}, + // STM32F40xxx/41xxx + {0x413, 0x20003000, 0x20020000, 0x08000000, 0x08100000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F42xxx/43xxx + {0x419, 0x20003000, 0x20030000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xB(C) + {0x423, 0x20003000, 0x20010000, 0x08000000, 0x08040000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F401xD(E) + {0x433, 0x20003000, 0x20018000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F410xx + {0x458, 0x20003000, 0x20008000, 0x08000000, 0x08020000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F411xx + {0x431, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F446xx + {0x421, 0x20003000, 0x20020000, 0x08000000, 0x08080000, 1, f2f4, 0x1FFFC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, + // STM32F469xx + {0x434, 0x20003000, 0x20060000, 0x08000000, 0x08200000, 1, f4db, 0x1FFEC000, 0x1FFFC00F, 0x1FFF0000, 0x1FFF7800, 0}, /* F7 */ - {0x449, "STM32F74xxx/75xxx", 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, - 0x1FF00000, 0x1FF0EDC0, 0}, + // STM32F74xxx/75xxx + {0x449, 0x20004000, 0x20050000, 0x08000000, 0x08100000, 1, f7, 0x1FFF0000, 0x1FFF001F, 0x1FF00000, 0x1FF0EDC0, 0}, /* L0 */ - {0x425, "STM32L031xx/041xx", 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x417, "STM32L05xxx/06xxx", 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x447, "STM32L07xxx/08xxx", 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, + // STM32L031xx/041xx + {0x425, 0x20001000, 0x20002000, 0x08000000, 0x08008000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L05xxx/06xxx + {0x417, 0x20001000, 0x20002000, 0x08000000, 0x08010000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L07xxx/08xxx + {0x447, 0x20002000, 0x20005000, 0x08000000, 0x08030000, 32, p_128, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, /* L1 */ - {0x416, "STM32L1xxx6(8/B)", 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, F_NO_ME}, - {0x429, "STM32L1xxx6(8/B)A", 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF01000, 0}, - {0x427, "STM32L1xxxC", 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, - 0x1FF00000, 0x1FF02000, 0}, - {0x436, "STM32L1xxxD", 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, 0}, - {0x437, "STM32L1xxxE", 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, - 0x1FF00000, 0x1FF02000, F_NO_ME}, + // STM32L1xxx6(8/B) + {0x416, 0x20000800, 0x20004000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + F_NO_ME}, + // STM32L1xxx6(8/B)A + {0x429, 0x20001000, 0x20008000, 0x08000000, 0x08020000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF01000, + 0}, + // STM32L1xxxC + {0x427, 0x20001000, 0x20008000, 0x08000000, 0x08040000, 16, p_256, 0x1FF80000, 0x1FF8001F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxD + {0x436, 0x20001000, 0x2000C000, 0x08000000, 0x08060000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + 0}, + // STM32L1xxxE + {0x437, 0x20001000, 0x20014000, 0x08000000, 0x08080000, 16, p_256, 0x1FF80000, 0x1FF8009F, 0x1FF00000, 0x1FF02000, + F_NO_ME}, /* L4 */ - {0x415, "STM32L476xx/486xx", 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, - 0x1FFF0000, 0x1FFF7000, 0}, + // STM32L476xx/486xx + {0x415, 0x20003100, 0x20018000, 0x08000000, 0x08100000, 1, p_2k, 0x1FFF7800, 0x1FFFF80F, 0x1FFF0000, 0x1FFF7000, 0}, /* These are not (yet) in AN2606: */ - {0x641, "Medium_Density PL", 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, - 0x1FFFF000, 0x1FFFF800, 0}, - {0x9a8, "STM32W-128K", 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x9b0, "STM32W-256K", 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, - 0x08040800, 0}, - {0x0, "", 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, + // Medium_Density PL + {0x641, 0x20000200, 0x20005000, 0x08000000, 0x08020000, 4, p_1k, 0x1FFFF800, 0x1FFFF80F, 0x1FFFF000, 0x1FFFF800, 0}, + // STM32W-128K + {0x9a8, 0x20000200, 0x20002000, 0x08000000, 0x08020000, 4, p_1k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + // STM32W-256K + {0x9b0, 0x20000200, 0x20004000, 0x08000000, 0x08040000, 4, p_2k, 0x08040800, 0x0804080F, 0x08040000, 0x08040800, 0}, + {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, nullptr, 0x0, 0x0, 0x0, 0x0, 0x0}, }; } // namespace esphome::shelly_dimmer diff --git a/esphome/components/shelly_dimmer/stm32flash.h b/esphome/components/shelly_dimmer/stm32flash.h index 9a81f07373e..9aa2e470808 100644 --- a/esphome/components/shelly_dimmer/stm32flash.h +++ b/esphome/components/shelly_dimmer/stm32flash.h @@ -72,17 +72,18 @@ using stm32_cmd_t = struct Stm32Cmd { uint8_t crc; }; +// Word-only fields: the table is in PROGMEM and aligned 32-bit loads from flash are safe on ESP8266 using stm32_dev_t = struct Stm32Dev { // NOLINT - const uint16_t id; - const char *name; + const uint32_t id; const uint32_t ram_start, ram_end; const uint32_t fl_start, fl_end; - const uint16_t fl_pps; // pages per sector + const uint32_t fl_pps; // pages per sector const uint32_t *fl_ps; // page size const uint32_t opt_start, opt_end; const uint32_t mem_start, mem_end; const uint32_t flags; }; +static_assert(sizeof(stm32_dev_t) == 12 * sizeof(uint32_t), "stm32_dev_t must stay word-only for flash reads"); using stm32_t = struct Stm32 { uart::UARTDevice *stream; From 1947c3d3551e98fbfd76a082bbe470083b22f7a4 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:14 +0100 Subject: [PATCH 19/50] [improv_ble] Start Improv again after a session has stopped (#20808) --- esphome/components/esp32_ble_server/ble_service.h | 1 + esphome/components/improv_ble/improv_ble_component.cpp | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index a0592d0a809..51a6ce72cde 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -48,6 +48,7 @@ class BLEService final { bool is_created() { return this->state_ == CREATED; } bool is_running() { return this->state_ == RUNNING; } bool is_starting() { return this->state_ == STARTING; } + bool is_stopped() { return this->state_ == STOPPED; } bool is_deleted() { return this->state_ == DELETED; } protected: diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 2029058899f..6e11975b6c3 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -142,7 +142,8 @@ void ImprovBLEComponent::loop() { this->set_status_indicator_state_(false); if (this->should_start_ && this->setup_complete_) { - if (this->service_->is_created()) { + // A service that an earlier session stopped stays STOPPED and can be started again + if (this->service_->is_created() || this->service_->is_stopped()) { this->service_->start(); } else if (this->service_->is_running()) { // Start by advertising the device name first BEFORE setting any state From bf67b49104ef7575ba9214be3ef48cedaa79a265 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:52 +0100 Subject: [PATCH 20/50] [i2c] Let host unit tests with I2C build on macOS (#20738) --- esphome/components/i2c/__init__.py | 21 +++++++++- tests/unit_tests/components/test_i2c.py | 51 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/components/test_i2c.py diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 453c2513fa7..5bf64609f53 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -144,10 +144,15 @@ def validate_config(config: ConfigType) -> ConfigType: return config +def _host_is_linux() -> bool: + """The host I2C bus needs Linux i2c-dev.""" + return sys.platform.lower().startswith("linux") + + def validate_host_config(config: ConfigType) -> ConfigType: if CORE.is_host: # Host I2C is currently only supported on Linux - if not sys.platform.lower().startswith("linux"): + if not _host_is_linux(): raise cv.Invalid( "I2C is only supported on Linux for the host platform. " f"Current platform: {sys.platform}" @@ -446,7 +451,7 @@ def final_validate_device_schema( ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_platform_filter = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, @@ -463,3 +468,15 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) + + +def FILTER_SOURCE_FILES() -> list[str]: + excluded = _platform_filter() + # An empty list means the target isn't known yet. Otherwise the host bus is + # only kept for the host platform, and it needs Linux i2c-dev + # (validate_host_config rejects host I2C elsewhere). Leaving it out lets host + # builds without a bus, like the C++ unit tests with their fake bus, build on + # macOS too. + if excluded and "i2c_bus_host.cpp" not in excluded and not _host_is_linux(): + excluded.append("i2c_bus_host.cpp") + return excluded diff --git a/tests/unit_tests/components/test_i2c.py b/tests/unit_tests/components/test_i2c.py new file mode 100644 index 00000000000..91304d69cf9 --- /dev/null +++ b/tests/unit_tests/components/test_i2c.py @@ -0,0 +1,51 @@ +"""I2C backend source selection.""" + +from unittest.mock import patch + +import pytest + +from esphome.components import i2c +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + +@pytest.mark.parametrize( + "build_os, host_bus_built", + [ + ("linux", True), + ("darwin", False), + ], +) +def test_host_bus_is_only_built_on_linux(build_os: str, host_bus_built: bool) -> None: + core_data = {KEY_CORE: {KEY_TARGET_PLATFORM: "host", KEY_TARGET_FRAMEWORK: "host"}} + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert ("i2c_bus_host.cpp" not in excluded) is host_bus_built + assert {"i2c_bus_arduino.cpp", "i2c_bus_esp_idf.cpp", "i2c_bus_zephyr.cpp"} <= set( + excluded + ) + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_nothing_excluded_before_the_target_is_known(build_os: str) -> None: + with ( + patch("esphome.config_helpers.CORE.data", {}), + patch.object(i2c.sys, "platform", build_os), + ): + assert i2c.FILTER_SOURCE_FILES() == [] + + +@pytest.mark.parametrize("build_os", ["linux", "darwin"]) +def test_host_bus_left_out_for_other_targets(build_os: str) -> None: + core_data = { + KEY_CORE: {KEY_TARGET_PLATFORM: "esp32", KEY_TARGET_FRAMEWORK: "esp-idf"} + } + with ( + patch("esphome.config_helpers.CORE.data", core_data), + patch.object(i2c.sys, "platform", build_os), + ): + excluded = i2c.FILTER_SOURCE_FILES() + assert excluded.count("i2c_bus_host.cpp") == 1 + assert "i2c_bus_esp_idf.cpp" not in excluded From 6356458081d1dd57519b27d09220210435c260a8 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 20:27:57 +0200 Subject: [PATCH 21/50] [dfrobot_sen0609] Add DFRobot SEN0609/SEN0610 radar (#20809) Co-authored-by: Claude Opus 5.5 --- CODEOWNERS | 1 + .../components/dfrobot_sen0609/__init__.py | 76 +++ .../dfrobot_sen0609/binary_sensor.py | 24 + .../dfrobot_sen0609/dfrobot_sen0609.cpp | 483 ++++++++++++++++++ .../dfrobot_sen0609/dfrobot_sen0609.h | 113 ++++ esphome/components/dfrobot_sen0609/sensor.py | 64 +++ .../components/dfrobot_sen0609/text_sensor.py | 29 ++ .../components/dfrobot_sen0609/benchmark.yaml | 5 + .../dfrobot_sen0609/common-speed.yaml | 19 + tests/components/dfrobot_sen0609/common.h | 94 ++++ tests/components/dfrobot_sen0609/common.yaml | 17 + .../dfrobot_sen0609_frames.cpp | 72 +++ .../dfrobot_sen0609_sequencer.cpp | 174 +++++++ .../dfrobot_sen0609/test-speed.esp32-idf.yaml | 3 + .../dfrobot_sen0609/test.esp32-idf.yaml | 3 + .../dfrobot_sen0609/test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_dfrobot_sen0609.yaml | 56 ++ .../test_uart_mock_dfrobot_sen0609.py | 57 +++ 18 files changed, 1293 insertions(+) create mode 100644 esphome/components/dfrobot_sen0609/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/binary_sensor.py create mode 100644 esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp create mode 100644 esphome/components/dfrobot_sen0609/dfrobot_sen0609.h create mode 100644 esphome/components/dfrobot_sen0609/sensor.py create mode 100644 esphome/components/dfrobot_sen0609/text_sensor.py create mode 100644 tests/components/dfrobot_sen0609/benchmark.yaml create mode 100644 tests/components/dfrobot_sen0609/common-speed.yaml create mode 100644 tests/components/dfrobot_sen0609/common.h create mode 100644 tests/components/dfrobot_sen0609/common.yaml create mode 100644 tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp create mode 100644 tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp create mode 100644 tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0609/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0609/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml create mode 100644 tests/integration/test_uart_mock_dfrobot_sen0609.py diff --git a/CODEOWNERS b/CODEOWNERS index 5e7c2eae794..46b94954fdc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -156,6 +156,7 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..4825f4b770a --- /dev/null +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,76 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["uart"] +DOMAIN = "dfrobot_sen0609" +MULTI_CONF = True + +dfrobot_sen0609_ns = cg.esphome_ns.namespace("dfrobot_sen0609") +DFRobotSEN0609Component = dfrobot_sen0609_ns.class_( + "DFRobotSEN0609Component", cg.Component, uart.UARTDevice +) +RunMode = dfrobot_sen0609_ns.enum("RunMode", is_class=True) + +CONF_DFROBOT_SEN0609_ID = "dfrobot_sen0609_id" + +MODE_PRESENCE = "presence" +MODE_SPEED_AND_DISTANCE = "speed_and_distance" +RUN_MODES = { + MODE_PRESENCE: RunMode.RUN_MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE: RunMode.RUN_MODE_SPEED_AND_DISTANCE, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DFRobotSEN0609Component), + cv.Optional(CONF_MODE, default=MODE_PRESENCE): cv.one_of( + *RUN_MODES, lower=True + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + + +def get_hub_config(root: ConfigType, hub_id: ID) -> ConfigType: + """Return the configuration of the hub with the given id.""" + return next(conf for conf in root[DOMAIN] if conf[CONF_ID] == hub_id) + + +def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: + """Reject entity keys that need a different hub mode; `keys` maps each key to the mode it needs.""" + + def validator(config: ConfigType) -> None: + hub = get_hub_config(fv.full_config.get(), config[CONF_DFROBOT_SEN0609_ID]) + for key, mode in keys.items(): + if key in config and hub[CONF_MODE] != mode: + raise cv.Invalid( + f"'{key}' needs the {DOMAIN} 'mode' to be '{mode}'", path=[key] + ) + + return validator + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/binary_sensor.py b/esphome/components/dfrobot_sen0609/binary_sensor.py new file mode 100644 index 00000000000..8d4cb7ed846 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/binary_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + 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_DFROBOT_SEN0609_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp new file mode 100644 index 00000000000..0e6ff31ffe7 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -0,0 +1,483 @@ +#include "dfrobot_sen0609.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include +#include +#include + +namespace esphome::dfrobot_sen0609 { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0609"); + +static constexpr uint8_t MAX_ATTEMPTS = 3; +// The module ignores a command that follows its last answer too closely +static constexpr uint32_t COMMAND_GAP_MS = 100; +static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; +static constexpr uint32_t LONG_TIMEOUT_MS = 2000; +static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; +// Presence frames come every second (setUartOutput period); this long without one means the module lost them +static constexpr uint32_t FRAME_TIMEOUT_MS = 10000; +static constexpr uint8_t FRAME_FIELDS = 5; + +static constexpr uint8_t LINE_END[] = {'\r', '\n'}; + +// Indexed by Param; empty for parameters that cannot be read +PROGMEM_STRING_TABLE(ReadCommands, "", "", "getSWV", "getHWV"); + +static_assert(PARAM_COUNT <= 16, "pending_writes_ and pending_reads_ hold one bit per parameter"); +static constexpr uint16_t param_bit(Param param) { return static_cast(1U << param); } + +static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { + return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; +} + +static bool contains(const char *str, ESPHOME_PGM_P needle) { + const size_t len = ESPHOME_strlen_P(needle); + for (; *str != '\0'; str++) { + if (ESPHOME_strncmp_P(str, needle, len) == 0) + return true; + } + return false; +} + +// Splits "a,b,c*" in place; returns the number of fields stored in `fields` +static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { + uint8_t count = 0; + char *start = str; + for (;; str++) { + if (*str != ',' && *str != '*' && *str != '\0') + continue; + const bool last = *str != ','; + *str = '\0'; + if (count < max_fields) + fields[count++] = start; + if (last) + return count; + start = str + 1; + } +} + +// Answers that refuse a command whose effect is already in place +static bool answer_means_done(Operation operation, const char *line) { + switch (operation) { + case Operation::OPERATION_STOP: + return contains(line, ESPHOME_PSTR("stopped already")); + case Operation::OPERATION_START: + return contains(line, ESPHOME_PSTR("started already")); + default: + return false; + } +} + +static bool parse_float(const char *str, float &value) { + char *end; + value = strtof(str, &end); + return end != str && *end == '\0'; +} + +static const LogString *operation_to_str(Operation operation) { + switch (operation) { + case Operation::OPERATION_STOP: + return LOG_STR("stop"); + case Operation::OPERATION_START: + return LOG_STR("start"); + case Operation::OPERATION_WRITE: + return LOG_STR("write"); + case Operation::OPERATION_READ: + return LOG_STR("read"); + default: + return LOG_STR("none"); + } +} + +DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) {} + +void DFRobotSEN0609Component::dump_config() { + ESP_LOGCONFIG( + TAG, + "DFRobot SEN0609:\n" + " Mode: %s", + this->mode_ == RunMode::RUN_MODE_PRESENCE ? LOG_STR_LITERAL("presence") : LOG_STR_LITERAL("speed and distance")); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "HasTarget", this->has_target_binary_sensor_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "TargetDistance", this->target_distance_sensor_); + LOG_SENSOR(" ", "TargetSpeed", this->target_speed_sensor_); + LOG_SENSOR(" ", "TargetEnergy", this->target_energy_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); + LOG_TEXT_SENSOR(" ", "HardwareVersion", this->hardware_version_text_sensor_); +#endif +} + +void DFRobotSEN0609Component::setup() { this->queue_configuration_(); } + +void DFRobotSEN0609Component::loop() { + uint8_t buf[MAX_LINE_LENGTH]; + size_t avail = this->available(); + while (avail > 0) { + const size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + if (!this->read_failed_) { + ESP_LOGW(TAG, "UART read failed"); + } + this->read_failed_ = true; + break; + } + this->read_failed_ = false; + avail -= to_read; + for (size_t i = 0; i < to_read; i++) + this->readline_(buf[i]); + } + this->run_sequencer_(); +} + +void DFRobotSEN0609Component::readline_(uint8_t c) { + if (c == '\r') + return; + if (c == '\n') { + if (!this->line_overflow_) { + this->line_[this->line_pos_] = '\0'; + this->handle_line_(this->line_); + } + this->line_overflow_ = false; + this->line_pos_ = 0; + return; + } + if (this->line_overflow_) + return; + if (this->line_pos_ >= MAX_LINE_LENGTH - 1) { + ESP_LOGW(TAG, "Line too long, dropped"); + this->line_overflow_ = true; + return; + } + this->line_[this->line_pos_++] = static_cast(c); + // The prompt has no line end, and the echo of the next command follows it on the same line + if (c == '>') { + this->line_[this->line_pos_] = '\0'; + if (ESPHOME_strcmp_P(this->line_, ESPHOME_PSTR("DFRobot:/>")) == 0) + this->line_pos_ = 0; + } +} + +void DFRobotSEN0609Component::handle_line_(char *line) { + if (*line == '\0') + return; + ESP_LOGV(TAG, "RX: %s", line); + if (line[0] == '$') { + this->handle_frame_(line); + return; + } + // Version lines name their value, so they are used even when they arrive late + const bool software = starts_with(line, ESPHOME_PSTR("SoftwareVersion:")); + if (software || starts_with(line, ESPHOME_PSTR("HardwareVersion:"))) { + if (this->operation_ == Operation::OPERATION_READ && + this->param_ == (software ? PARAM_SW_VERSION : PARAM_HW_VERSION)) + this->version_received_ = true; +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *sens = software ? this->version_text_sensor_ : this->hardware_version_text_sensor_; + if (sens != nullptr) + sens->publish_state(line + 16); +#endif + return; + } + if (this->operation_ == Operation::OPERATION_NONE) { + ESP_LOGD(TAG, "Ignored line, no command pending: %s", line); + return; + } + if (strcmp(line, this->command_) == 0) { + this->echo_seen_ = true; + return; + } + // The module echoes every command first; lines before the echo belong to an earlier command + if (!this->echo_seen_) { + ESP_LOGD(TAG, "Ignored line from an earlier command: %s", line); + return; + } + + if (starts_with(line, ESPHOME_PSTR("Done")) || answer_means_done(this->operation_, line)) { + this->finish_operation_(); + } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + // Stop the sensor before the next attempt + this->stopped_ = false; + this->fail_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); + this->fail_operation_(); + } else { + ESP_LOGD(TAG, "Unexpected answer to '%s': %s", this->command_, line); + } +} + +void DFRobotSEN0609Component::handle_frame_(char *line) { + // $DFHPD,, , , * + if (starts_with(line, ESPHOME_PSTR("$DFHPD,"))) { + const char presence = line[7]; + if (presence != '0' && presence != '1') { + this->log_bad_frame_(LOG_STR("Malformed presence frame"), line); + return; + } + this->last_frame_ms_ = App.get_loop_component_start_time(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(presence == '1'); +#endif + return; + } + // $DFDMD,,,,,,...* + if (!starts_with(line, ESPHOME_PSTR("$DFDMD,"))) { + this->log_bad_frame_(LOG_STR("Unknown frame"), line); + return; + } + char *fields[FRAME_FIELDS]; + float distance; + float speed; + float energy; + if (split_fields(line + 7, fields, FRAME_FIELDS) < FRAME_FIELDS) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + const optional targets = parse_number(fields[0]); + if (!targets.has_value()) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } + if (*targets == 0) { +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(false); +#endif +#ifdef USE_SENSOR + this->publish_target_(NAN, NAN, NAN); +#endif + return; + } + if (!parse_float(fields[2], distance) || !parse_float(fields[3], speed) || !parse_float(fields[4], energy)) { + this->log_bad_frame_(LOG_STR("Malformed target frame"), line); + return; + } +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->publish_state(true); +#endif +#ifdef USE_SENSOR + this->publish_target_(distance, speed, energy); +#endif +} + +void DFRobotSEN0609Component::log_bad_frame_(const LogString *problem, const char *line) { + // The module sends frames several times a second, so only the first bad one is logged + if (this->bad_frame_logged_) + return; + this->bad_frame_logged_ = true; + ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(problem), line); +} + +#ifdef USE_SENSOR +static void publish_deduplicated(sensor::Sensor *sens, Deduplicator &dedup, float value) { + if (sens == nullptr) + return; + if (std::isnan(value) ? dedup.next_unknown() : dedup.next(value)) + sens->publish_state(value); +} + +void DFRobotSEN0609Component::publish_target_(float distance, float speed, float energy) { + publish_deduplicated(this->target_distance_sensor_, this->distance_dedup_, distance); + publish_deduplicated(this->target_speed_sensor_, this->speed_dedup_, speed); + publish_deduplicated(this->target_energy_sensor_, this->energy_dedup_, energy); +} +#endif + +void DFRobotSEN0609Component::queue_configuration_() { + // The mode and the UART output are applied at every boot instead of being saved + uint16_t writes = param_bit(PARAM_RUN_APP); + if (this->mode_ == RunMode::RUN_MODE_PRESENCE) + writes |= param_bit(PARAM_UART_OUTPUT); + this->pending_writes_ |= writes; + this->pending_reads_ |= this->wanted_reads_(); +} + +uint16_t DFRobotSEN0609Component::wanted_reads_() const { + uint16_t reads = 0; +#ifdef USE_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_SW_VERSION); + if (this->hardware_version_text_sensor_ != nullptr) + reads |= param_bit(PARAM_HW_VERSION); +#endif + return reads; +} + +void DFRobotSEN0609Component::check_frames_(uint32_t now) { + // Speed mode frames are not watched: whether the module sends them without a target is not known + if (this->mode_ != RunMode::RUN_MODE_PRESENCE || now - this->last_frame_ms_ < FRAME_TIMEOUT_MS) + return; + // The module most likely restarted on its own and lost the configuration applied at boot + ESP_LOGW(TAG, "No presence frames, applying the configuration again"); + this->status_set_warning(); +#ifdef USE_BINARY_SENSOR + if (this->has_target_binary_sensor_ != nullptr) + this->has_target_binary_sensor_->invalidate_state(); +#endif + this->last_frame_ms_ = now; + this->queue_configuration_(); +} + +void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { + this->hold_start_ms_ = App.get_loop_component_start_time(); + this->hold_ms_ = duration_ms; +} + +void DFRobotSEN0609Component::run_sequencer_() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->operation_ != Operation::OPERATION_NONE) { + const bool slow = this->operation_ == Operation::OPERATION_STOP; + if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) + return; + ESP_LOGD(TAG, "No answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + if (now - this->hold_start_ms_ < this->hold_ms_) + return; + + const bool work_pending = this->pending_writes_ != 0 || this->pending_reads_ != 0; + Operation operation; + Param param = PARAM_COUNT; + if (!work_pending) { + if (!this->stopped_) { + this->check_frames_(now); + return; + } + operation = Operation::OPERATION_START; + } else if (!this->stopped_) { + operation = Operation::OPERATION_STOP; + } else if (this->pending_writes_ != 0) { + operation = Operation::OPERATION_WRITE; + param = static_cast(__builtin_ctz(this->pending_writes_)); + } else { + operation = Operation::OPERATION_READ; + param = static_cast(__builtin_ctz(this->pending_reads_)); + } + + const uint16_t bit = param_bit(param); + uint16_t &pending = operation == Operation::OPERATION_WRITE ? this->pending_writes_ : this->pending_reads_; + if (!this->build_command_(operation, param)) { + ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), + static_cast(param)); + pending &= ~bit; + this->sequence_failed_ = true; + this->status_set_warning(); + return; + } + if (operation == Operation::OPERATION_WRITE) + pending &= ~bit; + this->operation_ = operation; + this->param_ = param; + this->echo_seen_ = false; + this->version_received_ = false; + this->sent_ms_ = now; + ESP_LOGV(TAG, "TX: %s", this->command_); + this->write_str(this->command_); + this->write_array(LINE_END, sizeof(LINE_END)); +} + +bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { + char *buf = this->command_; + constexpr size_t len = MAX_COMMAND_LENGTH; + switch (operation) { + case Operation::OPERATION_STOP: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStop"), len); + return true; + case Operation::OPERATION_START: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); + return true; + case Operation::OPERATION_READ: + ESPHOME_strncpy_P(buf, reinterpret_cast(ReadCommands::get_progmem_str(param, 0)), len); + return buf[0] != '\0'; + default: + break; + } + + switch (param) { + case PARAM_RUN_APP: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setRunApp %u"), static_cast(this->mode_)); + return true; + case PARAM_UART_OUTPUT: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("setUartOutput 1 1 1 1.0"), len); + return true; + default: + buf[0] = '\0'; + return false; + } +} + +void DFRobotSEN0609Component::finish_operation_() { + switch (this->operation_) { + case Operation::OPERATION_STOP: + this->stopped_ = true; + break; + case Operation::OPERATION_START: + this->stopped_ = false; + this->last_frame_ms_ = App.get_loop_component_start_time(); + if (!this->sequence_failed_) + this->status_clear_warning(); + this->sequence_failed_ = false; + break; + case Operation::OPERATION_WRITE: + break; + case Operation::OPERATION_READ: + // The versions are published as their lines arrive + if (!this->version_received_) { + ESP_LOGW(TAG, "No version in the answer to '%s'", this->command_); + this->fail_operation_(); + return; + } + this->pending_reads_ &= ~param_bit(this->param_); + break; + default: + return; + } + if (this->is_aux_operation_()) { + this->aux_attempts_ = 0; + } else { + this->attempts_ = 0; + } + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); +} + +void DFRobotSEN0609Component::fail_operation_() { + const bool aux = this->is_aux_operation_(); + uint8_t &attempts = aux ? this->aux_attempts_ : this->attempts_; + const Operation operation = this->operation_; + this->operation_ = Operation::OPERATION_NONE; + this->hold_(COMMAND_GAP_MS); + // The writes are the boot configuration, which the frames depend on: keep them until they succeed + if (operation == Operation::OPERATION_WRITE) + this->pending_writes_ |= param_bit(this->param_); + if (++attempts < MAX_ATTEMPTS) + return; + + attempts = 0; + this->status_set_warning(); + if (aux || operation == Operation::OPERATION_WRITE) { + // Without stop, start or the configuration the sensor cannot run as set up; try again later + ESP_LOGW(TAG, "Command '%s' failed, trying again later", this->command_); + this->hold_ms_ = GIVE_UP_BACKOFF_MS; + return; + } + ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); + this->sequence_failed_ = true; + if (operation == Operation::OPERATION_READ) + this->pending_reads_ &= ~param_bit(this->param_); +} + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h new file mode 100644 index 00000000000..7d5baf97a0c --- /dev/null +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -0,0 +1,113 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +namespace esphome::dfrobot_sen0609 { + +static constexpr uint8_t MAX_LINE_LENGTH = 80; +static constexpr uint8_t MAX_COMMAND_LENGTH = 40; + +enum class RunMode : uint8_t { + RUN_MODE_PRESENCE = 0, + RUN_MODE_SPEED_AND_DISTANCE = 1, +}; + +/// Module parameters; a write or read of each is one command. Pending work runs in this order. +enum Param : uint8_t { + PARAM_RUN_APP, + PARAM_UART_OUTPUT, + PARAM_SW_VERSION, + PARAM_HW_VERSION, + PARAM_COUNT, +}; + +enum class Operation : uint8_t { + OPERATION_NONE, + OPERATION_STOP, + OPERATION_START, + OPERATION_WRITE, + OPERATION_READ, +}; + +class DFRobotSEN0609Component final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(has_target) +#endif +#ifdef USE_SENSOR + SUB_SENSOR(target_distance) + SUB_SENSOR(target_speed) + SUB_SENSOR(target_energy) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(version) + SUB_TEXT_SENSOR(hardware_version) +#endif + + public: + explicit DFRobotSEN0609Component(RunMode mode); + + void setup() override; + void loop() override; + void dump_config() override; + + protected: + void readline_(uint8_t c); + void handle_line_(char *line); + void handle_frame_(char *line); + void log_bad_frame_(const LogString *problem, const char *line); + + void queue_configuration_(); + uint16_t wanted_reads_() const; + void run_sequencer_(); + void check_frames_(uint32_t now); + bool build_command_(Operation operation, Param param); + void finish_operation_(); + void fail_operation_(); + void hold_(uint32_t duration_ms); + bool is_aux_operation_() const { + return this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_START; + } + +#ifdef USE_SENSOR + void publish_target_(float distance, float speed, float energy); + Deduplicator distance_dedup_; + Deduplicator speed_dedup_; + Deduplicator energy_dedup_; +#endif + + char line_[MAX_LINE_LENGTH]; + char command_[MAX_COMMAND_LENGTH]{}; + uint32_t sent_ms_{0}; + uint32_t hold_start_ms_{0}; + uint32_t hold_ms_{0}; + uint32_t last_frame_ms_{0}; + uint16_t pending_writes_{0}; + uint16_t pending_reads_{0}; + RunMode mode_; + Operation operation_{Operation::OPERATION_NONE}; + Param param_{PARAM_COUNT}; + uint8_t line_pos_{0}; + uint8_t attempts_{0}; + uint8_t aux_attempts_{0}; + bool line_overflow_{false}; + bool read_failed_{false}; + bool bad_frame_logged_{false}; + bool echo_seen_{false}; + bool version_received_{false}; + bool stopped_{false}; + bool sequence_failed_{false}; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/sensor.py b/esphome/components/dfrobot_sen0609/sensor.py new file mode 100644 index 00000000000..43c1a1bb05a --- /dev/null +++ b/esphome/components/dfrobot_sen0609/sensor.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_SPEED, + ICON_SIGNAL, + STATE_CLASS_MEASUREMENT, + UNIT_METER, + UNIT_METER_PER_SECOND, +) +from esphome.types import ConfigType + +from . import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + final_validate_mode, +) + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_TARGET_DISTANCE = "target_distance" +CONF_TARGET_ENERGY = "target_energy" +CONF_TARGET_SPEED = "target_speed" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_TARGET_DISTANCE): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER, + ), + cv.Optional(CONF_TARGET_SPEED): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_SPEED, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_METER_PER_SECOND, + ), + cv.Optional(CONF_TARGET_ENERGY): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_SIGNAL, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + { + CONF_TARGET_DISTANCE: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_ENERGY: MODE_SPEED_AND_DISTANCE, + CONF_TARGET_SPEED: MODE_SPEED_AND_DISTANCE, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_DISTANCE, hub.set_target_distance_sensor) + await sensors(CONF_TARGET_SPEED, hub.set_target_speed_sensor) + await sensors(CONF_TARGET_ENERGY, hub.set_target_energy_sensor) diff --git a/esphome/components/dfrobot_sen0609/text_sensor.py b/esphome/components/dfrobot_sen0609/text_sensor.py new file mode 100644 index 00000000000..8972f33c58e --- /dev/null +++ b/esphome/components/dfrobot_sen0609/text_sensor.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component + +DEPENDENCIES = ["dfrobot_sen0609"] + +CONF_HARDWARE_VERSION = "hardware_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + cv.Optional(CONF_HARDWARE_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_DFROBOT_SEN0609_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_hardware_version_text_sensor) diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml new file mode 100644 index 00000000000..9a41ec7cf26 --- /dev/null +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -0,0 +1,5 @@ +# Declaring the entity domains makes the C++ unit test build define USE_SENSOR and friends. +# Loaded with plain yaml.safe_load, so no ESPHome tags here. +binary_sensor: +sensor: +text_sensor: diff --git a/tests/components/dfrobot_sen0609/common-speed.yaml b/tests/components/dfrobot_sen0609/common-speed.yaml new file mode 100644 index 00000000000..b4063a6f6b2 --- /dev/null +++ b/tests/components/dfrobot_sen0609/common-speed.yaml @@ -0,0 +1,19 @@ +dfrobot_sen0609: + id: sen0609_speed + mode: speed_and_distance + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + has_target: + name: Target + +sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + target_distance: + name: Target Distance + target_speed: + name: Target Speed + target_energy: + name: Target Energy diff --git a/tests/components/dfrobot_sen0609/common.h b/tests/components/dfrobot_sen0609/common.h new file mode 100644 index 00000000000..08b9d86366a --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.h @@ -0,0 +1,94 @@ +#pragma once +#include +#include +#include +#include "esphome/components/dfrobot_sen0609/dfrobot_sen0609.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/application.h" + +namespace esphome::dfrobot_sen0609::testing { + +// Records what the hub writes and hands it what the test queued as module output. +class FakeUART : public uart::UARTComponent { + public: + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } + bool peek_byte(uint8_t *data) override { + if (this->available() == 0) + return false; + *data = this->rx[this->rx_pos]; + return true; + } + bool read_array(uint8_t *data, size_t len) override { + if (this->available() < len) + return false; + memcpy(data, this->rx.data() + this->rx_pos, len); + this->rx_pos += len; + return true; + } + size_t available() override { return this->rx.size() - this->rx_pos; } + uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } + void check_logger_conflict() override {} +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif + + std::string tx; + std::string rx; + size_t rx_pos{0}; +}; + +class SEN0609Test : public ::testing::Test { + protected: + explicit SEN0609Test(RunMode mode = RunMode::RUN_MODE_PRESENCE) : hub_(mode) {} + + void SetUp() override { + this->hub_.set_uart_parent(&this->uart_); + this->hub_.set_has_target_binary_sensor(&this->has_target_); + } + + // Runs the hub's loop `ms` later than the previous run + void run_(uint32_t ms = 16) { + this->now_ += ms; + LoopBlockingGuard guard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + void feed_(const std::string &text) { + this->uart_.rx += text; + this->run_(); + } + + // Runs the loop until the hub writes a full command line, and returns it without the line end + std::string next_command_(uint32_t max_ms = 5000) { + for (uint32_t waited = 0; waited <= max_ms; waited += 16) { + const size_t end = this->uart_.tx.find("\r\n"); + if (end != std::string::npos) { + std::string command = this->uart_.tx.substr(0, end); + this->uart_.tx.erase(0, end + 2); + return command; + } + this->run_(); + } + return ""; + } + + // Answers a command the way the module does: echo, optional lines, Done and the prompt + void answer_(const std::string &command, const std::string &lines = "") { + this->feed_(command + "\r\n" + lines + "Done\r\nDFRobot:/>"); + } + + // Expects `command` next and answers it + void expect_and_answer_(const std::string &command, const std::string &lines = "") { + ASSERT_EQ(this->next_command_(), command); + this->answer_(command, lines); + } + + FakeUART uart_; + DFRobotSEN0609Component hub_; + binary_sensor::BinarySensor has_target_; + uint32_t now_{1000}; +}; + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml new file mode 100644 index 00000000000..1f3887be631 --- /dev/null +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -0,0 +1,17 @@ +dfrobot_sen0609: + id: sen0609_presence + mode: presence + +binary_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + has_target: + name: Presence + +text_sensor: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + version: + name: Software Version + hardware_version: + name: Hardware Version diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp new file mode 100644 index 00000000000..7044d12675b --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_frames.cpp @@ -0,0 +1,72 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609FrameTest : public SEN0609Test { + protected: + SEN0609FrameTest() : SEN0609Test(RunMode::RUN_MODE_SPEED_AND_DISTANCE) {} + + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_target_distance_sensor(&this->distance_); + this->hub_.set_target_speed_sensor(&this->speed_); + this->hub_.set_target_energy_sensor(&this->energy_); + } + + sensor::Sensor distance_; + sensor::Sensor speed_; + sensor::Sensor energy_; +}; + +TEST_F(SEN0609FrameTest, PresenceFrames) { + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + this->feed_("$DFHPD,0, , , *\r\n"); + EXPECT_FALSE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, TargetFrame) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_FLOAT_EQ(this->distance_.state, 2.35f); + EXPECT_FLOAT_EQ(this->speed_.state, -0.40f); + EXPECT_FLOAT_EQ(this->energy_.state, 1234.0f); +} + +TEST_F(SEN0609FrameTest, NoTargetMakesValuesUnknown) { + this->feed_("$DFDMD,1,0,2.35,-0.40,1234,0,0*\r\n"); + this->feed_("$DFDMD,0,0,0,0,0,0,0*\r\n"); + EXPECT_FALSE(this->has_target_.state); + EXPECT_TRUE(std::isnan(this->distance_.state)); + EXPECT_TRUE(std::isnan(this->speed_.state)); + EXPECT_TRUE(std::isnan(this->energy_.state)); +} + +TEST_F(SEN0609FrameTest, MalformedFramesAreIgnored) { + this->feed_("$DFHPD,x, , , *\r\n"); + this->feed_("$DFDMD,1,0,abc,0.1,5*\r\n"); + this->feed_("$DFDMD,1,0,2.0*\r\n"); + this->feed_("$DFDMD,,0,2.0,0.1,5*\r\n"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_FALSE(this->distance_.has_state()); +} + +TEST_F(SEN0609FrameTest, FrameAfterPromptOnSameLine) { + this->feed_("DFRobot:/>$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +TEST_F(SEN0609FrameTest, FrameSplitAcrossReads) { + this->feed_("$DFDMD,1,0,3."); + EXPECT_FALSE(this->distance_.has_state()); + this->feed_("50,0.00,77,0,0*\r\n"); + EXPECT_FLOAT_EQ(this->distance_.state, 3.5f); +} + +TEST_F(SEN0609FrameTest, LongLineIsDroppedAndParserRecovers) { + this->feed_(std::string(200, 'x') + "\r\n"); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp new file mode 100644 index 00000000000..341ba09432f --- /dev/null +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -0,0 +1,174 @@ +#include "common.h" + +namespace esphome::dfrobot_sen0609::testing { + +class SEN0609SequencerTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->hub_.set_version_text_sensor(&this->version_); + this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); + } + + // Runs the boot sequence with a module that answers everything + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + } + + text_sensor::TextSensor version_; + text_sensor::TextSensor hardware_version_; +}; + +TEST_F(SEN0609SequencerTest, BootAppliesModeAndReadsVersions) { + this->boot_(); + EXPECT_EQ(this->version_.state, "V1.0.4"); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ErrorAnswerIsRetriedThenSkipped) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getSWV"); + this->feed_("getSWV\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_FALSE(this->version_.has_state()); +} + +TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getSWV"); + ASSERT_EQ(this->next_command_(), "getHWV"); + // The answer to the last getSWV arrives after it timed out + this->feed_("getSWV\r\nSoftwareVersion:V1.0.4\r\nDone\r\nDFRobot:/>"); + this->answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + // The late version line names its value, so it is still used + EXPECT_EQ(this->version_.state, "V1.0.4"); +} + +TEST_F(SEN0609SequencerTest, MissingVersionLineIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + EXPECT_EQ(this->version_.state, "V1.0.4"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, FailedConfigurationIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + this->feed_("setRunApp 0\r\nError\r\nDFRobot:/>"); + } + EXPECT_TRUE(this->hub_.status_has_warning()); + // The sensor is not started in an unknown mode + EXPECT_EQ(this->next_command_(20000), ""); + ASSERT_EQ(this->next_command_(15000), "setRunApp 0"); + this->answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, SilentModuleBacksOffAndKeepsWork) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + EXPECT_EQ(this->next_command_(20000), ""); + // The module shows up later; the boot work is still pending + ASSERT_EQ(this->next_command_(15000), "sensorStop"); + this->answer_("sensorStop"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { + this->hub_.setup(); + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->feed_("sensorStop\r\nsensor stopped already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "setRunApp 0"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAgainBeforeRetry) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRunApp 0"); + // The sensor was started again behind the hub's back + this->feed_("setRunApp 0\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + EXPECT_EQ(this->next_command_(), "setUartOutput 1 1 1 1.0"); +} + +TEST_F(SEN0609SequencerTest, AlreadyStartedCountsAsStarted) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + // As left by an earlier sequence that failed + this->hub_.status_set_warning(); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, PresenceFramesKeepConfiguration) { + this->boot_(); + for (int i = 0; i < 15; i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + EXPECT_EQ(this->uart_.tx, ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, MissingPresenceFramesReapplyConfiguration) { + this->boot_(); + this->feed_("$DFHPD,1, , , *\r\n"); + EXPECT_TRUE(this->has_target_.state); + EXPECT_EQ(this->next_command_(9000), ""); + // The module restarted on its own and stopped sending frames + ASSERT_EQ(this->next_command_(), "sensorStop"); + EXPECT_FALSE(this->has_target_.has_state()); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +} // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml new file mode 100644 index 00000000000..969abd67776 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test-speed.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common-speed.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp32-idf.yaml b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml new file mode 100644 index 00000000000..e3b03f3e4e1 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml new file mode 100644 index 00000000000..57fa8a65364 --- /dev/null +++ b/tests/components/dfrobot_sen0609/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0609: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml new file mode 100644 index 00000000000..e1805f81c5d --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml @@ -0,0 +1,56 @@ +esphome: + name: uart-mock-dfrobot-sen0609 + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 9600 + # A small model of the module's command line: echo, answer, Done and the prompt + on_tx: + - then: + - lambda: |- + std::string cmd(data.begin(), data.end()); + if (cmd == "\r\n") + return; + std::string reply = cmd + "\r\n"; + if (cmd == "getSWV") + reply += "SoftwareVersion:V1.0.4\r\n"; + reply += "Done\r\nDFRobot:/>"; + id(mock_uart).inject_to_rx_buffer(std::vector(reply.begin(), reply.end())); + +interval: + - interval: 200ms + then: + - lambda: |- + // '$' is kept apart so it is not read as a substitution + static const std::string FRAME = std::string(1, '$') + "DFHPD,1, , , *\r\n"; + id(mock_uart).inject_to_rx_buffer(std::vector(FRAME.begin(), FRAME.end())); + +dfrobot_sen0609: + id: sen0609 + uart_id: mock_uart + +binary_sensor: + - platform: dfrobot_sen0609 + has_target: + name: Has Target + +text_sensor: + - platform: dfrobot_sen0609 + version: + name: Version diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py new file mode 100644 index 00000000000..1146e27d27f --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -0,0 +1,57 @@ +"""Integration test for the DFRobot SEN0609 component against a model of the module's command line. + +Checks the boot sequence: the software version is read and presence frames are parsed. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, EntityState, TextSensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0609( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot reads the software version; presence frames reach the binary sensor.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + states: dict[int, EntityState] = {} + + async def wait_for(condition, what: str) -> None: + try: + async with asyncio.timeout(10.0): + while not condition(): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"Timeout waiting for {what}; states: {states}") + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + version = require_entity(entities, "version", TextSensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity): + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + await wait_for(lambda: state_of(has_target) is True, "presence") + await wait_for(lambda: state_of(version) == "V1.0.4", "software version") From 108ed67d3159d4a0a01acca53917c99265e2cf08 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:30:25 -0400 Subject: [PATCH 22/50] [rx8111] Add basic RX8111CE real-time clock support (#20785) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/rx8111/__init__.py | 4 + esphome/components/rx8111/rx8111.cpp | 143 ++++++++++++++++ esphome/components/rx8111/rx8111.h | 20 +++ esphome/components/rx8111/time.py | 36 ++++ tests/components/rx8111/__init__.py | 7 + tests/components/rx8111/common.yaml | 10 ++ tests/components/rx8111/test.esp32-ard.yaml | 3 + tests/components/rx8111/test.esp32-idf.yaml | 3 + tests/components/rx8111/test.esp8266-ard.yaml | 3 + .../rx8111/test.nrf52-adafruit.yaml | 3 + tests/components/rx8111/test.rp2040-ard.yaml | 3 + tests/components/rx8111/test_rx8111.cpp | 160 ++++++++++++++++++ .../components/rx8111/test_rx8111.py | 38 +++++ 14 files changed, 434 insertions(+) create mode 100644 esphome/components/rx8111/__init__.py create mode 100644 esphome/components/rx8111/rx8111.cpp create mode 100644 esphome/components/rx8111/rx8111.h create mode 100644 esphome/components/rx8111/time.py create mode 100644 tests/components/rx8111/__init__.py create mode 100644 tests/components/rx8111/common.yaml create mode 100644 tests/components/rx8111/test.esp32-ard.yaml create mode 100644 tests/components/rx8111/test.esp32-idf.yaml create mode 100644 tests/components/rx8111/test.esp8266-ard.yaml create mode 100644 tests/components/rx8111/test.nrf52-adafruit.yaml create mode 100644 tests/components/rx8111/test.rp2040-ard.yaml create mode 100644 tests/components/rx8111/test_rx8111.cpp create mode 100644 tests/unit_tests/components/rx8111/test_rx8111.py diff --git a/CODEOWNERS b/CODEOWNERS index 46b94954fdc..3cbf36777c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -510,6 +510,7 @@ esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom +esphome/components/rx8111/* @techdregs esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/samsung/* @jorofi diff --git a/esphome/components/rx8111/__init__.py b/esphome/components/rx8111/__init__.py new file mode 100644 index 00000000000..61505825072 --- /dev/null +++ b/esphome/components/rx8111/__init__.py @@ -0,0 +1,4 @@ +"""Epson RX8111CE real-time clock.""" + +CODEOWNERS = ["@techdregs"] +DOMAIN = "rx8111" diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp new file mode 100644 index 00000000000..b3f26cb2611 --- /dev/null +++ b/esphome/components/rx8111/rx8111.cpp @@ -0,0 +1,143 @@ +#include "rx8111.h" +#include "esphome/core/log.h" + +namespace esphome::rx8111 { + +ESPHOME_LOG_TAG(TAG, "rx8111"); + +static constexpr uint8_t RX8111_REG_SEC = 0x10; +static constexpr uint8_t RX8111_REG_FLAG = 0x1E; +static constexpr uint8_t RX8111_REG_CONTROL = 0x1F; +static constexpr uint8_t RX8111_FLAG_POR = 0x80; +static constexpr uint8_t RX8111_FLAG_VLF = 0x02; +static constexpr uint8_t RX8111_FLAG_XST = 0x01; +static constexpr uint8_t RX8111_CTRL_STOP = 0x01; +// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. +static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; + +static constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } + +void RX8111Component::setup() { this->read_time(); } + +void RX8111Component::update() { this->read_time(); } + +void RX8111Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8111:"); + LOG_I2C_DEVICE(this); + RealTimeClock::dump_config(); +} + +void RX8111Component::read_time() { + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) { + ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time"); + this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time")); + return; + } + + uint8_t buf[7]; + // I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot. + if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF}; + for (size_t i = 0; i < sizeof(buf); i++) { + if (i == 3) + continue; // Weekday uses one-hot encoding, not BCD. + buf[i] &= CALENDAR_MASKS[i]; + if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) { + ESP_LOGW(TAG, "Invalid BCD in RTC time"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + } + const uint8_t onehot = buf[3]; + if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) { + ESP_LOGW(TAG, "Invalid RTC weekday"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); + ESPTime rtc_time{ + .second = bcd_to_uint8(buf[0]), + .minute = bcd_to_uint8(buf[1]), + .hour = bcd_to_uint8(buf[2]), + .day_of_week = weekday, + .day_of_month = bcd_to_uint8(buf[4]), + .month = bcd_to_uint8(buf[5]), + .year = static_cast(bcd_to_uint8(buf[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { + ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + ESP_LOGW(TAG, "RTC weekday does not match calendar date"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + + this->status_clear_warning(); + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +void RX8111Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid() || now.year > 2099) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC"); + return; + } + if (!this->set_stop_bit_(true)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + uint8_t buf[7]; + buf[0] = uint8_to_bcd(now.second); + buf[1] = uint8_to_bcd(now.minute); + buf[2] = uint8_to_bcd(now.hour); + buf[3] = 1 << (now.day_of_week - 1); + buf[4] = uint8_to_bcd(now.day_of_month); + buf[5] = uint8_to_bcd(now.month); + buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); + // Always try to restart the counter, even after a failed calendar write. + const bool restarted = this->set_stop_bit_(false); + if (!written || !restarted) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); +} + +bool RX8111Component::set_stop_bit_(bool stop) { + uint8_t control; + if (!this->read_byte(RX8111_REG_CONTROL, &control)) + return false; + if (stop) { + control |= RX8111_CTRL_STOP; + } else { + control &= ~RX8111_CTRL_STOP; + } + return this->write_byte(RX8111_REG_CONTROL, control); +} + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/rx8111.h b/esphome/components/rx8111/rx8111.h new file mode 100644 index 00000000000..9405d362b0a --- /dev/null +++ b/esphome/components/rx8111/rx8111.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8111 { + +class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool set_stop_bit_(bool stop); +}; + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py new file mode 100644 index 00000000000..0b166807a22 --- /dev/null +++ b/esphome/components/rx8111/time.py @@ -0,0 +1,36 @@ +"""Epson RX8111CE time platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@techdregs"] +DEPENDENCIES = ["i2c"] + +rx8111_ns = cg.esphome_ns.namespace("rx8111") +RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(RX8111Component)} +).extend(i2c.i2c_device_schema(0x32)) + +RX8111_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(RX8111Component)} +) + +automation.register_apply_action( + "rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) +automation.register_apply_action( + "rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()") +) + + +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) + await time.register_time(var, config) diff --git a/tests/components/rx8111/__init__.py b/tests/components/rx8111/__init__.py new file mode 100644 index 00000000000..ad1f584730f --- /dev/null +++ b/tests/components/rx8111/__init__.py @@ -0,0 +1,7 @@ +"""Load the RX8111 time platform's dependencies in the C++ test build.""" + +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/rx8111/common.yaml b/tests/components/rx8111/common.yaml new file mode 100644 index 00000000000..c53dcad6606 --- /dev/null +++ b/tests/components/rx8111/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + then: + - rx8111.read_time: rtc + - rx8111.write_time: rtc + +time: + - platform: rx8111 + id: rtc + i2c_id: i2c_bus diff --git a/tests/components/rx8111/test.esp32-ard.yaml b/tests/components/rx8111/test.esp32-ard.yaml new file mode 100644 index 00000000000..9fc690ff826 --- /dev/null +++ b/tests/components/rx8111/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp32-idf.yaml b/tests/components/rx8111/test.esp32-idf.yaml new file mode 100644 index 00000000000..5368401cdae --- /dev/null +++ b/tests/components/rx8111/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp8266-ard.yaml b/tests/components/rx8111/test.esp8266-ard.yaml new file mode 100644 index 00000000000..0d9801376df --- /dev/null +++ b/tests/components/rx8111/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.nrf52-adafruit.yaml b/tests/components/rx8111/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..47fa151be55 --- /dev/null +++ b/tests/components/rx8111/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.rp2040-ard.yaml b/tests/components/rx8111/test.rp2040-ard.yaml new file mode 100644 index 00000000000..4e7128f3b2a --- /dev/null +++ b/tests/components/rx8111/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp new file mode 100644 index 00000000000..15a7b75c5db --- /dev/null +++ b/tests/components/rx8111/test_rx8111.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include + +#include "esphome/components/rx8111/rx8111.h" + +namespace esphome::rx8111::testing { + +static constexpr uint8_t ADDRESS = 0x32; +static constexpr uint8_t SEC = 0x10; +static constexpr uint8_t WEEK = 0x13; +static constexpr uint8_t DAY = 0x14; +static constexpr uint8_t MONTH = 0x15; +static constexpr uint8_t FLAG = 0x1E; +static constexpr uint8_t CONTROL = 0x1F; + +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + std::vector writes; + int fail_read{-1}; + int fail_write{-1}; + bool latch_event_before_flag_write{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, + size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + EXPECT_GT(out_size, 0); + if (out_size == 0) + return i2c::ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (reg == this->fail_read) + return i2c::ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + } else { + if (reg == this->fail_write) + return i2c::ERROR_TIMEOUT; + this->writes.push_back(reg); + if (reg == FLAG) { + if (this->latch_event_before_flag_write) + this->regs[FLAG] |= 0x04; // EVF latches immediately before FLAG write. + // RX8111 flags are write-zero-to-clear; writing one leaves the bit unchanged. + this->regs[FLAG] &= out[1]; + } else { + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + } + } + return i2c::ERROR_OK; + } +}; + +class RX8111Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); + } + + RegisterBus bus_; + RX8111Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) { + this->bus_.regs[FLAG] = 0xB8; // POR + existing UF/TF/AF. + this->bus_.regs[CONTROL] = 0x5C; + this->bus_.latch_event_before_flag_write = true; + const auto before = ESPTime::from_epoch_utc(::time(nullptr)); + this->rtc_.write_time(); + const auto after = ESPTime::from_epoch_utc(::time(nullptr)); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); + EXPECT_EQ(this->bus_.regs[FLAG], 0x3C); // The newly latched EVF survives too. + auto bcd = [](int value) { return (value / 10) * 16 + value % 10; }; + EXPECT_TRUE((this->bus_.regs[SEC] == bcd(before.second) && this->bus_.regs[DAY] == bcd(before.day_of_month)) || + (this->bus_.regs[SEC] == bcd(after.second) && this->bus_.regs[DAY] == bcd(after.day_of_month))); + EXPECT_TRUE(this->bus_.regs[WEEK] == (1 << (before.day_of_week - 1)) || + this->bus_.regs[WEEK] == (1 << (after.day_of_week - 1))); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, SEC, CONTROL, FLAG})); +} + +TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) { + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x83; + this->bus_.writes.clear(); + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.regs[FLAG] = 0; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_FALSE(this->rtc_.status_has_warning()); + const uint8_t valid_weekday = this->bus_.regs[WEEK]; + const uint8_t valid_day = this->bus_.regs[DAY]; + const uint8_t valid_month = this->bus_.regs[MONTH]; + const uint8_t wrong_weekday = valid_weekday == 1 ? 2 : 1; + for (uint8_t weekday : {uint8_t{0x00}, uint8_t{0x03}, uint8_t{0x80}, wrong_weekday}) { + this->bus_.regs[WEEK] = weekday; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1) << "weekday " << unsigned(weekday); + EXPECT_TRUE(this->rtc_.status_has_warning()); + } + this->bus_.regs[WEEK] = valid_weekday; + this->bus_.regs[DAY] = 0x32; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = 0x31; + this->bus_.regs[MONTH] = 0x04; // April 31 is always invalid. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = valid_day; + this->bus_.regs[MONTH] = valid_month; + this->bus_.regs[SEC] = 0x6A; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x60; // ESPTime permits leap seconds, but RX8111 only counts 00..59. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x00; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 2); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) { + this->bus_.fail_read = FLAG; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = SEC; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + this->bus_.fail_read = -1; + this->bus_.fail_write = CONTROL; + this->rtc_.write_time(); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.fail_write = SEC; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, CONTROL})); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); + this->bus_.fail_write = FLAG; + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0x83); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); +} + +} // namespace esphome::rx8111::testing diff --git a/tests/unit_tests/components/rx8111/test_rx8111.py b/tests/unit_tests/components/rx8111/test_rx8111.py new file mode 100644 index 00000000000..48b35d43e08 --- /dev/null +++ b/tests/unit_tests/components/rx8111/test_rx8111.py @@ -0,0 +1,38 @@ +"""Basic RX8111 configuration regressions.""" + +from pathlib import Path + +import pytest + +from esphome.config import validate_config +from esphome.core import CORE + + +def configuration(tmp_path: Path, actions: list | None = None) -> dict: + return { + "esphome": {"name": "rx8111-clock-test", "build_path": str(tmp_path / "build")}, + "esp32": {"board": "esp32dev", "framework": {"type": "esp-idf"}}, + "logger": {}, + "i2c": {"sda": 21, "scl": 22, "scan": False}, + "time": [{"platform": "rx8111", "id": "rtc"}], + "script": [ + { + "id": "clock_actions", + "then": actions or [{"rx8111.read_time": {"id": "rtc"}}], + } + ], + } + + +def validate_rx8111(tmp_path: Path, config: dict): + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + return validate_config(config, {}) + + +@pytest.mark.parametrize("action", ["rx8111.read_time", "rx8111.write_time"]) +def test_actions(tmp_path: Path, action: str) -> None: + for payload in ({}, {"id": "rtc"}): + assert not validate_rx8111( + tmp_path, configuration(tmp_path, [{action: payload}]) + ).errors From 45ea5f422080d7e3ee09abc9d99fc639add45ca6 Mon Sep 17 00:00:00 2001 From: Adminy <22717869+adminy@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:03:53 +0100 Subject: [PATCH 23/50] [nrf52] nrf52840dongle support (#19009) 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/nrf52/__init__.py | 338 ++++++++++++------ esphome/components/nrf52/boards.py | 22 ++ esphome/components/nrf52/const.py | 1 + esphome/components/nrf52/framework.py | 93 +++++ .../components/zephyr_mcumgr/ota/__init__.py | 4 +- .../nrf52/test.nrf52-nrf52840dongle.yaml | 3 + .../zigbee/test.nrf52-nrf52840dongle.yaml | 2 + ..._components_base.nrf52-nrf52840dongle.yaml | 16 + tests/unit_tests/test_download_types.py | 8 +- tests/unit_tests/test_espidf_toolchain.py | 4 +- tests/unit_tests/test_nrf52_config.py | 101 +++++- tests/unit_tests/test_nrf52_nrfutil.py | 157 ++++++++ tests/unit_tests/test_nrf52_upload.py | 240 +++++++++++-- 13 files changed, 836 insertions(+), 153 deletions(-) create mode 100644 tests/components/nrf52/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zigbee/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml create mode 100644 tests/unit_tests/test_nrf52_nrfutil.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index fcdffc0c645..b0c375eb53d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -40,6 +40,8 @@ from esphome.const import ( CONF_ENABLE_OTA_ROLLBACK, CONF_FRAMEWORK, CONF_ID, + CONF_INVERTED, + CONF_NUMBER, CONF_OTA, CONF_RESET_PIN, CONF_SAFE_MODE, @@ -66,15 +68,17 @@ from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from .boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG, SELF_RESET_PINS from .const import ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) from .framework import ( check_and_install, + ensure_nordic_nrfutil, get_build_env, get_build_paths, setup_platformio_python_env, @@ -192,13 +196,16 @@ def set_framework(config: ConfigType) -> ConfigType: )(config) -BOOTLOADERS = [ +# Bootloaders flashed over USB serial DFU: the Adafruit ones with adafruit-nrfutil, the factory Open +# DFU bootloader with Nordic's nrfutil; mcuboot uses mcumgr instead +SERIAL_DFU_BOOTLOADERS = ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - BOOTLOADER_MCUBOOT, -] + BOOTLOADER_NRF, +) +BOOTLOADERS = [*SERIAL_DFU_BOOTLOADERS, BOOTLOADER_MCUBOOT] _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) @@ -250,6 +257,18 @@ _DFU_SCHEMA = cv.Schema( ) +def _default_self_reset_pin(config: ConfigType) -> ConfigType: + # The factory bootloader ignores the retained register the touch sets, so a board with a GPIO + # wired to RESET resets itself through that pin + dfu = config.get(CONF_DFU) + number = SELF_RESET_PINS.get(config[CONF_BOARD]) + if dfu is not None and number and CONF_RESET_PIN not in dfu: + dfu[CONF_RESET_PIN] = pins.gpio_output_pin_schema( + {CONF_NUMBER: number, CONF_INVERTED: True} + ) + return config + + def _dfu_schema(value: bool | ConfigType) -> ConfigType: if isinstance(value, bool): if not value: @@ -300,6 +319,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _default_self_reset_pin, _resolve_toolchain, set_framework, ) @@ -311,6 +331,15 @@ def _validate_mcumgr(config): raise cv.Invalid(f"'{bootloader}' bootloader does not support DFU") +def _validate_open_dfu_toolchain(config: ConfigType) -> None: + # The deprecated PlatformIO toolchain configures every non-mcuboot upload for a touch-reset + # bootloader, which the factory Open DFU bootloader is not + if config[KEY_BOOTLOADER] == BOOTLOADER_NRF and CORE.using_toolchain_platformio: + raise cv.Invalid( + f"'{BOOTLOADER_NRF}' bootloader requires 'toolchain: {Toolchain.SDK_NRF}'" + ) + + def _final_validate(config): # Remove before 2027.2.0 @@ -319,6 +348,7 @@ def _final_validate(config): "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " "Please use 'toolchain: sdk-nrf' instead." ) + _validate_open_dfu_toolchain(config) if CONF_DFU in config: _validate_mcumgr(config) @@ -470,6 +500,15 @@ async def to_code(config: ConfigType) -> None: }; """ ) + if config[CONF_BOARD] == "nrf52840dongle": + # The board DTS enables uart0 on edge GPIOs (P0.17, P0.22, P0.24) although nothing is wired to it + zephyr_add_overlay( + """ + &uart0 { + status = "disabled"; + }; + """ + ) zephyr_add_prj_conf("REBOOT", True) # some boards enable USB and UART by default. @@ -535,12 +574,12 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: return [] types = [] UF2_PATH = "zephyr/zephyr.uf2" - DFU_PATH = "firmware.zip" HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1 without OTA, SDK 3.4.0+ HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2 to 3.3.x, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent - if (build_dir / UF2_PATH).is_file(): + has_uf2 = (build_dir / UF2_PATH).is_file() + if has_uf2: types = [ { "title": "UF2 package (recommended)", @@ -548,12 +587,6 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "file": UF2_PATH, "download": f"{storage_json.name}.uf2", }, - { - "title": "DFU package", - "description": "For flashing via adafruit-nrfutil using USB CDC.", - "file": DFU_PATH, - "download": f"dfu-{storage_json.name}.zip", - }, ] else: types = [ @@ -568,6 +601,16 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] + # Bootloaders without a UF2 family (the dongle's Open DFU bootloader) still get a DFU package + if (build_dir / DFU_PACKAGE).is_file(): + types += [ + { + "title": "DFU package", + "description": "For flashing via nrfutil using USB CDC.", + "file": DFU_PACKAGE, + "download": f"dfu-{storage_json.name}.zip", + }, + ] if (build_dir / APP_IMAGE_PATH).is_file(): types += [ { @@ -592,6 +635,105 @@ def _upload_using_platformio( return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args) +# USB vendor and product id the nRF52840 Dongle enumerates with while its Open DFU bootloader runs +OPEN_DFU_USB_ID = (0x1915, 0x521F) +DFU_PACKAGE = "firmware.zip" # the serial DFU package, next to the firmware in the build directory + + +def _reset_into_bootloader(host: str) -> None: + """Send the 1200 bps touch that resets an Adafruit bootloader into DFU mode.""" + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + +def _wait_for_port_permissions(host: str) -> None: + """Give udev a moment to set up the re-enumerated port before using it.""" + import time as _time + + from esphome.__main__ import check_permissions + + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + return + except EsphomeError: + _time.sleep(0.05) + check_permissions(host) # raises with helpful message + + +def _open_dfu_ports() -> set[str]: + import serial.tools.list_ports as _list_ports + + return { + port.device + for port in _list_ports.comports(include_links=True) + if (port.vid, port.pid) == OPEN_DFU_USB_ID + } + + +def _enter_open_dfu(host: str) -> str: + """The port of the Open DFU bootloader: ``host`` when it is one already, otherwise the one that + appears after the 1200 bps touch reboots an application built with ``dfu:`` into it. The + bootloader is its own USB device, so it never comes back under the application's path, and + only a port that was not there before the touch counts, not another dongle's bootloader.""" + import time as _time + + import serial as _serial + + before = _open_dfu_ports() + if host in before: + return host + try: + _serial.Serial(host, baudrate=1200, timeout=1).close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if appeared := _open_dfu_ports() - before: + port = min(appeared) + _wait_for_port_permissions(port) + return port + raise EsphomeError( + f"{host} did not enter the Open DFU bootloader. An application built with 'dfu:' enters it " + "on upload; otherwise press the RESET button on the side of the dongle (the red LED pulses " + "while the bootloader runs) and upload again." + ) + + def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type @@ -610,97 +752,57 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if get_port_type(host) == PortType.SERIAL: check_permissions(host) - if zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader == BOOTLOADER_MCUBOOT: mcumgr_device = host + elif CORE.using_toolchain_platformio: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True + elif bootloader not in SERIAL_DFU_BOOTLOADERS: + raise EsphomeError( + f"Serial upload is not implemented for the {bootloader} bootloader" + ) else: - if not CORE.using_toolchain_platformio: - bootloader = zephyr_data()[KEY_BOOTLOADER] - if bootloader not in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - raise EsphomeError("Not implemented yet") + env = get_build_env(None) # no compile, just the DFU tool + dfu_package = CORE.relative_pioenvs_path(CORE.name) / DFU_PACKAGE + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + if bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol + dfu_cmd = [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "dfu", + "usb-serial", + "-pkg", + str(dfu_package), + "-p", + _enter_open_dfu(host), + ] + else: check_and_install() paths = get_build_paths() - env = get_build_env(None) # no compile, just nrfutil - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - if not dfu_package.is_file(): - raise EsphomeError("Firmware not found. Please compile first.") - import time as _time - - import serial as _serial - import serial.tools.list_ports as _list_ports - - try: - ser = _serial.Serial(host, baudrate=1200, timeout=1) - ser.close() - except _serial.SerialException as err: - raise EsphomeError(f"Failed to open {host}: {err}") from err - - # Wait for device to reset (port disappears) - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host not in {p.device for p in _list_ports.comports()}: - break - else: - _LOGGER.warning( - "Device did not leave %s within 5 s; " - "it may not have entered bootloader mode", - host, - ) - - # Wait for DFU port to reappear - deadline = _time.monotonic() + 10 - while _time.monotonic() < deadline: - _time.sleep(0.1) - if host in {p.device for p in _list_ports.comports()}: - break - else: - raise EsphomeError( - f"DFU port {host!r} did not reappear within 10 s. " - "Check that the device entered DFU mode." - ) - - # Wait for udev to finish setting up device permissions - deadline = _time.monotonic() + 5 - while _time.monotonic() < deadline: - try: - check_permissions(host) - break - except EsphomeError: - _time.sleep(0.05) - else: - check_permissions(host) # raises with helpful message - - python = str(paths["python_executable"]) - if not run_command_ok( - [ - python, - "-m", - "nordicsemi.__main__", - "dfu", - "serial", - "-pkg", - str(dfu_package), - "-p", - host, - "-b", - "115200", - "--singlebank", - ], - env=env, - stream_output=True, - ): - raise EsphomeError("nRF52 serial DFU upload failed") - else: - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: serial upload + _reset_into_bootloader(host) + _wait_for_port_permissions(host) + dfu_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ] + if not run_command_ok(dfu_cmd, env=env, stream_output=True): + raise EsphomeError("nRF52 serial DFU upload failed") + return True if host == "PYOCD": if not CORE.using_toolchain_platformio: @@ -1099,13 +1201,35 @@ def run_compile(args, config: ConfigType) -> bool: ): raise EsphomeError(f"Failed to generate UF2 from {hex_file.name}") - if bootloader in ( - BOOTLOADER_ADAFRUIT, - BOOTLOADER_ADAFRUIT_NRF52_SD132, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - ): - dfu_package = build_dir / "firmware.zip" + # The serial DFU package follows the hex it is made from; a package from another bootloader's + # build would otherwise be offered for download with this one + dfu_package = build_dir / DFU_PACKAGE + if bootloader not in SERIAL_DFU_BOOTLOADERS: + dfu_package.unlink(missing_ok=True) + elif bootloader == BOOTLOADER_NRF: + # The factory Open DFU bootloader speaks Nordic's Secure DFU protocol, which only Nordic's own + # tool packages + if not run_command_ok( + [ + ensure_nordic_nrfutil(env), + "nrf5sdk-tools", + "pkg", + "generate", + "--hw-version", + "52", + "--sd-req", + "0x00", + "--application-version", + "1", + "--application", + str(zephyr_dir / "zephyr.hex"), + str(dfu_package), + ], + env=env, + stream_output=True, + ): + raise EsphomeError("Failed to create the DFU package") + else: genpkg_cmd = [ str(paths["python_executable"]), "-m", @@ -1118,6 +1242,6 @@ def run_compile(args, config: ConfigType) -> bool: genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] if not run_command_ok(genpkg_cmd, env=env, stream_output=True): - raise EsphomeError("Failed to create adafruit DFU package") + raise EsphomeError("Failed to create the DFU package") return True diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 564bf560d69..3f5d9d1b0e3 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -6,6 +6,7 @@ from .const import ( BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, ) BOARDS_ZEPHYR = { @@ -33,8 +34,24 @@ BOARDS_ZEPHYR = { BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ] }, + # Nordic nRF52840 Dongle (PCA10059): factory Open DFU bootloader at 0xE0000, kept in place + "nrf52840dongle": {KEY_BOOTLOADER: [BOOTLOADER_NRF]}, } +# Boards with a GPIO wired to their RESET pin: driving it low is a pin reset, which the factory +# bootloader takes as its cue to stay in DFU mode (Nordic's own USB DFU trigger does the same) +SELF_RESET_PINS = { + "nrf52840dongle": "P0.19", +} + +# Bootloaders whose layout has a SoftDevice and the Adafruit bootloader, between which the mcumgr OTA +# image slot is placed +MCUMGR_OTA_BOOTLOADERS = ( + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, +) + # https://github.com/ffenix113/zigbee_home/blob/17bb7b9e9d375e756da9e38913f53303937fb66a/types/board/known_boards.go # https://learn.adafruit.com/introducing-the-adafruit-nrf52840-feather?view=all#hathach-memory-map BOOTLOADER_CONFIG = { @@ -50,4 +67,9 @@ BOOTLOADER_CONFIG = { Section("SoftDevice", 0x0, 0x27000, "flash_primary"), Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"), ], + # The board's BOARD_HAS_NRF5_BOOTLOADER already reserves the MBR page at 0x0, so the application + # links at 0x1000, where the bootloader loads it; only the bootloader itself is reserved here + BOOTLOADER_NRF: [ + Section("open_bootloader", 0xE0000, 0x20000, "flash_primary"), + ], } diff --git a/esphome/components/nrf52/const.py b/esphome/components/nrf52/const.py index 977ca2252ae..166e79bacc6 100644 --- a/esphome/components/nrf52/const.py +++ b/esphome/components/nrf52/const.py @@ -2,6 +2,7 @@ BOOTLOADER_ADAFRUIT = "adafruit" BOOTLOADER_ADAFRUIT_NRF52_SD132 = "adafruit_nrf52_sd132" BOOTLOADER_ADAFRUIT_NRF52_SD140_V6 = "adafruit_nrf52_sd140_v6" BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 = "adafruit_nrf52_sd140_v7" +BOOTLOADER_NRF = "nrf" EXTRA_ADC = [ "VDD", diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 00796562f42..6c60e58462a 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -19,6 +19,7 @@ from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( create_venv, download_and_extract, + download_with_resume, get_python_env_executable_path, rmdir, run_command_ok, @@ -680,6 +681,98 @@ def _check_and_install(version: str) -> None: _install_toolchain() +# Nordic's nrfutil launcher, one static binary per host; its commands are installed from Nordic's +# package index by the launcher itself. The path carries no version, so the file is pinned by the +# size and digest Nordic's file store reported for launcher 8.2.1 (2026-09-09) +NORDIC_NRFUTIL_URL = "https://files.nordicsemi.com/artifactory/swtools/external/nrfutil/executables/{triple}/{name}" +_NRFUTIL_LAUNCHERS: dict[str, tuple[int, str]] = { + "x86_64-unknown-linux-gnu": ( + 6725408, + "1cf4bd1d3d64f43a6b9602d8d8e8d647bec0d26d517ba72fd154f95546b50260", + ), + "aarch64-unknown-linux-gnu": ( + 5506040, + "e7310f072132766e69b43c833c6a87a53367ea68d5b8a5f7c6fa1a45993ecde4", + ), + "aarch64-apple-darwin": ( + 5263376, + "1b993eece5a6e3e079ce25fa31a134ce4c12fb51735d10114a2ea4a1df048591", + ), + "x86_64-apple-darwin": ( + 6389488, + "a5ffea65325e5d38eae00f612472e18ffe2c036f800240873d9f702c1b53d945", + ), + "x86_64-pc-windows-msvc": ( + 5939032, + "22cb1bd03fc29016670c1fc8408a78bd213286ecc1156c70712485d77b097c75", + ), +} +_NRFUTIL_OS_SUFFIX = { + "linux": "unknown-linux-gnu", + "macos": "apple-darwin", + "windows": "pc-windows-msvc", +} +# The nrf5sdk-tools command gives the launcher the pkg and dfu subcommands; a new version reinstalls +NRF5SDK_TOOLS_VERSION = "1.1.0" + + +def _nrfutil_target_triple() -> str: + sysname, machine, _ = _get_toolchain_platform_info() + triple = f"{machine}-{_NRFUTIL_OS_SUFFIX.get(sysname, sysname)}" + if triple not in _NRFUTIL_LAUNCHERS: + raise EsphomeError(f"Nordic's nrfutil is not available for {sysname} {machine}") + return triple + + +def ensure_nordic_nrfutil(env: dict[str, str]) -> str: + """Nordic's nrfutil with its nrf5sdk-tools command, downloaded into the tools cache on first use. + + The launcher keeps its installed commands under NRFUTIL_HOME, which is set in ``env`` so that + the copy here never touches a user's own ~/.nrfutil. Raises EsphomeError when the download or + the command install fails, for instance without network. + """ + # The downloaded file is a bootstrap launcher: on its first run it installs the real core into + # NRFUTIL_HOME/bin and runs that, so it must not sit there itself or it runs itself forever + root = get_sdk_nrf_tools_path() / "nrfutil" + home = root / "home" + suffix = ".exe" if os.name == "nt" else "" + exe = root / "launcher" / f"nrfutil{suffix}" + command = home / "bin" / f"nrfutil-nrf5sdk-tools{suffix}" + env["NRFUTIL_HOME"] = str(home) + sentinel = root / f".ready-{NRF5SDK_TOOLS_VERSION}" + + # A cache that lost its launcher or command (a partial clean-up) is installed again + def installed() -> bool: + return sentinel.exists() and exe.is_file() and command.is_file() + + if installed(): + return str(exe) + with _install_lock("nrfutil"): + if not installed(): + exe.parent.mkdir(parents=True, exist_ok=True) + _LOGGER.info("Downloading Nordic's nrfutil ...") + triple = _nrfutil_target_triple() + size, sha256 = _NRFUTIL_LAUNCHERS[triple] + download_with_resume( + NORDIC_NRFUTIL_URL.format(triple=triple, name=exe.name), + exe, + sha256=sha256, + size=size, + ) + exe.chmod(0o755) + _LOGGER.info("Installing nrfutil's nrf5sdk-tools command ...") + if ( + not run_command_ok( + [str(exe), "install", f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}"], + env=env, + ) + or not command.is_file() + ): + raise EsphomeError("Failed to install nrfutil's nrf5sdk-tools command") + sentinel.touch() + return str(exe) + + def _install_toolchain() -> None: toolchain_version = _get_toolchain_version() toolchains_dir = _get_toolchain_path(toolchain_version) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ed7069a5403..44c8be9e374 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( @@ -102,7 +102,7 @@ def _validate_bootloader(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader == BOOTLOADER_MCUBOOT: return - if bootloader not in BOOTLOADER_CONFIG: + if bootloader not in MCUMGR_OTA_BOOTLOADERS: raise cv.Invalid(f"{bootloader} does not support OTA") framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): diff --git a/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..c65ad301f8a --- /dev/null +++ b/tests/components/nrf52/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,3 @@ +nrf52: + dcdc: False + dfu: true # the 1200 bps touch reboots into the factory bootloader diff --git a/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..ada15caa1ec --- /dev/null +++ b/tests/components/zigbee/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zigbee: !include common_nrf52.yaml diff --git a/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..30a65c9739d --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-nrf52840dongle.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: nrf52840dongle + bootloader: nrf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 0f5aae444d0..b694ce74533 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -96,15 +96,19 @@ def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: [ # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. ( - ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], ), - (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + (["zephyr/zephyr.uf2", "firmware.zip"], ["zephyr/zephyr.uf2", "firmware.zip"]), + # The DFU package is offered only when the build made one + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2"]), ( ["zephyr/merged.hex", "zephyr/app_update.bin"], ["zephyr/merged.hex", "zephyr/app_update.bin"], ), (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + # The dongle's Open DFU bootloader has no UF2 family, so its DFU package rides beside the HEX + (["zephyr/merged.hex", "firmware.zip"], ["zephyr/merged.hex", "firmware.zip"]), ([], ["zephyr/zephyr.hex"]), ], ) diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 3cba1a7357d..43ad9131a96 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -1275,11 +1275,11 @@ def test_run_compile_stops_on_ninja_failure(setup_core: Path) -> None: @pytest.mark.parametrize("memory_ld_rc", [0, 3]) def test_run_compile_testing_mode_builds_memory_ld_first( - setup_core: Path, memory_ld_rc: int + setup_core: Path, memory_ld_rc: int, monkeypatch: pytest.MonkeyPatch ) -> None: """Testing mode builds and patches memory.ld before the main build.""" _setup_build(setup_core) - CORE.testing_mode = True + monkeypatch.setattr(CORE, "testing_mode", True) # CORE.reset() does not clear it targets: list[tuple[str, ...]] = [] def record(*run_targets: str, **kwargs: object) -> int: diff --git a/tests/unit_tests/test_nrf52_config.py b/tests/unit_tests/test_nrf52_config.py index 4d9d281ebfe..eb13a55e488 100644 --- a/tests/unit_tests/test_nrf52_config.py +++ b/tests/unit_tests/test_nrf52_config.py @@ -1,12 +1,111 @@ """Tests for the nRF52 configuration validation.""" +from collections.abc import Generator + import pytest -from esphome.components.nrf52 import _detect_bootloader +from esphome.components.nrf52 import ( + _default_self_reset_pin, + _detect_bootloader, + _validate_open_dfu_toolchain, + set_core_data, +) +from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.const import BOOTLOADER_NRF +from esphome.components.zephyr.const import KEY_PM_STATIC, KEY_ZEPHYR +from esphome.components.zephyr_mcumgr.ota import _validate_bootloader +from esphome.config import path_context import esphome.config_validation as cv +from esphome.const import CONF_INVERTED, CONF_NUMBER, KEY_CORE, Toolchain +from esphome.core import CORE def test_detect_bootloader_reports_a_missing_board() -> None: """The bootloader check runs before the schema, so it reports the missing key.""" with pytest.raises(cv.Invalid, match="'board' is a required option"): _detect_bootloader({}) + + +def test_dongle_defaults_to_nrf_bootloader() -> None: + """The dongle's only bootloader is the factory one, so it is selected without a config line.""" + config = _detect_bootloader({"board": "nrf52840dongle"}) + assert config["bootloader"] == BOOTLOADER_NRF + + +def test_dongle_rejects_foreign_bootloader() -> None: + with pytest.raises(cv.Invalid, match="nrf52840dongle does not support"): + _detect_bootloader({"board": "nrf52840dongle", "bootloader": "mcuboot"}) + + +def _dongle_core_data() -> None: + CORE.data[KEY_CORE] = {} + CORE.testing_mode = ( + False # the real flash, not the larger one faked for grouped tests + ) + set_core_data({"board": "nrf52840dongle", "bootloader": BOOTLOADER_NRF}) + + +@pytest.fixture +def _path_ctx() -> Generator[None]: + """A pin schema reports its errors against the config path, so one has to be set.""" + token = path_context.set(["nrf52"]) + yield + path_context.reset(token) + + +def test_nrf_bootloader_reserves_factory_region() -> None: + """Only the Open DFU bootloader is registered as a static partition; the board's own + nrf5_mbr partition takes the first page, so the app lands at 0x1000 where the bootloader loads it.""" + _dongle_core_data() + registered = {s.name: s for s in CORE.data[KEY_ZEPHYR][KEY_PM_STATIC]} + assert {s.name for s in BOOTLOADER_CONFIG[BOOTLOADER_NRF]} <= set(registered) + assert "mbr" not in registered + assert registered["open_bootloader"].address == 0xE0000 + assert registered["open_bootloader"].end_address == 0x100000 + + +def test_nrf_bootloader_rejects_mcumgr_ota() -> None: + """OTA with mcumgr places its slot against a SoftDevice, which this layout has none of.""" + _dongle_core_data() + with pytest.raises(cv.Invalid, match="does not support OTA"): + _validate_bootloader({}) + + +def test_nrf_bootloader_accepts_the_sdk_nrf_toolchain() -> None: + CORE.toolchain = Toolchain.SDK_NRF + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +def test_nrf_bootloader_rejects_the_platformio_toolchain() -> None: + """The deprecated PlatformIO upload path assumes a touch-reset bootloader.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="sdk-nrf"): + _validate_open_dfu_toolchain({"bootloader": BOOTLOADER_NRF}) + + +@pytest.mark.usefixtures("_path_ctx") +def test_dongle_dfu_resets_itself_through_the_pin_wired_to_reset() -> None: + """The factory bootloader ignores the touch's register, so P0.19 pulls RESET for it.""" + _dongle_core_data() + config = _default_self_reset_pin({"board": "nrf52840dongle", "dfu": {}}) + pin = config["dfu"]["reset_pin"] + assert pin[CONF_NUMBER] == 19 + assert pin[CONF_INVERTED] is True + + +@pytest.mark.usefixtures("_path_ctx") +def test_dfu_reset_pin_given_by_the_user_is_kept() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "nrf52840dongle", "dfu": {"reset_pin": {"number": 5}}} + ) + assert config["dfu"]["reset_pin"] == {"number": 5} + + +@pytest.mark.usefixtures("_path_ctx") +def test_other_boards_get_no_reset_pin() -> None: + _dongle_core_data() + config = _default_self_reset_pin( + {"board": "adafruit_itsybitsy_nrf52840", "dfu": {}} + ) + assert "reset_pin" not in config["dfu"] diff --git a/tests/unit_tests/test_nrf52_nrfutil.py b/tests/unit_tests/test_nrf52_nrfutil.py new file mode 100644 index 00000000000..c544dbfd8d5 --- /dev/null +++ b/tests/unit_tests/test_nrf52_nrfutil.py @@ -0,0 +1,157 @@ +"""Nordic's nrfutil is downloaded into the tools cache and keeps its commands there.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.components.nrf52 import framework +from esphome.components.nrf52.framework import ( + _NRFUTIL_LAUNCHERS, + NRF5SDK_TOOLS_VERSION, + _nrfutil_target_triple, + ensure_nordic_nrfutil, +) +from esphome.core import EsphomeError + +LINUX = ("linux", "x86_64", "tar.xz") + + +@pytest.mark.parametrize( + ("host", "triple"), + [ + (("macos", "aarch64", "tar.xz"), "aarch64-apple-darwin"), + (("macos", "x86_64", "tar.xz"), "x86_64-apple-darwin"), + (LINUX, "x86_64-unknown-linux-gnu"), + (("windows", "x86_64", "7z"), "x86_64-pc-windows-msvc"), + ], +) +def test_launcher_triple_follows_the_host( + host: tuple[str, str, str], triple: str +) -> None: + with patch.object(framework, "_get_toolchain_platform_info", return_value=host): + assert _nrfutil_target_triple() == triple + + +def test_unknown_host_is_a_clear_error() -> None: + with ( + patch.object( + framework, + "_get_toolchain_platform_info", + return_value=("freebsd", "x86_64", "tar.xz"), + ), + pytest.raises(EsphomeError, match="freebsd x86_64"), + ): + _nrfutil_target_triple() + + +# The launcher and its command carry the host's executable suffix +SUFFIX = ".exe" if os.name == "nt" else "" + + +def _fake_download(url: str, dest: Path, **kwargs: object) -> None: + dest.write_bytes(b"launcher") + + +def _fake_install(cmd: list[str], env: dict[str, str]) -> bool: + """The launcher installs its command under NRFUTIL_HOME.""" + command = Path(env["NRFUTIL_HOME"]) / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}" + command.parent.mkdir(parents=True, exist_ok=True) + command.write_bytes(b"command") + return True + + +def test_first_use_downloads_and_installs_into_the_cache(tmp_path: Path) -> None: + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object( + framework, "download_with_resume", side_effect=_fake_download + ) as download, + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + exe = ensure_nordic_nrfutil(env) + + # The launcher sits outside the home it installs into, and the command version is pinned + assert exe == str(tmp_path / "nrfutil" / "launcher" / f"nrfutil{SUFFIX}") + assert env["NRFUTIL_HOME"] == str(tmp_path / "nrfutil" / "home") + # The launcher comes from the host's URL and is checked against the pinned digest + size, sha256 = _NRFUTIL_LAUNCHERS["x86_64-unknown-linux-gnu"] + assert download.call_args[0][0].endswith( + f"/x86_64-unknown-linux-gnu/nrfutil{SUFFIX}" + ) + assert download.call_args[1] == {"sha256": sha256, "size": size} + assert install.call_args[0][0] == [ + exe, + "install", + f"nrf5sdk-tools={NRF5SDK_TOOLS_VERSION}", + ] + assert (tmp_path / "nrfutil" / f".ready-{NRF5SDK_TOOLS_VERSION}").exists() + + +def _installed_cache(tmp_path: Path, with_command: bool = True) -> Path: + root = tmp_path / "nrfutil" + (root / "launcher").mkdir(parents=True) + (root / "launcher" / f"nrfutil{SUFFIX}").write_bytes(b"launcher") + if with_command: + (root / "home" / "bin").mkdir(parents=True) + (root / "home" / "bin" / f"nrfutil-nrf5sdk-tools{SUFFIX}").write_bytes( + b"command" + ) + (root / f".ready-{NRF5SDK_TOOLS_VERSION}").touch() + return root + + +def test_installed_cache_is_used_without_network(tmp_path: Path) -> None: + root = _installed_cache(tmp_path) + env: dict[str, str] = {} + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "download_with_resume") as download, + patch.object(framework, "run_command_ok") as install, + ): + assert ensure_nordic_nrfutil(env) == str(root / "launcher" / f"nrfutil{SUFFIX}") + download.assert_not_called() + install.assert_not_called() + assert env["NRFUTIL_HOME"] == str(root / "home") + + +def test_a_cache_that_lost_its_command_is_installed_again(tmp_path: Path) -> None: + _installed_cache(tmp_path, with_command=False) + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", side_effect=_fake_install) as install, + ): + ensure_nordic_nrfutil({}) + install.assert_called_once() + + +def test_install_without_the_command_file_is_a_failure(tmp_path: Path) -> None: + """The launcher's exit code alone does not prove the command is in place.""" + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=True), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) + + +def test_failed_install_leaves_no_ready_marker(tmp_path: Path) -> None: + with ( + patch.object(framework, "get_sdk_nrf_tools_path", return_value=tmp_path), + patch.object(framework, "_get_toolchain_platform_info", return_value=LINUX), + patch.object(framework, "download_with_resume", side_effect=_fake_download), + patch.object(framework, "run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="nrf5sdk-tools"), + ): + ensure_nordic_nrfutil({}) + assert not list((tmp_path / "nrfutil").glob(".ready-*")) diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py index 9091b429f63..cd5500caa6e 100644 --- a/tests/unit_tests/test_nrf52_upload.py +++ b/tests/unit_tests/test_nrf52_upload.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.nrf52.const import ( + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + BOOTLOADER_NRF, +) from esphome.components.zephyr.const import ( KEY_BOARD, KEY_BOOTLOADER, @@ -217,14 +220,32 @@ class TestUploadProgramSerialPlatformio: # --------------------------------------------------------------------------- +def _prepare_serial_dfu( + tmp_path: Path, bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +) -> tuple[dict, Path]: + """Set up CORE for a serial DFU upload with a built firmware.zip; returns (paths, dfu_package).""" + _setup_nrf52_core(bootloader=bootloader, build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + dfu_package = CORE.relative_pioenvs_path(CORE.name) / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + return _make_paths(tmp_path), dfu_package + + def _enter_serial_dfu_patches( - stack: ExitStack, host: str, tmp_path: Path, paths: dict + stack: ExitStack, + host: str, + tmp_path: Path, + paths: dict, + usb_id: tuple[int, int] | None = None, ) -> MagicMock: """Enter all context managers needed for the serial DFU happy path. Returns the mock for ``run_command_ok`` so callers can inspect calls. comports() returns [] on the first call (port disappeared) and a list - containing the host on every subsequent call (port reappeared). Patches + containing the host on every subsequent call (port reappeared). With + ``usb_id`` set, the port is present with that vid/pid on every call, as it + is for a bootloader that is entered by hand. Patches are applied directly on the real pyserial module attributes so they are visible to the deferred ``import serial[.tools.list_ports] as _x`` statements inside upload_program. @@ -236,11 +257,13 @@ def _enter_serial_dfu_patches( _comports_calls = [0] - def _comports(): + def _comports(**_kwargs): _comports_calls[0] += 1 - if _comports_calls[0] == 1: + if _comports_calls[0] == 1 and usb_id is None: return [] # port disappeared → disappear loop breaks - return [MagicMock(device=host)] # port back → reappear loop breaks + # port back → reappear loop breaks + vid, pid = usb_id or (None, None) + return [MagicMock(device=host, vid=vid, pid=pid)] stack.enter_context( patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) @@ -254,6 +277,12 @@ def _enter_serial_dfu_patches( patch("esphome.components.nrf52.get_build_env", return_value={}) ) stack.enter_context(patch("time.sleep")) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + return_value="/opt/nordic/nrfutil", + ) + ) # Patch directly on the real pyserial module so the deferred imports inside # upload_program see our mocks regardless of how sys.modules is cached. stack.enter_context(patch.object(serial, "Serial")) @@ -266,25 +295,6 @@ def _enter_serial_dfu_patches( class TestUploadProgramSerialDfu: - def test_unsupported_bootloader_raises( - self, setup_core: Path, tmp_path: Path - ) -> None: - """An unknown bootloader must raise EsphomeError before touching the port.""" - from esphome.components.nrf52 import upload_program - from esphome.upload_targets import PortType - - _setup_nrf52_core( - bootloader="unknown_bootloader", build_path=tmp_path / "build" - ) - CORE.config_path = tmp_path / "test.yaml" - - with ( - patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), - patch("esphome.__main__.check_permissions"), - pytest.raises(EsphomeError, match="Not implemented"), - ): - upload_program(config={}, args=None, host="/dev/ttyACM0") - def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: """Missing firmware.zip must raise EsphomeError before opening the serial port.""" from esphome.components.nrf52 import upload_program @@ -313,13 +323,7 @@ class TestUploadProgramSerialDfu: """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, dfu_package = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: @@ -338,17 +342,175 @@ class TestUploadProgramSerialDfu: assert host in cmd assert "--singlebank" in cmd + def test_serial_upload_refuses_an_unlisted_bootloader( + self, setup_core: Path, tmp_path: Path + ) -> None: + """A bootloader without a serial DFU path fails before any tool runs.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, "future") + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + with pytest.raises(EsphomeError, match="not implemented for the future"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_skips_the_touch( + self, setup_core: Path, tmp_path: Path + ) -> None: + """The factory Open DFU bootloader has no auto-entry, so no 1200 bps touch is sent.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + assert upload_program(config={}, args=None, host=host) is True + assert not serial.Serial.mock_calls # pylint: disable=no-member + + # Nordic's tool speaks the Secure DFU protocol of this bootloader + cmd = mock_run.call_args[0][0] + assert cmd[:4] == ["/opt/nordic/nrfutil", "nrf5sdk-tools", "dfu", "usb-serial"] + assert str(dfu_package) in cmd + assert host in cmd + + def test_open_dfu_application_port_is_touched_and_the_bootloader_port_flashed( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An application port gets the 1200 bps touch; the bootloader then appears as its own port.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, dfu_package = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + bootloader_port = "/dev/ttyACM1" + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + app = MagicMock(device=host, vid=0x2FE3, pid=0x0100) + if calls[0] == 1: + return [app] # still the application when the upload starts + return [ + MagicMock( + device=bootloader_port, + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ) + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + serial.Serial.assert_called_once_with(host, baudrate=1200, timeout=1) # pylint: disable=no-member + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == bootloader_port + assert str(dfu_package) in cmd + + def test_open_dfu_leaves_another_dongle_in_its_bootloader_alone( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Only a bootloader port that appears after the touch is the touched dongle's.""" + import serial + + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + other = MagicMock( + device="/dev/ttyACM2", vid=OPEN_DFU_USB_ID[0], pid=OPEN_DFU_USB_ID[1] + ) + calls = [0] + + def _comports(**_kwargs): + calls[0] += 1 + if calls[0] == 1: + return [MagicMock(device=host, vid=0x2FE3, pid=0x0100), other] + if calls[0] == 2: + return [other] # the touched dongle is rebooting + return [ + other, + MagicMock( + device="/dev/ttyACM1", + vid=OPEN_DFU_USB_ID[0], + pid=OPEN_DFU_USB_ID[1], + ), + ] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + assert upload_program(config={}, args=None, host=host) is True + + cmd = mock_run.call_args[0][0] + assert cmd[cmd.index("-p") + 1] == "/dev/ttyACM1" + + def test_open_dfu_bootloader_that_never_appears_gets_the_reset_hint( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Without ``dfu:`` the touch does nothing; the hint names the RESET button.""" + from esphome.components.nrf52 import upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + clock = [0.0] + + def _monotonic() -> float: + clock[0] += 1.0 + return clock[0] + + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=(0x2FE3, 0x0100) + ) + stack.enter_context(patch("time.monotonic", side_effect=_monotonic)) + with pytest.raises(EsphomeError, match="press the RESET button"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + + def test_open_dfu_bootloader_needs_nordic_nrfutil( + self, setup_core: Path, tmp_path: Path + ) -> None: + """When Nordic's nrfutil cannot be fetched the upload fails before trying adafruit-nrfutil.""" + from esphome.components.nrf52 import OPEN_DFU_USB_ID, upload_program + + paths, _ = _prepare_serial_dfu(tmp_path, BOOTLOADER_NRF) + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches( + stack, host, tmp_path, paths, usb_id=OPEN_DFU_USB_ID + ) + stack.enter_context( + patch( + "esphome.components.nrf52.ensure_nordic_nrfutil", + side_effect=EsphomeError("Failed to download Nordic's nrfutil"), + ) + ) + with pytest.raises(EsphomeError, match="download Nordic's nrfutil"): + upload_program(config={}, args=None, host=host) + mock_run.assert_not_called() + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: """A failed nordicsemi DFU must raise EsphomeError.""" from esphome.components.nrf52 import upload_program - _setup_nrf52_core(build_path=tmp_path / "build") - CORE.config_path = tmp_path / "test.yaml" - paths = _make_paths(tmp_path) - build_dir = CORE.relative_pioenvs_path(CORE.name) - dfu_package = build_dir / "firmware.zip" - dfu_package.parent.mkdir(parents=True, exist_ok=True) - dfu_package.touch() + paths, _ = _prepare_serial_dfu(tmp_path) host = "/dev/ttyACM0" with ExitStack() as stack: From 6f6fdd0f8a2d0f6976bdf5f0e7d49db0028c8957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:55:47 -1000 Subject: [PATCH 24/50] Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#20815) Signed-off-by: dependabot[bot] --- .github/workflows/auto-label-pr.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/sync-dependency-versions.yml | 2 +- .github/workflows/sync-device-classes.yml | 2 +- .github/workflows/sync-integration-durations.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index b49c66b976d..82cf9f28288 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -28,7 +28,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 38a4b8ff0ed..728406e82ff 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -35,7 +35,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index bfe96f3944a..d3c4c5bf029 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0991a6308f..64b025a7427 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,7 +245,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index 73a3f7dbaa9..76e40a0eba5 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ba0404011be..33af93b481f 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-integration-durations.yml b/.github/workflows/sync-integration-durations.yml index d09a1cf2428..6bfb492fc9e 100644 --- a/.github/workflows/sync-integration-durations.yml +++ b/.github/workflows/sync-integration-durations.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} From 7e564c12dde48d5777a845f1ba5e41020e451d96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:58:07 -1000 Subject: [PATCH 25/50] Bump py7zr from 1.1.3 to 1.1.4 (#20816) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0c1a1fb6235..13625818ad8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ smpclient==7.3.0 smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 -py7zr==1.1.3 +py7zr==1.1.4 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 7ed06e0adaca56332d644973ad5855f2e0f827ae Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 20:19:56 +0000 Subject: [PATCH 26/50] [as734x] [1/3] Add AS7341/AS7343/TCS3448 spectral sensor hub with counts sensors (#17038) 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/as734x/__init__.py | 104 ++++++ esphome/components/as734x/as734x.cpp | 301 ++++++++++++++++++ esphome/components/as734x/as734x.h | 99 ++++++ esphome/components/as734x/sensor.py | 53 +++ esphome/core/defines.h | 2 + tests/component_tests/as734x/__init__.py | 0 tests/component_tests/as734x/test_as734x.py | 94 ++++++ tests/component_tests/as734x/test_as734x.yaml | 47 +++ tests/components/as734x/common.yaml | 44 +++ tests/components/as734x/test.esp32-idf.yaml | 3 + tests/components/as734x/test.esp8266-ard.yaml | 3 + tests/components/as734x/test.rp2040-ard.yaml | 3 + 13 files changed, 754 insertions(+) create mode 100644 esphome/components/as734x/__init__.py create mode 100644 esphome/components/as734x/as734x.cpp create mode 100644 esphome/components/as734x/as734x.h create mode 100644 esphome/components/as734x/sensor.py create mode 100644 tests/component_tests/as734x/__init__.py create mode 100644 tests/component_tests/as734x/test_as734x.py create mode 100644 tests/component_tests/as734x/test_as734x.yaml create mode 100644 tests/components/as734x/common.yaml create mode 100644 tests/components/as734x/test.esp32-idf.yaml create mode 100644 tests/components/as734x/test.esp8266-ard.yaml create mode 100644 tests/components/as734x/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3cbf36777c0..f2f24f1106f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,7 @@ esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr +esphome/components/as734x/* @latonita @mrgnr esphome/components/async_tcp/* @esphome/core esphome/components/at581x/* @X-Ryl669 esphome/components/atc_mithermometer/* @ahpohl diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py new file mode 100644 index 00000000000..6657d76bf4d --- /dev/null +++ b/esphome/components/as734x/__init__.py @@ -0,0 +1,104 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_GAIN, CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita", "@mrgnr"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensor"] +MULTI_CONF = True +DOMAIN = "as734x" + +CONF_AS734X_ID = "as734x_id" +CONF_ATIME = "atime" +CONF_ASTEP = "astep" + +# Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. +BANDS_43 = ( + "f1", + "f2", + "fz", + "f3", + "f4", + "fy", + "f5", + "fxl", + "f6", + "f7", + "f8", + "nir", + "clear", +) +BANDS_41 = tuple(b for b in BANDS_43 if b not in ("fz", "fy", "fxl")) + +as734x_ns = cg.esphome_ns.namespace("as734x") +AS734XComponent = as734x_ns.class_( + "AS734XComponent", cg.PollingComponent, i2c.I2CDevice +) + +Gain = as734x_ns.enum("Gain") +GAINS_41 = { + f"X{name}": getattr(Gain, f"GAIN_{name.replace('.', '_')}X") + for name in ("0.5", "1", "2", "4", "8", "16", "32", "64", "128", "256", "512") +} +GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} + + +# One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us +CYCLE_STEP_SECONDS = 2.78e-6 + + +def _validate_exposure(cycles: int): + """Reject an exposure that is empty or longer than the update interval.""" + + def validator(config: ConfigType) -> ConfigType: + atime, astep = config[CONF_ATIME], config[CONF_ASTEP] + if atime == 0 and astep == 0: + raise cv.Invalid(f"{CONF_ATIME} and {CONF_ASTEP} must not both be 0") + measurement = (atime + 1) * (astep + 1) * CYCLE_STEP_SECONDS * cycles + if measurement > config[CONF_UPDATE_INTERVAL].total_seconds: + raise cv.Invalid( + f"A measurement takes {measurement:.1f}s with these {CONF_ATIME} and " + f"{CONF_ASTEP} values, longer than the {CONF_UPDATE_INTERVAL}" + ) + return config + + return validator + + +def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: + return cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AS734XComponent), + cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), + cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(address)), + _validate_exposure(cycles), + ) + + +CONFIG_SCHEMA = cv.typed_schema( + { + "AS7341": _schema(GAINS_41, 0x39, cycles=2), # two SMUX steps per update + "AS7343": _schema(GAINS_43, 0x39, cycles=3), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3), + }, + upper=True, +) + + +async def to_code(config: ConfigType) -> None: + chip = config[CONF_TYPE] + cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") + var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP")) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_gain(config[CONF_GAIN])) + cg.add(var.set_atime(config[CONF_ATIME])) + cg.add(var.set_astep(config[CONF_ASTEP])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp new file mode 100644 index 00000000000..2a9de8f3df2 --- /dev/null +++ b/esphome/components/as734x/as734x.cpp @@ -0,0 +1,301 @@ +#include "as734x.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::as734x { + +ESPHOME_LOG_TAG(TAG, "as734x"); + +// Registers and bits shared by both chips +static constexpr uint8_t REG_ENABLE = 0x80; +static constexpr uint8_t REG_ATIME = 0x81; +static constexpr uint8_t REG_ASTATUS = 0x94; +static constexpr uint8_t ENABLE_PON = 0x01; +static constexpr uint8_t ENABLE_SP_EN = 0x02; +static constexpr uint8_t ENABLE_SMUX_EN = 0x10; +static constexpr uint8_t CFG0_REG_BANK = 0x10; +static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set +static constexpr uint8_t STATUS2_AVALID = 0x40; +static constexpr uint8_t ASTATUS_ASAT = 0x80; + +static constexpr uint8_t AS7341_CFG6 = 0xAF; +static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain +static constexpr uint8_t AS7341_SMUX_RAM = 0x00; +static constexpr uint8_t AS7343_CHAIN_CMD = 0xE4; +static constexpr uint8_t AS7343_CHAIN_SMUX = 0xE7; +static constexpr uint8_t AS7343_CHAINS = 3; + +// Read one field of a ChipInfo kept in flash +template static T pgm(const T &field) { + T value; + progmem_memcpy(&value, &field, sizeof(T)); + return value; +} + +static constexpr uint8_t NO_SLOT = 0xFF; +static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; +static constexpr uint8_t MAX_SMUX_LEN = 20; +static constexpr uint8_t MAX_ADC_SLOTS = 18; +static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; + +#ifdef USE_AS7341 +static const char AS7341_NAME[] PROGMEM = "AS7341"; +static constexpr uint8_t AS7341_ADC_SLOTS = 6; +static constexpr uint8_t AS7341_SMUX_LEN = 20; +static constexpr uint8_t AS7341_SMUX_STEPS = 2; +static const RegValue AS7341_INIT[] PROGMEM = {{0x70, 0x00}}; // CONFIG: SPM mode, INT_SEL and LED_SEL cleared +static const uint8_t AS7341_SMUX[] PROGMEM = { + 0x30, 0x01, 0x00, 0x00, 0x00, 0x42, 0x00, 0x00, 0x50, 0x00, // step 0: F1 to F4, NIR, Clear + 0x00, 0x00, 0x20, 0x04, 0x00, 0x30, 0x01, 0x50, 0x00, 0x06, // step 0, continued + 0x00, 0x00, 0x00, 0x40, 0x02, 0x00, 0x10, 0x03, 0x50, 0x10, // step 1: F5 to F8, NIR, Clear + 0x03, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x50, 0x00, 0x06, // step 1, continued +}; +static_assert(sizeof(AS7341_SMUX) == AS7341_SMUX_STEPS * AS7341_SMUX_LEN && AS7341_SMUX_LEN <= MAX_SMUX_LEN); +static constexpr uint8_t N = NO_SLOT; +static const uint8_t AS7341_READOUT[] PROGMEM = { + 0, 1, N, 2, 3, N, N, N, N, N, N, N, N, // step 0 + N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 +}; +static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +const ChipInfo AS7341_CHIP PROGMEM = { + .name = AS7341_NAME, + .init = AS7341_INIT, + .smux = AS7341_SMUX, + .readout = AS7341_READOUT, + .init_count = 1, + .astep_reg = 0xCA, + .cfg0_reg = 0xA9, + .cfg1_reg = 0xAA, + .status2_reg = 0xA3, + .id_reg = 0x92, + .id_mask = 0xFC, + .id_value = 0x09 << 2, + .adc_slots = AS7341_ADC_SLOTS, + .smux_steps = AS7341_SMUX_STEPS, + .integration_cycles = 1, + .smux_len = AS7341_SMUX_LEN, + .auto_smux = false, + .extra_clear_slot = NO_SLOT, +}; +#endif + +#ifdef USE_AS7343 +static const char AS7343_NAME[] PROGMEM = "AS7343"; +static constexpr uint8_t AS7343_ADC_SLOTS = 18; +static constexpr uint8_t AS7343_SMUX_LEN = 10; +static const char TCS3448_NAME[] PROGMEM = "TCS3448"; +// CFG6, FD_CFG0, CFG10, CFG8, CFG20 (auto_smux, 18 channels in 3 cycles), AGC_GAIN_MAX, FD_TIME_1, FD_TIME_2 +static const RegValue AS7343_INIT[] PROGMEM = {{0xF5, 0x00}, {0xDF, 0xA1}, {0x65, 0xF2}, {0xC9, 0xC8}, + {0xD6, 0x62}, {0xD7, 0x99}, {0xE0, 0x64}, {0xE2, 0x21}}; +static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05, 0x00, 0x01, 0x00, 0x30, + 0x00, 0x00, 0x60, 0x20, 0x04, 0x50, 0x03, 0x00, 0x01, 0x05, + 0x05, 0x00, 0x60, 0x00, 0x30, 0x00, 0x40, 0x10, 0x20, 0x00}; +// Clear is the average of the cycle 1 (slot 4) and cycle 2 (slot 10) readings; cycle 3 always reads 0. +static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; +static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); +static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +static constexpr ChipInfo as7343_chip(const char *name) { + return { + .name = name, + .init = AS7343_INIT, + .smux = AS7343_SMUX, + .readout = AS7343_READOUT, + .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), + .astep_reg = 0xD4, + .cfg0_reg = 0xBF, + .cfg1_reg = 0xC6, + .status2_reg = 0x90, + .id_reg = 0x5A, + .id_mask = 0xFF, + .id_value = 0x81, + .adc_slots = AS7343_ADC_SLOTS, + .smux_steps = 1, + .integration_cycles = 3, + .smux_len = AS7343_SMUX_LEN, + .auto_smux = true, + .extra_clear_slot = 4, + }; +} +const ChipInfo AS7343_CHIP PROGMEM = as7343_chip(AS7343_NAME); +const ChipInfo TCS3448_CHIP PROGMEM = as7343_chip(TCS3448_NAME); // AS7343 register map, address 0x59 +#endif + +// Registers below 0x80 are only reachable with the REG_BANK bit of CFG0 set; CFG0 itself always answers. +bool AS734XComponent::select_bank_(uint8_t reg) { + const bool low = reg < BANK_SPLIT; + if (low == this->bank_low_) { + return true; + } + this->bank_low_ = low; + return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); +} + +void AS734XComponent::setup() { + const ChipInfo *chip = this->chip_; + const uint8_t id_reg = pgm(chip->id_reg); + uint8_t id = 0; + // The sensor keeps its bank across an MCU reset, so the first access always writes CFG0 + bool ok = this->select_bank_(REG_ENABLE) && this->write_byte(REG_ENABLE, ENABLE_PON); + delayMicroseconds(200); // oscillator start-up + ok = ok && this->select_bank_(id_reg) && this->read_byte(id_reg, &id); + if (ok && (id & pgm(chip->id_mask)) != pgm(chip->id_value)) { + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + const RegValue *init = pgm(chip->init); + for (uint8_t i = 0, n = pgm(chip->init_count); ok && i < n; i++) { + RegValue rv; + progmem_memcpy(&rv, &init[i], sizeof(rv)); + ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); + } + // Everything from here on, including the measurement registers, is in the high bank. + ok = ok && this->select_bank_(REG_ATIME) && this->write_byte(REG_ATIME, this->atime_) && + this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) && + this->write_byte(pgm(chip->cfg1_reg), this->gain_); + if (pgm(chip->auto_smux)) { + const uint8_t *smux = pgm(chip->smux); + const uint8_t len = pgm(chip->smux_len); + for (uint8_t i = 0; ok && i < AS7343_CHAINS * len; i++) { + ok = this->write_byte(AS7343_CHAIN_SMUX, progmem_read_byte(&smux[i])) && + this->write_byte(AS7343_CHAIN_CMD, 0x46 + (i / len) * 0x10); + } + } + if (!ok) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + this->disable_loop(); +} + +void AS734XComponent::dump_config() { + const float gain = this->gain_ == GAIN_0_5X ? 0.5f : static_cast(1 << (this->gain_ - 1)); + ESP_LOGCONFIG(TAG, + "AS734x:\n" + " Chip: %s\n" + " Gain: %gx\n" + " ATIME: %u\n" + " ASTEP: %u", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + for (auto *sens : this->sensors_) { + LOG_SENSOR(" ", "Counts", sens); + } + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void AS734XComponent::update() { + if (this->state_ != State::STATE_IDLE) { + ESP_LOGW(TAG, "Skipping update, previous measurement still running"); + return; + } + this->step_ = 0; + this->enable_loop(); + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to start measurement")); + } +} + +bool AS734XComponent::start_step_() { + this->start_ms_ = App.get_loop_component_start_time(); + if (pgm(this->chip_->auto_smux)) { + this->state_ = State::STATE_READ_DATA; + return this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN); + } + // Load this step's image into SMUX RAM; SMUX_EN copies it to the chain and clears itself when done. + std::array image{}; + const uint8_t len = pgm(this->chip_->smux_len); + progmem_memcpy(image.data(), pgm(this->chip_->smux) + this->step_ * len, len); + this->state_ = State::STATE_WAIT_SMUX; + return this->write_byte(AS7341_CFG6, AS7341_SMUX_CMD_WRITE) && + this->write_bytes(AS7341_SMUX_RAM, image.data(), len) && + this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SMUX_EN); +} + +void AS734XComponent::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t value = 0; + switch (this->state_) { + case State::STATE_WAIT_SMUX: + // A failed read counts as busy, so the timeout below catches a chip that stops answering. + if (this->read_byte(REG_ENABLE, &value) && !(value & ENABLE_SMUX_EN)) { + this->start_ms_ = now; + this->state_ = State::STATE_READ_DATA; + if (!this->write_byte(REG_ENABLE, ENABLE_PON | ENABLE_SP_EN)) { + this->abort_measurement_(LOG_STR("Failed to start integration")); + } + return; + } + break; + case State::STATE_READ_DATA: + if (now - this->start_ms_ < this->integration_ms_) { + return; + } + if (this->read_byte(pgm(this->chip_->status2_reg), &value) && (value & STATUS2_AVALID)) { + this->read_step_(); + return; + } + break; + case State::STATE_IDLE: + this->disable_loop(); + return; + } + if (now - this->start_ms_ > this->timeout_ms_) { + this->abort_measurement_(LOG_STR("Measurement timeout")); + } +} + +void AS734XComponent::read_step_() { + // Reading ASTATUS latches the data, which is only consistent within one burst starting there. + std::array frame{}; + if (!this->read_bytes(REG_ASTATUS, frame.data(), 1 + 2 * pgm(this->chip_->adc_slots)) || + !this->write_byte(REG_ENABLE, ENABLE_PON)) { + this->abort_measurement_(LOG_STR("Failed to read channel data")); + return; + } + if (frame[0] & ASTATUS_ASAT) { + ESP_LOGV(TAG, "Step %u saturated", this->step_); + } + auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); }; + const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS; + const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint8_t slot = progmem_read_byte(&readout[band]); + if (slot == NO_SLOT || this->sensors_[band] == nullptr) { + continue; + } + uint32_t counts = slot_value(slot); + if (band == CLEAR_BAND && extra_clear != NO_SLOT) { + counts = (counts + slot_value(extra_clear)) / 2; + } + this->sensors_[band]->publish_state(counts); + } + if (++this->step_ < pgm(this->chip_->smux_steps)) { + if (!this->start_step_()) { + this->abort_measurement_(LOG_STR("Failed to configure SMUX")); + } + return; + } + this->status_clear_warning(); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +void AS734XComponent::abort_measurement_(const LogString *reason) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); + this->write_byte(REG_ENABLE, ENABLE_PON); + this->status_set_warning(reason); + this->state_ = State::STATE_IDLE; + this->disable_loop(); +} + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h new file mode 100644 index 00000000000..184084ca2b7 --- /dev/null +++ b/esphome/components/as734x/as734x.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::as734x { + +enum Gain : uint8_t { + GAIN_0_5X, + GAIN_1X, + GAIN_2X, + GAIN_4X, + GAIN_8X, + GAIN_16X, + GAIN_32X, + GAIN_64X, + GAIN_128X, + GAIN_256X, + GAIN_512X, + GAIN_1024X, // AS7343 only + GAIN_2048X, // AS7343 only +}; + +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +struct RegValue { + uint8_t reg; + uint8_t value; +}; + +/// Per-chip constants, stored in flash. The pointed-to tables are in flash too. +struct ChipInfo { + const char *name; + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + uint8_t init_count; + uint8_t astep_reg; + uint8_t cfg0_reg; + uint8_t cfg1_reg; + uint8_t status2_reg; + uint8_t id_reg; + uint8_t id_mask; + uint8_t id_value; + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update + uint8_t integration_cycles; + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) +}; + +#ifdef USE_AS7341 +extern const ChipInfo AS7341_CHIP; +#endif +#ifdef USE_AS7343 +extern const ChipInfo AS7343_CHIP; +extern const ChipInfo TCS3448_CHIP; +#endif + +class AS734XComponent : public PollingComponent, public i2c::I2CDevice { + public: + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) {} + + void setup() override; + void dump_config() override; + void update() override; + void loop() override; + + void set_gain(Gain gain) { this->gain_ = gain; } + void set_atime(uint8_t atime) { this->atime_ = atime; } + void set_astep(uint16_t astep) { this->astep_ = astep; } + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + + protected: + bool select_bank_(uint8_t reg); + bool start_step_(); + void read_step_(); + void abort_measurement_(const LogString *reason); + + std::array sensors_{}; + const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy + uint32_t start_ms_{0}; + uint32_t integration_ms_{0}; + uint32_t timeout_ms_{0}; + uint16_t astep_{0}; + enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Gain gain_{GAIN_1X}; + uint8_t atime_{0}; + uint8_t step_{0}; + bool bank_low_{true}; // unknown until setup() writes CFG0 +}; + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py new file mode 100644 index 00000000000..aa1487f8a97 --- /dev/null +++ b/esphome/components/as734x/sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import UNIT_COUNTS +import esphome.config_validation as cv +from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +ICON_COUNTS = "mdi:counter" + +_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} + ), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + full = fv.full_config.get() + hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) + if hub[CONF_TYPE] == "AS7341" and ( + extra := [band for band in config[CONF_COUNTS] if band not in BANDS_41] + ): + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[CONF_COUNTS, extra[0]] + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AS734X_ID]) + counts = config[CONF_COUNTS] + for i, band in enumerate(BANDS_43): + if (conf := counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_counts_sensor(sens, i)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 79ea459bad8..c8a128e76cb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -43,6 +43,8 @@ // Feature flags #define USE_ALARM_CONTROL_PANEL #define USE_AREAS +#define USE_AS7341 +#define USE_AS7343 #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER diff --git a/tests/component_tests/as734x/__init__.py b/tests/component_tests/as734x/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py new file mode 100644 index 00000000000..98ae9571693 --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.py @@ -0,0 +1,94 @@ +"""Tests for the as734x band to counts index mapping and band validation. + +The C++ readout maps publish each band from a fixed index, so the order of BANDS_43 is a +contract with as734x.cpp. Reordering either side only moves readings to the wrong entity, +which no build test would notice, so the indices are pinned here. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.as734x import ( + CONF_AS734X_ID, + CONFIG_SCHEMA as HUB_SCHEMA, + sensor as as734x_sensor, +) +from esphome.config import Config +from esphome.const import CONF_COUNTS, CONF_ID, CONF_TYPE, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + +CONFIG = "tests/component_tests/as734x/test_as734x.yaml" + +BAND_INDEX = { + "f1": 0, + "f2": 1, + "fz": 2, + "f3": 3, + "f4": 4, + "fy": 5, + "f5": 6, + "fxl": 7, + "f6": 8, + "f7": 9, + "f8": 10, + "nir": 11, + "clear": 12, +} +AS7341_BANDS = ("f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "nir", "clear") + + +def test_as7341_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in AS7341_BANDS: + assert f"hub41->set_counts_sensor(s41_{band}, {BAND_INDEX[band]});" in main_cpp + + +def test_as7343_bands_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band, index in BAND_INDEX.items(): + assert f"hub43->set_counts_sensor(s43_{band}, {index});" in main_cpp + + +@pytest.mark.parametrize("band", ["fz", "fy", "fxl"]) +def test_as7341_rejects_as7343_only_band( + band: str, set_core_config: SetCoreConfigCallable +) -> None: + full = Config() + full["as734x"] = [{CONF_ID: ID("hub41"), CONF_TYPE: "AS7341"}] + full.declare_ids.append((ID("hub41"), ["as734x", 0, CONF_ID])) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + with pytest.raises(cv.Invalid, match=f"no {band} band"): + as734x_sensor.FINAL_VALIDATE_SCHEMA( + {CONF_AS734X_ID: ID("hub41"), CONF_COUNTS: {"f1": {}, band: {}}} + ) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ({"type": "AS7343", "atime": 0, "astep": 0}, "must not both be 0"), + # about 140 s with three cycles, against the default 60 s interval + ( + {"type": "AS7343", "atime": 255, "astep": 65534}, + "longer than the update_interval", + ), + # about 93 s with two cycles, rejected only if a third cycle were counted + ( + {"type": "AS7341", "atime": 255, "astep": 65534, "update_interval": "100s"}, + None, + ), + ], +) +def test_exposure_bound( + config: dict, error: str | None, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + if error is None: + HUB_SCHEMA(config) + return + with pytest.raises(cv.Invalid, match=error): + HUB_SCHEMA(config) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml new file mode 100644 index 00000000000..0ae5ce2ce10 --- /dev/null +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -0,0 +1,47 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: 21 + scl: 22 + +as734x: + - id: hub41 + type: AS7341 + - id: hub43 + type: AS7343 + address: 0x3A + +sensor: + - platform: as734x + as734x_id: hub41 + counts: + f1: {id: s41_f1, name: s41 f1} + f2: {id: s41_f2, name: s41 f2} + f3: {id: s41_f3, name: s41 f3} + f4: {id: s41_f4, name: s41 f4} + f5: {id: s41_f5, name: s41 f5} + f6: {id: s41_f6, name: s41 f6} + f7: {id: s41_f7, name: s41 f7} + f8: {id: s41_f8, name: s41 f8} + nir: {id: s41_nir, name: s41 nir} + clear: {id: s41_clear, name: s41 clear} + - platform: as734x + as734x_id: hub43 + counts: + f1: {id: s43_f1, name: s43 f1} + f2: {id: s43_f2, name: s43 f2} + fz: {id: s43_fz, name: s43 fz} + f3: {id: s43_f3, name: s43 f3} + f4: {id: s43_f4, name: s43 f4} + fy: {id: s43_fy, name: s43 fy} + f5: {id: s43_f5, name: s43 f5} + fxl: {id: s43_fxl, name: s43 fxl} + f6: {id: s43_f6, name: s43 f6} + f7: {id: s43_f7, name: s43 f7} + f8: {id: s43_f8, name: s43 f8} + nir: {id: s43_nir, name: s43 nir} + clear: {id: s43_clear, name: s43 clear} diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml new file mode 100644 index 00000000000..5fa94d7ef5b --- /dev/null +++ b/tests/components/as734x/common.yaml @@ -0,0 +1,44 @@ +as734x: + - id: as7341_hub + type: AS7341 + i2c_id: i2c_bus + update_interval: 15s + gain: X8 + atime: 120 + astep: 99 + - id: as7343_hub + type: AS7343 + i2c_id: i2c_bus + address: 0x3A + gain: X512 + - id: tcs3448_hub + type: TCS3448 + i2c_id: i2c_bus + gain: X2048 + +sensor: + - platform: as734x + as734x_id: as7341_hub + counts: + f1: + name: AS7341 F1 + nir: + name: AS7341 NIR + clear: + name: AS7341 Clear + - platform: as734x + as734x_id: as7343_hub + counts: + fz: + name: AS7343 FZ + fxl: + name: AS7343 FXL + clear: + name: AS7343 Clear + - platform: as734x + as734x_id: tcs3448_hub + counts: + f1: + name: TCS3448 F1 + fy: + name: TCS3448 FY diff --git a/tests/components/as734x/test.esp32-idf.yaml b/tests/components/as734x/test.esp32-idf.yaml new file mode 100644 index 00000000000..c2f5846bc52 --- /dev/null +++ b/tests/components/as734x/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.esp8266-ard.yaml b/tests/components/as734x/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b94a2dc317d --- /dev/null +++ b/tests/components/as734x/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + as734x: !include common.yaml diff --git a/tests/components/as734x/test.rp2040-ard.yaml b/tests/components/as734x/test.rp2040-ard.yaml new file mode 100644 index 00000000000..36538043f2a --- /dev/null +++ b/tests/components/as734x/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + as734x: !include common.yaml From 304627efd36bfce05f0742b00484cadfc0d27368 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 22:34:00 +0200 Subject: [PATCH 27/50] [ld2410][ld2412] Move CONF_OUT_PIN_LEVEL to the shared constants (#20820) --- esphome/components/const/__init__.py | 1 + esphome/components/ld2410/select/__init__.py | 2 +- esphome/components/ld2412/select/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index dcf40d2a07c..3a531978aaa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -52,6 +52,7 @@ CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" +CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 936ad83d39d..e1129ac9524 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = ld2410_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 40a075e5d54..5249e9b97a6 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -20,7 +21,6 @@ LightOutControlSelect = LD2412_ns.class_("LightOutControlSelect", select.Select) CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" -CONF_OUT_PIN_LEVEL = "out_pin_level" CONFIG_SCHEMA = { From 27d8d49b17ac8f1f7ab8cd88279f2536b25e8ebe Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 22:41:20 +0200 Subject: [PATCH 28/50] [dfrobot_sen0609] Add number, switch and select settings (#20813) Co-authored-by: Claude Opus 5.5 --- .../components/dfrobot_sen0609/__init__.py | 10 +- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 446 ++++++++++++++++- .../dfrobot_sen0609/dfrobot_sen0609.h | 100 ++++ .../dfrobot_sen0609/number/__init__.py | 194 ++++++++ .../number/dfrobot_sen0609_number.h | 18 + .../dfrobot_sen0609/select/__init__.py | 29 ++ .../select/dfrobot_sen0609_select.h | 17 + .../dfrobot_sen0609/switch/__init__.py | 68 +++ .../switch/dfrobot_sen0609_switch.h | 18 + .../components/dfrobot_sen0609/benchmark.yaml | 3 + .../dfrobot_sen0609/common-speed.yaml | 17 + tests/components/dfrobot_sen0609/common.h | 24 + tests/components/dfrobot_sen0609/common.yaml | 34 ++ .../dfrobot_sen0609_sequencer.cpp | 453 +++++++++++++++++- .../fixtures/uart_mock_dfrobot_sen0609.yaml | 20 +- .../test_uart_mock_dfrobot_sen0609.py | 31 +- 16 files changed, 1434 insertions(+), 48 deletions(-) create mode 100644 esphome/components/dfrobot_sen0609/number/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h create mode 100644 esphome/components/dfrobot_sen0609/select/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h create mode 100644 esphome/components/dfrobot_sen0609/switch/__init__.py create mode 100644 esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 4825f4b770a..c1d2f806377 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -3,7 +3,7 @@ from collections.abc import Callable import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODE +from esphome.const import CONF_ID, CONF_MODE, CONF_MODEL from esphome.core import ID import esphome.final_validate as fv from esphome.types import ConfigType @@ -28,10 +28,18 @@ RUN_MODES = { MODE_SPEED_AND_DISTANCE: RunMode.RUN_MODE_SPEED_AND_DISTANCE, } +MODEL_SEN0609 = "SEN0609" +MODEL_SEN0610 = "SEN0610" +# Longest detection range in metres +MAX_RANGES = {MODEL_SEN0609: 25.0, MODEL_SEN0610: 12.0} + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(DFRobotSEN0609Component), + cv.Optional(CONF_MODEL, default=MODEL_SEN0609): cv.one_of( + *MAX_RANGES, upper=True + ), cv.Optional(CONF_MODE, default=MODE_PRESENCE): cv.one_of( *RUN_MODES, lower=True ), diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 0e6ff31ffe7..26538998a2f 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -17,6 +17,8 @@ ESPHOME_LOG_TAG(TAG, "dfrobot_sen0609"); static constexpr uint8_t MAX_ATTEMPTS = 3; // The module ignores a command that follows its last answer too closely static constexpr uint32_t COMMAND_GAP_MS = 100; +// Gathers entity changes made close together into one flash write +static constexpr uint32_t COALESCE_MS = 1000; static constexpr uint32_t DEFAULT_TIMEOUT_MS = 1000; static constexpr uint32_t LONG_TIMEOUT_MS = 2000; static constexpr uint32_t GIVE_UP_BACKOFF_MS = 30000; @@ -27,11 +29,34 @@ static constexpr uint8_t FRAME_FIELDS = 5; static constexpr uint8_t LINE_END[] = {'\r', '\n'}; // Indexed by Param; empty for parameters that cannot be read -PROGMEM_STRING_TABLE(ReadCommands, "", "", "getSWV", "getHWV"); +PROGMEM_STRING_TABLE(ReadCommands, "", "", "getRange", "getTrigRange", "getSensitivity", "getLatency", "getInhibit", + "getThrFactor", "getMicroMotion", "getGpioMode 1", "", "", "getSWV", "getHWV"); static_assert(PARAM_COUNT <= 16, "pending_writes_ and pending_reads_ hold one bit per parameter"); static constexpr uint16_t param_bit(Param param) { return static_cast(1U << param); } +// Parameters kept in the module's flash only after saveConfig +static constexpr uint16_t PERSISTENT_PARAMS = + param_bit(PARAM_RANGE) | param_bit(PARAM_TRIG_RANGE) | param_bit(PARAM_SENSITIVITY) | param_bit(PARAM_LATENCY) | + param_bit(PARAM_INHIBIT) | param_bit(PARAM_THR_FACTOR) | param_bit(PARAM_MICRO_MOTION) | param_bit(PARAM_GPIO_MODE); +// The frames depend on these, so their writes are kept until they succeed +static constexpr uint16_t FRAME_PARAMS = param_bit(PARAM_RUN_APP) | param_bit(PARAM_UART_OUTPUT); + +#ifdef USE_SWITCH +static constexpr bool is_led(Param param) { return param == PARAM_RUN_LED || param == PARAM_OUT_LED; } +static constexpr SwitchSetting led_switch(Param param) { + return param == PARAM_RUN_LED ? SWITCH_RUN_LED : SWITCH_OUT_LED; +} +#endif + +#ifdef USE_NUMBER +static_assert(NUMBER_COUNT <= 16, "changed_numbers_ holds one bit per number"); +static constexpr uint8_t NUMBER_PARAMS[NUMBER_COUNT] PROGMEM = { + PARAM_RANGE, PARAM_RANGE, PARAM_TRIG_RANGE, PARAM_SENSITIVITY, PARAM_SENSITIVITY, + PARAM_LATENCY, PARAM_LATENCY, PARAM_INHIBIT, PARAM_THR_FACTOR, +}; +#endif + static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; } @@ -69,6 +94,8 @@ static bool answer_means_done(Operation operation, const char *line) { return contains(line, ESPHOME_PSTR("stopped already")); case Operation::OPERATION_START: return contains(line, ESPHOME_PSTR("started already")); + case Operation::OPERATION_SAVE: + return starts_with(line, ESPHOME_PSTR("no parameter")); default: return false; } @@ -88,6 +115,8 @@ static const LogString *operation_to_str(Operation operation) { return LOG_STR("start"); case Operation::OPERATION_WRITE: return LOG_STR("write"); + case Operation::OPERATION_SAVE: + return LOG_STR("save"); case Operation::OPERATION_READ: return LOG_STR("read"); default: @@ -95,7 +124,11 @@ static const LogString *operation_to_str(Operation operation) { } } -DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) {} +DFRobotSEN0609Component::DFRobotSEN0609Component(RunMode mode) : mode_(mode) { +#ifdef USE_NUMBER + this->number_values_.fill(NAN); +#endif +} void DFRobotSEN0609Component::dump_config() { ESP_LOGCONFIG( @@ -115,9 +148,34 @@ void DFRobotSEN0609Component::dump_config() { LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); LOG_TEXT_SENSOR(" ", "HardwareVersion", this->hardware_version_text_sensor_); #endif +#ifdef USE_NUMBER + for (number::Number *n : this->numbers_) { + LOG_NUMBER(" ", "Number", n); + } +#endif +#ifdef USE_SWITCH + for (switch_::Switch *s : this->switches_) { + LOG_SWITCH(" ", "Switch", s); + } +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "OutPinLevel", this->out_pin_level_select_); +#endif } -void DFRobotSEN0609Component::setup() { this->queue_configuration_(); } +void DFRobotSEN0609Component::setup() { +#ifdef USE_SWITCH + // The module forgets the LED states at power off + for (uint8_t i = SWITCH_RUN_LED; i <= SWITCH_OUT_LED; i++) { + switch_::Switch *sw = this->switches_[i]; + if (sw == nullptr) + continue; + const bool on = sw->get_initial_state_with_restore_mode().value_or(true); + this->publish_switch_(static_cast(i), on != sw->is_inverted()); + } +#endif + this->queue_configuration_(); +} void DFRobotSEN0609Component::loop() { uint8_t buf[MAX_LINE_LENGTH]; @@ -204,6 +262,8 @@ void DFRobotSEN0609Component::handle_line_(char *line) { if (starts_with(line, ESPHOME_PSTR("Done")) || answer_means_done(this->operation_, line)) { this->finish_operation_(); + } else if (starts_with(line, ESPHOME_PSTR("Response "))) { + this->parse_response_(line + 9); } else if (contains(line, ESPHOME_PSTR("not stopped"))) { // Stop the sensor before the next attempt this->stopped_ = false; @@ -295,11 +355,30 @@ void DFRobotSEN0609Component::publish_target_(float distance, float speed, float } #endif +void DFRobotSEN0609Component::parse_response_(const char *args) { + this->response_count_ = 0; + while (this->response_count_ < MAX_RESPONSE_VALUES) { + char *end; + const float value = strtof(args, &end); + // strtof also takes "nan" and "inf", which no setting can hold + if (end == args || !std::isfinite(value)) + break; + this->response_[this->response_count_++] = value; + args = end; + } +} + void DFRobotSEN0609Component::queue_configuration_() { - // The mode and the UART output are applied at every boot instead of being saved + // The mode, the UART output and the LEDs are applied at every boot instead of being saved uint16_t writes = param_bit(PARAM_RUN_APP); if (this->mode_ == RunMode::RUN_MODE_PRESENCE) writes |= param_bit(PARAM_UART_OUTPUT); +#ifdef USE_SWITCH + if (this->switches_[SWITCH_RUN_LED] != nullptr) + writes |= param_bit(PARAM_RUN_LED); + if (this->switches_[SWITCH_OUT_LED] != nullptr) + writes |= param_bit(PARAM_OUT_LED); +#endif this->pending_writes_ |= writes; this->pending_reads_ |= this->wanted_reads_(); } @@ -311,6 +390,20 @@ uint16_t DFRobotSEN0609Component::wanted_reads_() const { reads |= param_bit(PARAM_SW_VERSION); if (this->hardware_version_text_sensor_ != nullptr) reads |= param_bit(PARAM_HW_VERSION); +#endif +#ifdef USE_NUMBER + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (this->numbers_[i] != nullptr) + reads |= param_bit(static_cast(progmem_read_byte(&NUMBER_PARAMS[i]))); + } +#endif +#ifdef USE_SWITCH + if (this->switches_[SWITCH_MICRO_MOTION] != nullptr) + reads |= param_bit(PARAM_MICRO_MOTION); +#endif +#ifdef USE_SELECT + if (this->out_pin_level_select_ != nullptr) + reads |= param_bit(PARAM_GPIO_MODE); #endif return reads; } @@ -330,6 +423,86 @@ void DFRobotSEN0609Component::check_frames_(uint32_t now) { this->queue_configuration_(); } +void DFRobotSEN0609Component::request_write_(Param param) { + const uint32_t now = App.get_loop_component_start_time(); + this->pending_writes_ |= param_bit(param); + this->last_change_ms_ = now; + if (this->operation_ != Operation::OPERATION_NONE || this->stopped_) + return; + // Start a new sequence a little later, so changes made together end in one flash write. + // A longer hold, such as the back-off from a module that does not answer, is not cut short. + const uint32_t elapsed = now - this->hold_start_ms_; + if (elapsed >= this->hold_ms_ || this->hold_ms_ - elapsed < COALESCE_MS) + this->hold_(COALESCE_MS); +} + +#ifdef USE_NUMBER +void DFRobotSEN0609Component::control_number(NumberSetting setting, float value) { + // The module needs min_range <= trigger_range <= max_range; a value not read yet is NAN and passes + const auto &values = this->number_values_; + const float min_range = setting == NUMBER_MIN_RANGE ? value : values[NUMBER_MIN_RANGE]; + const float max_range = setting == NUMBER_MAX_RANGE ? value : values[NUMBER_MAX_RANGE]; + const float trigger_range = setting == NUMBER_TRIGGER_RANGE ? value : values[NUMBER_TRIGGER_RANGE]; + if (setting <= NUMBER_TRIGGER_RANGE && + (min_range > max_range || trigger_range < min_range || trigger_range > max_range)) { + ESP_LOGW(TAG, "Ranges must keep minimum <= trigger <= maximum, not written"); + if (!std::isnan(this->number_values_[setting])) + this->publish_number_(setting, this->number_values_[setting]); + return; + } + this->changed_numbers_ |= 1U << setting; + this->publish_number_(setting, value); + this->request_write_(static_cast(progmem_read_byte(&NUMBER_PARAMS[setting]))); +} + +void DFRobotSEN0609Component::forget_number_changes_(Param param, bool unknown) { + for (uint8_t i = 0; i < NUMBER_COUNT; i++) { + if (progmem_read_byte(&NUMBER_PARAMS[i]) != param) + continue; + this->changed_numbers_ &= ~(1U << i); + if (unknown) + this->publish_number_(static_cast(i), NAN); + } +} + +void DFRobotSEN0609Component::publish_number_(NumberSetting setting, float value) { + this->number_values_[setting] = value; + if (this->numbers_[setting] != nullptr) + this->numbers_[setting]->publish_state(value); +} +#endif + +#ifdef USE_SWITCH +void DFRobotSEN0609Component::control_switch(SwitchSetting setting, bool state) { + this->publish_switch_(setting, state); + switch (setting) { + case SWITCH_MICRO_MOTION: + this->request_write_(PARAM_MICRO_MOTION); + break; + case SWITCH_RUN_LED: + this->request_write_(PARAM_RUN_LED); + break; + default: + this->request_write_(PARAM_OUT_LED); + break; + } +} + +void DFRobotSEN0609Component::publish_switch_(SwitchSetting setting, bool state) { + this->switch_states_[setting] = state; + if (this->switches_[setting] != nullptr) + this->switches_[setting]->publish_state(state); +} +#endif + +#ifdef USE_SELECT +void DFRobotSEN0609Component::control_out_pin_level(size_t index) { + this->out_pin_level_ = static_cast(index); + this->out_pin_level_select_->publish_state(index); + this->request_write_(PARAM_GPIO_MODE); +} +#endif + void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { this->hold_start_ms_ = App.get_loop_component_start_time(); this->hold_ms_ = duration_ms; @@ -338,7 +511,7 @@ void DFRobotSEN0609Component::hold_(uint32_t duration_ms) { void DFRobotSEN0609Component::run_sequencer_() { const uint32_t now = App.get_loop_component_start_time(); if (this->operation_ != Operation::OPERATION_NONE) { - const bool slow = this->operation_ == Operation::OPERATION_STOP; + const bool slow = this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_SAVE; if (now - this->sent_ms_ < (slow ? LONG_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) return; ESP_LOGD(TAG, "No answer to '%s'", this->command_); @@ -348,12 +521,19 @@ void DFRobotSEN0609Component::run_sequencer_() { if (now - this->hold_start_ms_ < this->hold_ms_) return; - const bool work_pending = this->pending_writes_ != 0 || this->pending_reads_ != 0; + const bool work_pending = this->save_pending_ || this->pending_writes_ != 0 || this->pending_reads_ != 0; Operation operation; Param param = PARAM_COUNT; if (!work_pending) { if (!this->stopped_) { this->check_frames_(now); + // Work given up on is tried again in a later sequence, so the sensor runs in between + if ((this->deferred_writes_ != 0 || this->deferred_save_) && now - this->deferred_ms_ >= GIVE_UP_BACKOFF_MS) { + this->pending_writes_ |= this->deferred_writes_; + this->save_pending_ = this->save_pending_ || this->deferred_save_; + this->deferred_writes_ = 0; + this->deferred_save_ = false; + } return; } operation = Operation::OPERATION_START; @@ -362,25 +542,57 @@ void DFRobotSEN0609Component::run_sequencer_() { } else if (this->pending_writes_ != 0) { operation = Operation::OPERATION_WRITE; param = static_cast(__builtin_ctz(this->pending_writes_)); + } else if (this->save_pending_) { + // Changes made within a second of each other share one flash write, also while a sequence runs + if (now - this->last_change_ms_ < COALESCE_MS) + return; + operation = Operation::OPERATION_SAVE; } else { operation = Operation::OPERATION_READ; param = static_cast(__builtin_ctz(this->pending_reads_)); } const uint16_t bit = param_bit(param); - uint16_t &pending = operation == Operation::OPERATION_WRITE ? this->pending_writes_ : this->pending_reads_; - if (!this->build_command_(operation, param)) { - ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), - static_cast(param)); - pending &= ~bit; + bool built = this->build_command_(operation, param); + if (!built && operation == Operation::OPERATION_WRITE) { + // The value this write is paired with is unknown: read it first, once, and keep the write pending + if ((this->paired_reads_ & bit) == 0) + this->pending_reads_ |= this->wanted_reads_() & bit; + if ((this->pending_reads_ & bit) != 0) { + this->paired_reads_ |= bit; + operation = Operation::OPERATION_READ; + built = this->build_command_(operation, param); + } + } + if (!built) { + if (operation == Operation::OPERATION_WRITE) { + ESP_LOGW(TAG, "Setting not written: the value it is paired with could not be read"); + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + // Both values come from the read that failed, so what the module holds is unknown + this->forget_number_changes_(param, true); +#endif + } else { + ESP_LOGE(TAG, "No command for %s of parameter %u", LOG_STR_ARG(operation_to_str(operation)), + static_cast(param)); + this->pending_reads_ &= ~bit; + } this->sequence_failed_ = true; this->status_set_warning(); return; } - if (operation == Operation::OPERATION_WRITE) - pending &= ~bit; + // Cleared now, so a change made while the write is on its way is written again + if (operation == Operation::OPERATION_WRITE) { + this->pending_writes_ &= ~bit; + this->paired_reads_ &= ~bit; +#ifdef USE_NUMBER + this->forget_number_changes_(param, false); +#endif + } this->operation_ = operation; this->param_ = param; + this->response_count_ = 0; this->echo_seen_ = false; this->version_received_ = false; this->sent_ms_ = now; @@ -399,6 +611,9 @@ bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { case Operation::OPERATION_START: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("sensorStart"), len); return true; + case Operation::OPERATION_SAVE: + ESPHOME_strncpy_P(buf, ESPHOME_PSTR("saveConfig"), len); + return true; case Operation::OPERATION_READ: ESPHOME_strncpy_P(buf, reinterpret_cast(ReadCommands::get_progmem_str(param, 0)), len); return buf[0] != '\0'; @@ -413,10 +628,149 @@ bool DFRobotSEN0609Component::build_command_(Operation operation, Param param) { case PARAM_UART_OUTPUT: ESPHOME_strncpy_P(buf, ESPHOME_PSTR("setUartOutput 1 1 1 1.0"), len); return true; +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setMicroMotion %u"), + static_cast(this->switch_states_[SWITCH_MICRO_MOTION])); + return true; + case PARAM_RUN_LED: + case PARAM_OUT_LED: { + // 0 turns the LED on + const bool on = this->switch_states_[led_switch(param)]; + this->sent_led_on_ = on; + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setLedMode %u %u"), param == PARAM_RUN_LED ? 1U : 2U, on ? 0U : 1U); + return true; + } +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + ESPHOME_snprintf_P(buf, len, ESPHOME_PSTR("setGpioMode 1 %u"), static_cast(this->out_pin_level_)); + return true; +#endif + default: + break; + } + +#ifdef USE_NUMBER + const auto &values = this->number_values_; + float first = NAN; + float second = 0.0f; + ESPHOME_PGM_P format = nullptr; + switch (param) { + case PARAM_RANGE: + first = values[NUMBER_MIN_RANGE]; + second = values[NUMBER_MAX_RANGE]; + format = ESPHOME_PSTR("setRange %.2f %.2f"); + break; + case PARAM_TRIG_RANGE: + first = values[NUMBER_TRIGGER_RANGE]; + format = ESPHOME_PSTR("setTrigRange %.2f"); + break; + case PARAM_SENSITIVITY: + first = values[NUMBER_HOLD_SENSITIVITY]; + second = values[NUMBER_TRIGGER_SENSITIVITY]; + format = ESPHOME_PSTR("setSensitivity %.0f %.0f"); + break; + case PARAM_LATENCY: + first = values[NUMBER_ON_LATENCY]; + second = values[NUMBER_OFF_LATENCY]; + format = ESPHOME_PSTR("setLatency %.2f %.1f"); + break; + case PARAM_INHIBIT: + first = values[NUMBER_INHIBIT_TIME]; + format = ESPHOME_PSTR("setInhibit %.2f"); + break; + case PARAM_THR_FACTOR: + first = values[NUMBER_THRESHOLD_FACTOR]; + format = ESPHOME_PSTR("setThrFactor %.0f"); + break; default: - buf[0] = '\0'; return false; } + // Commands that take two values need both read from the module first + if (std::isnan(first) || std::isnan(second)) + return false; + ESPHOME_snprintf_P(buf, len, format, first, second); + return true; +#else + return false; +#endif +} + +bool DFRobotSEN0609Component::apply_read_(Param param) { + const float *r = this->response_; + const uint8_t count = this->response_count_; + // A change still waiting to be written is newer than what the module holds + const bool write_pending = (this->pending_writes_ & param_bit(param)) != 0; +#ifdef USE_NUMBER + // Checked for each number: publishing runs automations, which may change the next one + auto read_number = [this](NumberSetting setting, float value) { + if ((this->changed_numbers_ & (1U << setting)) == 0) + this->publish_number_(setting, value); + }; +#endif + switch (param) { +#ifdef USE_NUMBER + case PARAM_RANGE: + if (count < 2) + return false; + read_number(NUMBER_MIN_RANGE, r[0]); + read_number(NUMBER_MAX_RANGE, r[1]); + return true; + case PARAM_TRIG_RANGE: + if (count < 1) + return false; + read_number(NUMBER_TRIGGER_RANGE, r[0]); + return true; + case PARAM_SENSITIVITY: + if (count < 2) + return false; + read_number(NUMBER_HOLD_SENSITIVITY, r[0]); + read_number(NUMBER_TRIGGER_SENSITIVITY, r[1]); + return true; + case PARAM_LATENCY: + if (count < 2) + return false; + read_number(NUMBER_ON_LATENCY, r[0]); + read_number(NUMBER_OFF_LATENCY, r[1]); + return true; + case PARAM_INHIBIT: + if (count < 1) + return false; + read_number(NUMBER_INHIBIT_TIME, r[0]); + return true; + case PARAM_THR_FACTOR: + if (count < 1) + return false; + read_number(NUMBER_THRESHOLD_FACTOR, r[0]); + return true; +#endif +#ifdef USE_SWITCH + case PARAM_MICRO_MOTION: + if (count < 1) + return false; + if (!write_pending) + this->publish_switch_(SWITCH_MICRO_MOTION, r[0] != 0.0f); + return true; +#endif +#ifdef USE_SELECT + case PARAM_GPIO_MODE: + // Response + if (count < 2 || (r[1] != 0.0f && r[1] != 1.0f)) + return false; + if (!write_pending) { + this->out_pin_level_ = static_cast(r[1]); + this->out_pin_level_select_->publish_state(this->out_pin_level_); + } + return true; +#endif + case PARAM_SW_VERSION: + case PARAM_HW_VERSION: + // The versions are published as their lines arrive + return this->version_received_; + default: + return true; + } } void DFRobotSEN0609Component::finish_operation_() { @@ -432,15 +786,29 @@ void DFRobotSEN0609Component::finish_operation_() { this->sequence_failed_ = false; break; case Operation::OPERATION_WRITE: + this->deferred_writes_ &= ~param_bit(this->param_); + if (PERSISTENT_PARAMS & param_bit(this->param_)) + this->save_pending_ = true; +#ifdef USE_SWITCH + if (is_led(this->param_)) { + const uint8_t bit = 1U << led_switch(this->param_); + this->led_known_ |= bit; + this->led_on_ = this->sent_led_on_ ? (this->led_on_ | bit) : (this->led_on_ & ~bit); + } +#endif + break; + case Operation::OPERATION_SAVE: + this->save_pending_ = false; + this->deferred_save_ = false; break; case Operation::OPERATION_READ: - // The versions are published as their lines arrive - if (!this->version_received_) { - ESP_LOGW(TAG, "No version in the answer to '%s'", this->command_); + if (!this->apply_read_(this->param_)) { + ESP_LOGW(TAG, "Malformed answer to '%s'", this->command_); this->fail_operation_(); return; } this->pending_reads_ &= ~param_bit(this->param_); + this->paired_reads_ &= ~param_bit(this->param_); break; default: return; @@ -460,15 +828,16 @@ void DFRobotSEN0609Component::fail_operation_() { const Operation operation = this->operation_; this->operation_ = Operation::OPERATION_NONE; this->hold_(COMMAND_GAP_MS); - // The writes are the boot configuration, which the frames depend on: keep them until they succeed - if (operation == Operation::OPERATION_WRITE) + const bool keep_write = operation == Operation::OPERATION_WRITE && (FRAME_PARAMS & param_bit(this->param_)) != 0; + const bool retry = ++attempts < MAX_ATTEMPTS; + if (operation == Operation::OPERATION_WRITE && (retry || keep_write)) this->pending_writes_ |= param_bit(this->param_); - if (++attempts < MAX_ATTEMPTS) + if (retry) return; attempts = 0; this->status_set_warning(); - if (aux || operation == Operation::OPERATION_WRITE) { + if (aux || keep_write) { // Without stop, start or the configuration the sensor cannot run as set up; try again later ESP_LOGW(TAG, "Command '%s' failed, trying again later", this->command_); this->hold_ms_ = GIVE_UP_BACKOFF_MS; @@ -476,8 +845,39 @@ void DFRobotSEN0609Component::fail_operation_() { } ESP_LOGW(TAG, "Giving up on %s '%s'", LOG_STR_ARG(operation_to_str(operation)), this->command_); this->sequence_failed_ = true; - if (operation == Operation::OPERATION_READ) - this->pending_reads_ &= ~param_bit(this->param_); + switch (operation) { + case Operation::OPERATION_WRITE: +#ifdef USE_SWITCH + if (is_led(this->param_)) { + // A newer change is written anyway + if ((this->pending_writes_ & param_bit(this->param_)) != 0) + break; + // The LEDs cannot be read: show the state the module last accepted, or write this one again later + const uint8_t bit = 1U << led_switch(this->param_); + if ((this->led_known_ & bit) != 0) { + this->publish_switch_(led_switch(this->param_), (this->led_on_ & bit) != 0); + } else { + this->deferred_writes_ |= param_bit(this->param_); + this->deferred_ms_ = App.get_loop_component_start_time(); + } + break; + } +#endif + // The entity shows a value the module never got; show the module's value again + this->pending_reads_ |= this->wanted_reads_() & param_bit(this->param_); + break; + case Operation::OPERATION_SAVE: + // The entities show the changes as kept, but unsaved they are lost at power off: save in a later sequence + this->save_pending_ = false; + this->deferred_save_ = true; + this->deferred_ms_ = App.get_loop_component_start_time(); + break; + case Operation::OPERATION_READ: + this->pending_reads_ &= ~param_bit(this->param_); + break; + default: + break; + } } } // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index 7d5baf97a0c..719aa6f5fe0 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -12,22 +12,66 @@ #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/components/uart/uart.h" +#include + namespace esphome::dfrobot_sen0609 { static constexpr uint8_t MAX_LINE_LENGTH = 80; static constexpr uint8_t MAX_COMMAND_LENGTH = 40; +static constexpr uint8_t MAX_RESPONSE_VALUES = 3; enum class RunMode : uint8_t { RUN_MODE_PRESENCE = 0, RUN_MODE_SPEED_AND_DISTANCE = 1, }; +/// Settings shown as number entities. +enum NumberSetting : uint8_t { + NUMBER_MIN_RANGE, + NUMBER_MAX_RANGE, + NUMBER_TRIGGER_RANGE, + NUMBER_HOLD_SENSITIVITY, + NUMBER_TRIGGER_SENSITIVITY, + NUMBER_ON_LATENCY, + NUMBER_OFF_LATENCY, + NUMBER_INHIBIT_TIME, + NUMBER_THRESHOLD_FACTOR, + NUMBER_COUNT, +}; + +/// Settings shown as switch entities. +enum SwitchSetting : uint8_t { + SWITCH_MICRO_MOTION, + SWITCH_RUN_LED, + SWITCH_OUT_LED, + SWITCH_COUNT, +}; + /// Module parameters; a write or read of each is one command. Pending work runs in this order. enum Param : uint8_t { PARAM_RUN_APP, PARAM_UART_OUTPUT, + PARAM_RANGE, + PARAM_TRIG_RANGE, + PARAM_SENSITIVITY, + PARAM_LATENCY, + PARAM_INHIBIT, + PARAM_THR_FACTOR, + PARAM_MICRO_MOTION, + PARAM_GPIO_MODE, + PARAM_RUN_LED, + PARAM_OUT_LED, PARAM_SW_VERSION, PARAM_HW_VERSION, PARAM_COUNT, @@ -38,6 +82,7 @@ enum class Operation : uint8_t { OPERATION_STOP, OPERATION_START, OPERATION_WRITE, + OPERATION_SAVE, OPERATION_READ, }; @@ -54,6 +99,9 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice SUB_TEXT_SENSOR(version) SUB_TEXT_SENSOR(hardware_version) #endif +#ifdef USE_SELECT + SUB_SELECT(out_pin_level) +#endif public: explicit DFRobotSEN0609Component(RunMode mode); @@ -62,17 +110,32 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice void loop() override; void dump_config() override; +#ifdef USE_NUMBER + void set_number(NumberSetting setting, number::Number *number) { this->numbers_[setting] = number; } + void control_number(NumberSetting setting, float value); +#endif +#ifdef USE_SWITCH + void set_switch(SwitchSetting setting, switch_::Switch *sw) { this->switches_[setting] = sw; } + void control_switch(SwitchSetting setting, bool state); +#endif +#ifdef USE_SELECT + void control_out_pin_level(size_t index); +#endif + protected: void readline_(uint8_t c); void handle_line_(char *line); void handle_frame_(char *line); void log_bad_frame_(const LogString *problem, const char *line); + void parse_response_(const char *args); void queue_configuration_(); uint16_t wanted_reads_() const; + void request_write_(Param param); void run_sequencer_(); void check_frames_(uint32_t now); bool build_command_(Operation operation, Param param); + bool apply_read_(Param param); void finish_operation_(); void fail_operation_(); void hold_(uint32_t duration_ms); @@ -80,6 +143,13 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice return this->operation_ == Operation::OPERATION_STOP || this->operation_ == Operation::OPERATION_START; } +#ifdef USE_NUMBER + void publish_number_(NumberSetting setting, float value); + void forget_number_changes_(Param param, bool unknown); +#endif +#ifdef USE_SWITCH + void publish_switch_(SwitchSetting setting, bool state); +#endif #ifdef USE_SENSOR void publish_target_(float distance, float speed, float energy); Deduplicator distance_dedup_; @@ -87,26 +157,56 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice Deduplicator energy_dedup_; #endif +#ifdef USE_NUMBER + std::array numbers_{}; + std::array number_values_; +#endif +#ifdef USE_SWITCH + std::array switches_{}; + std::array switch_states_{}; +#endif + char line_[MAX_LINE_LENGTH]; char command_[MAX_COMMAND_LENGTH]{}; + float response_[MAX_RESPONSE_VALUES]{}; uint32_t sent_ms_{0}; uint32_t hold_start_ms_{0}; uint32_t hold_ms_{0}; uint32_t last_frame_ms_{0}; + uint32_t last_change_ms_{0}; + uint32_t deferred_ms_{0}; uint16_t pending_writes_{0}; uint16_t pending_reads_{0}; + // Reads made for a write whose paired value is unknown; the write is dropped when the read fails + uint16_t paired_reads_{0}; + // Writes given up on that are tried again in a later sequence + uint16_t deferred_writes_{0}; +#ifdef USE_NUMBER + // Numbers changed since their last write was sent, one bit per NumberSetting + uint16_t changed_numbers_{0}; +#endif RunMode mode_; Operation operation_{Operation::OPERATION_NONE}; Param param_{PARAM_COUNT}; uint8_t line_pos_{0}; + uint8_t response_count_{0}; + uint8_t out_pin_level_{0}; uint8_t attempts_{0}; uint8_t aux_attempts_{0}; +#ifdef USE_SWITCH + // The module cannot be asked for its LED states: the ones it last accepted, one bit per SwitchSetting + uint8_t led_known_{0}; + uint8_t led_on_{0}; + bool sent_led_on_{false}; +#endif bool line_overflow_{false}; bool read_failed_{false}; bool bad_frame_logged_{false}; bool echo_seen_{false}; bool version_received_{false}; bool stopped_{false}; + bool save_pending_{false}; + bool deferred_save_{false}; bool sequence_failed_{false}; }; diff --git a/esphome/components/dfrobot_sen0609/number/__init__.py b/esphome/components/dfrobot_sen0609/number/__init__.py new file mode 100644 index 00000000000..668ad929c50 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/__init__.py @@ -0,0 +1,194 @@ +from typing import NamedTuple + +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MAX_RANGE, + CONF_MIN_RANGE, + CONF_MODEL, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + ICON_MOTION_SENSOR, + ICON_RULER, + ICON_TIMELAPSE, + UNIT_METER, + UNIT_SECOND, +) +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MAX_RANGES, + MODE_PRESENCE, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, + get_hub_config, +) + +DFRobotSEN0609Number = dfrobot_sen0609_ns.class_("DFRobotSEN0609Number", number.Number) +NumberSetting = dfrobot_sen0609_ns.enum("NumberSetting") + +CONF_HOLD_SENSITIVITY = "hold_sensitivity" +CONF_INHIBIT_TIME = "inhibit_time" +CONF_OFF_LATENCY = "off_latency" +CONF_ON_LATENCY = "on_latency" +CONF_THRESHOLD_FACTOR = "threshold_factor" +CONF_TRIGGER_RANGE = "trigger_range" +CONF_TRIGGER_SENSITIVITY = "trigger_sensitivity" + + +class Setting(NamedTuple): + key: str + setting: cg.MockObj + min_value: float + max_value: float | None # None: the model's longest range + step: float + mode: str | None # None: both modes + schema: cv.Schema + + +def _distance(icon: str = ICON_RULER) -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=icon, + unit_of_measurement=UNIT_METER, + ) + + +def _duration() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_TIMELAPSE, + unit_of_measurement=UNIT_SECOND, + ) + + +def _plain() -> cv.Schema: + return number.number_schema( + DFRobotSEN0609Number, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ) + + +SETTINGS = ( + Setting( + CONF_MIN_RANGE, + NumberSetting.NUMBER_MIN_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_MAX_RANGE, + NumberSetting.NUMBER_MAX_RANGE, + 0.6, + None, + 0.1, + None, + _distance(), + ), + Setting( + CONF_TRIGGER_RANGE, + NumberSetting.NUMBER_TRIGGER_RANGE, + 0.6, + None, + 0.1, + MODE_PRESENCE, + _distance(ICON_MOTION_SENSOR), + ), + Setting( + CONF_HOLD_SENSITIVITY, + NumberSetting.NUMBER_HOLD_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_TRIGGER_SENSITIVITY, + NumberSetting.NUMBER_TRIGGER_SENSITIVITY, + 0, + 9, + 1, + MODE_PRESENCE, + _plain(), + ), + Setting( + CONF_ON_LATENCY, + NumberSetting.NUMBER_ON_LATENCY, + 0, + 2, + 0.01, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_OFF_LATENCY, + NumberSetting.NUMBER_OFF_LATENCY, + 2, + 1500, + 0.5, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_INHIBIT_TIME, + NumberSetting.NUMBER_INHIBIT_TIME, + 0.1, + 60, + 0.1, + MODE_PRESENCE, + _duration(), + ), + Setting( + CONF_THRESHOLD_FACTOR, + NumberSetting.NUMBER_THRESHOLD_FACTOR, + 0, + 65535, + 1, + MODE_SPEED_AND_DISTANCE, + _plain(), + ), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + **{cv.Optional(s.key): s.schema for s in SETTINGS}, + } +) + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {s.key: s.mode for s in SETTINGS if s.mode is not None} +) + + +async def to_code(config: ConfigType) -> None: + hub_id = config[CONF_DFROBOT_SEN0609_ID] + hub = await cg.get_variable(hub_id) + max_range = MAX_RANGES[get_hub_config(CORE.config, hub_id)[CONF_MODEL]] + numbers = number.sub_numbers(config, parent=hub) + for s in SETTINGS: + await numbers( + s.key, + lambda n, setting=s.setting: hub.set_number(setting, n), + s.setting, + min_value=s.min_value, + max_value=max_range if s.max_value is None else s.max_value, + step=s.step, + ) diff --git a/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h new file mode 100644 index 00000000000..d241ec75e10 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/number/dfrobot_sen0609_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Number final : public number::Number, public Parented { + public: + explicit DFRobotSEN0609Number(NumberSetting setting) : setting_(setting) {} + + protected: + void control(float value) override { this->parent_->control_number(this->setting_, value); } + + NumberSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/select/__init__.py b/esphome/components/dfrobot_sen0609/select/__init__.py new file mode 100644 index 00000000000..a68ed8c8729 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import select +from esphome.components.const import CONF_OUT_PIN_LEVEL +import esphome.config_validation as cv +from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType + +from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component, dfrobot_sen0609_ns + +OutPinLevelSelect = dfrobot_sen0609_ns.class_("OutPinLevelSelect", select.Select) + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_OUT_PIN_LEVEL): select.select_schema( + OutPinLevelSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_SCALE, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + selects = select.sub_selects(config, parent=hub) + # The level the OUT pin has while a target is present; the index is the module's value + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) diff --git a/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h new file mode 100644 index 00000000000..0ea2fdd8974 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/select/dfrobot_sen0609_select.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class OutPinLevelSelect final : public select::Select, public Parented { + public: + // User provided, not "= default": `new(p) OutPinLevelSelect()` would zero-fill .bss that is already zero. + OutPinLevelSelect() {} + + protected: + void control(size_t index) override { this->parent_->control_out_pin_level(index); } +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/esphome/components/dfrobot_sen0609/switch/__init__.py b/esphome/components/dfrobot_sen0609/switch/__init__.py new file mode 100644 index 00000000000..3baa9656f4c --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_SWITCH, + ENTITY_CATEGORY_CONFIG, + ICON_LIGHTBULB, + ICON_MOTION_SENSOR, +) +from esphome.types import ConfigType + +from .. import ( + CONF_DFROBOT_SEN0609_ID, + MODE_SPEED_AND_DISTANCE, + DFRobotSEN0609Component, + dfrobot_sen0609_ns, + final_validate_mode, +) + +DFRobotSEN0609Switch = dfrobot_sen0609_ns.class_("DFRobotSEN0609Switch", switch.Switch) +SwitchSetting = dfrobot_sen0609_ns.enum("SwitchSetting") + +CONF_MICRO_MOTION = "micro_motion" +CONF_OUT_LED = "out_led" +CONF_RUN_LED = "run_led" + + +def _led() -> cv.Schema: + # The module forgets the LED state at power off, so it is restored from here + return switch.switch_schema( + DFRobotSEN0609Switch, + default_restore_mode="RESTORE_DEFAULT_ON", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_LIGHTBULB, + ) + + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_DFROBOT_SEN0609_ID): cv.use_id(DFRobotSEN0609Component), + cv.Optional(CONF_MICRO_MOTION): switch.switch_schema( + DFRobotSEN0609Switch, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_MOTION_SENSOR, + ), + cv.Optional(CONF_RUN_LED): _led(), + cv.Optional(CONF_OUT_LED): _led(), +} + +FINAL_VALIDATE_SCHEMA = final_validate_mode( + {CONF_MICRO_MOTION: MODE_SPEED_AND_DISTANCE} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DFROBOT_SEN0609_ID]) + switches = switch.sub_switches(config, parent=hub) + for key, setting in ( + (CONF_MICRO_MOTION, SwitchSetting.SWITCH_MICRO_MOTION), + (CONF_RUN_LED, SwitchSetting.SWITCH_RUN_LED), + (CONF_OUT_LED, SwitchSetting.SWITCH_OUT_LED), + ): + await switches( + key, lambda s, setting=setting: hub.set_switch(setting, s), setting + ) diff --git a/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h new file mode 100644 index 00000000000..dc2def0d809 --- /dev/null +++ b/esphome/components/dfrobot_sen0609/switch/dfrobot_sen0609_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../dfrobot_sen0609.h" + +namespace esphome::dfrobot_sen0609 { + +class DFRobotSEN0609Switch final : public switch_::Switch, public Parented { + public: + explicit DFRobotSEN0609Switch(SwitchSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->control_switch(this->setting_, state); } + + SwitchSetting setting_; +}; + +} // namespace esphome::dfrobot_sen0609 diff --git a/tests/components/dfrobot_sen0609/benchmark.yaml b/tests/components/dfrobot_sen0609/benchmark.yaml index 9a41ec7cf26..2a35b04794d 100644 --- a/tests/components/dfrobot_sen0609/benchmark.yaml +++ b/tests/components/dfrobot_sen0609/benchmark.yaml @@ -3,3 +3,6 @@ binary_sensor: sensor: text_sensor: +number: +switch: +select: diff --git a/tests/components/dfrobot_sen0609/common-speed.yaml b/tests/components/dfrobot_sen0609/common-speed.yaml index b4063a6f6b2..623d808fb75 100644 --- a/tests/components/dfrobot_sen0609/common-speed.yaml +++ b/tests/components/dfrobot_sen0609/common-speed.yaml @@ -1,5 +1,6 @@ dfrobot_sen0609: id: sen0609_speed + model: SEN0610 mode: speed_and_distance binary_sensor: @@ -17,3 +18,19 @@ sensor: name: Target Speed target_energy: name: Target Energy + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + min_range: + name: Minimum Range + max_range: + name: Maximum Range + threshold_factor: + name: Threshold Factor + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_speed + micro_motion: + name: Micro Motion diff --git a/tests/components/dfrobot_sen0609/common.h b/tests/components/dfrobot_sen0609/common.h index 08b9d86366a..7e4ea62f84d 100644 --- a/tests/components/dfrobot_sen0609/common.h +++ b/tests/components/dfrobot_sen0609/common.h @@ -39,6 +39,22 @@ class FakeUART : public uart::UARTComponent { size_t rx_pos{0}; }; +// The hub publishes read values; writes are driven through control_number() directly. +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +class TestSwitch : public switch_::Switch { + protected: + void write_state(bool state) override {} +}; + +class TestSelect : public select::Select { + protected: + void control(size_t index) override {} +}; + class SEN0609Test : public ::testing::Test { protected: explicit SEN0609Test(RunMode mode = RunMode::RUN_MODE_PRESENCE) : hub_(mode) {} @@ -60,6 +76,14 @@ class SEN0609Test : public ::testing::Test { this->run_(); } + // Feeds a presence frame every second, so the frame watchdog stays quiet, for `seconds` or until the hub sends + void feed_frames_(int seconds) { + for (int i = 0; i < seconds && this->uart_.tx.empty(); i++) { + this->feed_("$DFHPD,0, , , *\r\n"); + this->run_(1000); + } + } + // Runs the loop until the hub writes a full command line, and returns it without the line end std::string next_command_(uint32_t max_ms = 5000) { for (uint32_t waited = 0; waited <= max_ms; waited += 16) { diff --git a/tests/components/dfrobot_sen0609/common.yaml b/tests/components/dfrobot_sen0609/common.yaml index 1f3887be631..aca15b906fc 100644 --- a/tests/components/dfrobot_sen0609/common.yaml +++ b/tests/components/dfrobot_sen0609/common.yaml @@ -15,3 +15,37 @@ text_sensor: name: Software Version hardware_version: name: Hardware Version + +number: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + min_range: + name: Minimum Range + max_range: + name: Maximum Range + trigger_range: + name: Trigger Range + hold_sensitivity: + name: Hold Sensitivity + trigger_sensitivity: + name: Trigger Sensitivity + on_latency: + name: On Latency + off_latency: + name: Off Latency + inhibit_time: + name: Inhibit Time + +switch: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + run_led: + name: Run LED + out_led: + name: Out LED + +select: + - platform: dfrobot_sen0609 + dfrobot_sen0609_id: sen0609_presence + out_pin_level: + name: Out Pin Level diff --git a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp index 341ba09432f..aa39c4f6a4d 100644 --- a/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp +++ b/tests/components/dfrobot_sen0609/dfrobot_sen0609_sequencer.cpp @@ -6,8 +6,10 @@ class SEN0609SequencerTest : public SEN0609Test { protected: void SetUp() override { SEN0609Test::SetUp(); + this->hub_.set_number(NUMBER_MIN_RANGE, &this->min_range_); + this->hub_.set_number(NUMBER_MAX_RANGE, &this->max_range_); + this->hub_.set_number(NUMBER_HOLD_SENSITIVITY, &this->hold_sensitivity_); this->hub_.set_version_text_sensor(&this->version_); - this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); } // Runs the boot sequence with a module that answers everything @@ -16,36 +18,97 @@ class SEN0609SequencerTest : public SEN0609Test { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); } + TestNumber min_range_; + TestNumber max_range_; + TestNumber hold_sensitivity_; text_sensor::TextSensor version_; - text_sensor::TextSensor hardware_version_; }; -TEST_F(SEN0609SequencerTest, BootAppliesModeAndReadsVersions) { +TEST_F(SEN0609SequencerTest, BootReadsSettingsWithoutSaving) { this->boot_(); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); EXPECT_EQ(this->version_.state, "V1.0.4"); - EXPECT_EQ(this->hardware_version_.state, "V2.0"); EXPECT_EQ(this->next_command_(), ""); EXPECT_FALSE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, ChangesMadeTogetherShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 10.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 10.00"); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, WriteWaitsBeforeStarting) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(500), ""); + EXPECT_EQ(this->next_command_(), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, NotStoppedStopsAndRetries) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + // The module was started behind our back + this->feed_("setRange 1.00 12.00\r\nsensor is not stopped\r\nDFRobot:/>"); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); +} + +TEST_F(SEN0609SequencerTest, MalformedAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); +} + +TEST_F(SEN0609SequencerTest, NonFiniteAnswerIsRetried) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response nan 12.0\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 inf\r\n"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); +} + TEST_F(SEN0609SequencerTest, ErrorAnswerIsRetriedThenSkipped) { this->hub_.setup(); this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); for (int i = 0; i < 3; i++) { - ASSERT_EQ(this->next_command_(), "getSWV"); - this->feed_("getSWV\r\nError\r\nDFRobot:/>"); + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); } - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_TRUE(this->hub_.status_has_warning()); - EXPECT_FALSE(this->version_.has_state()); + EXPECT_FALSE(this->min_range_.has_state()); } TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { @@ -53,15 +116,29 @@ TEST_F(SEN0609SequencerTest, LateAnswerIsNotTakenForNextCommand) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "getRange"); + ASSERT_EQ(this->next_command_(), "getSensitivity"); + // The answer to the last getRange arrives after it timed out + this->feed_("getRange\r\nResponse 0.6 12.0\r\nDone\r\nDFRobot:/>"); + this->answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); +} + +TEST_F(SEN0609SequencerTest, LateVersionLineIsStillUsed) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); for (int i = 0; i < 3; i++) ASSERT_EQ(this->next_command_(), "getSWV"); - ASSERT_EQ(this->next_command_(), "getHWV"); - // The answer to the last getSWV arrives after it timed out + ASSERT_EQ(this->next_command_(), "sensorStart"); + // The answer to the last getSWV arrives after it timed out; the version line names its value this->feed_("getSWV\r\nSoftwareVersion:V1.0.4\r\nDone\r\nDFRobot:/>"); - this->answer_("getHWV", "HardwareVersion:V2.0\r\n"); - this->expect_and_answer_("sensorStart"); - EXPECT_EQ(this->hardware_version_.state, "V2.0"); - // The late version line names its value, so it is still used + this->answer_("sensorStart"); EXPECT_EQ(this->version_.state, "V1.0.4"); } @@ -70,10 +147,11 @@ TEST_F(SEN0609SequencerTest, MissingVersionLineIsRetried) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); EXPECT_EQ(this->version_.state, "V1.0.4"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } @@ -91,8 +169,9 @@ TEST_F(SEN0609SequencerTest, FailedConfigurationIsRetriedLater) { ASSERT_EQ(this->next_command_(15000), "setRunApp 0"); this->answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } @@ -110,6 +189,157 @@ TEST_F(SEN0609SequencerTest, SilentModuleBacksOffAndKeepsWork) { EXPECT_EQ(this->next_command_(), "setRunApp 0"); } +TEST_F(SEN0609SequencerTest, PairedValueUnknownIsReadBeforeWrite) { + // No boot reads, so the trigger sensitivity paired with hold sensitivity is unknown + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, PairedValueReadFailureDropsWrite) { + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->feed_("getSensitivity\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); + // Whether the change reached the module is unknown + EXPECT_TRUE(std::isnan(this->hold_sensitivity_.state)); +} + +TEST_F(SEN0609SequencerTest, ChangeAfterFailedBootReadIsWritten) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "getRange"); + this->feed_("getRange\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ChangeBeforeBootReadWaitsForPairedValue) { + this->hub_.setup(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->expect_and_answer_("setRange 1.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringWriteIsWrittenAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MIN_RANGE, 2.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 2.00 12.00"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ChangesDuringSequenceShareOneSave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->hub_.control_number(NUMBER_MAX_RANGE, 10.0f); + this->answer_("setRange 1.00 12.00"); + this->expect_and_answer_("setRange 1.00 10.00"); + // The save waits a second after the last change + EXPECT_EQ(this->next_command_(500), ""); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 3.0f); + this->expect_and_answer_("setSensitivity 3 5"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); +} + +TEST_F(SEN0609SequencerTest, ReadAfterFailedWriteKeepsNewerPartnerChange) { + this->boot_(); + this->hub_.control_number(NUMBER_HOLD_SENSITIVITY, 9.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setSensitivity 9 5"); + this->feed_("setSensitivity 9 5\r\nError\r\nDFRobot:/>"); + } + ASSERT_EQ(this->next_command_(), "getSensitivity"); + this->hub_.control_number(NUMBER_TRIGGER_SENSITIVITY, 4.0f); + this->answer_("getSensitivity", "Response 7 5\r\n"); + // The rejected hold sensitivity is put back, the newer trigger sensitivity is kept + EXPECT_FLOAT_EQ(this->hold_sensitivity_.state, 7.0f); + this->expect_and_answer_("setSensitivity 7 4"); +} + +TEST_F(SEN0609SequencerTest, AutomationDuringReadKeepsItsChange) { + bool changed = false; + this->min_range_.add_on_state_callback([this, &changed](float) { + if (!changed) { + changed = true; + this->hub_.control_number(NUMBER_MAX_RANGE, 8.0f); + } + }); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->max_range_.state, 8.0f); + this->expect_and_answer_("setRange 0.60 8.00"); +} + +TEST_F(SEN0609SequencerTest, ChangeDuringReadIsKept) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + ASSERT_EQ(this->next_command_(), "getRange"); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->answer_("getRange", "Response 0.6 12.0\r\n"); + EXPECT_FLOAT_EQ(this->min_range_.state, 1.0f); + this->expect_and_answer_("setRange 1.00 12.00"); +} + +TEST_F(SEN0609SequencerTest, FailedWriteShowsModuleValueAgain) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setRange 1.00 12.00"); + this->feed_("setRange 1.00 12.00\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("sensorStart"); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + TEST_F(SEN0609SequencerTest, AlreadyStoppedCountsAsStopped) { this->hub_.setup(); ASSERT_EQ(this->next_command_(), "sensorStop"); @@ -133,8 +363,9 @@ TEST_F(SEN0609SequencerTest, AlreadyStartedCountsAsStarted) { this->expect_and_answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); // As left by an earlier sequence that failed this->hub_.status_set_warning(); ASSERT_EQ(this->next_command_(), "sensorStart"); @@ -165,10 +396,194 @@ TEST_F(SEN0609SequencerTest, MissingPresenceFramesReapplyConfiguration) { this->answer_("sensorStop"); this->expect_and_answer_("setRunApp 0"); this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); - this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); this->expect_and_answer_("sensorStart"); EXPECT_FALSE(this->hub_.status_has_warning()); } +TEST_F(SEN0609SequencerTest, ChangeDuringBackOffKeepsBackOff) { + this->hub_.setup(); + for (int i = 0; i < 3; i++) + ASSERT_EQ(this->next_command_(), "sensorStop"); + this->run_(2100); // the last attempt times out + EXPECT_TRUE(this->hub_.status_has_warning()); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + EXPECT_EQ(this->next_command_(20000), ""); + EXPECT_EQ(this->next_command_(15000), "sensorStop"); +} + +TEST_F(SEN0609SequencerTest, FailedSaveIsRetriedLater) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError\r\nDFRobot:/>"); + } + // The sensor runs again meanwhile + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("saveConfig"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SequencerTest, ErrorMentioningNoParameterIsNotASave) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 1.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRange 1.00 12.00"); + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nError: no parameter saved\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), "saveConfig"); +} + +TEST_F(SEN0609SequencerTest, MinRangeAboveMaxRangeIsRefused) { + this->boot_(); + this->hub_.control_number(NUMBER_MIN_RANGE, 13.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + this->hub_.control_number(NUMBER_MAX_RANGE, 0.5f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + EXPECT_EQ(this->next_command_(3000), ""); +} + +TEST_F(SEN0609SequencerTest, TriggerRangeOutsideRangeIsRefused) { + TestNumber trigger_range; + this->hub_.set_number(NUMBER_TRIGGER_RANGE, &trigger_range); + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + this->expect_and_answer_("getRange", "Response 0.6 12.0\r\n"); + this->expect_and_answer_("getTrigRange", "Response 6.0\r\n"); + this->expect_and_answer_("getSensitivity", "Response 7 5\r\n"); + this->expect_and_answer_("getSWV", "SoftwareVersion:V1.0.4\r\n"); + this->expect_and_answer_("sensorStart"); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 13.0f); + EXPECT_FLOAT_EQ(trigger_range.state, 6.0f); + // Lowering the maximum below the trigger range is refused too + this->hub_.control_number(NUMBER_MAX_RANGE, 5.0f); + EXPECT_FLOAT_EQ(this->max_range_.state, 12.0f); + this->hub_.control_number(NUMBER_MIN_RANGE, 7.0f); + EXPECT_FLOAT_EQ(this->min_range_.state, 0.6f); + EXPECT_EQ(this->next_command_(3000), ""); + this->hub_.control_number(NUMBER_TRIGGER_RANGE, 8.0f); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setTrigRange 8.00"); +} + +class SEN0609SettingsTest : public SEN0609Test { + protected: + void SetUp() override { + SEN0609Test::SetUp(); + this->run_led_.set_restore_mode(switch_::SWITCH_ALWAYS_ON); + this->run_led_.set_inverted(true); + this->out_led_.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + this->out_pin_level_.traits.set_options({"low", "high"}); + this->hub_.set_switch(SWITCH_MICRO_MOTION, &this->micro_motion_); + this->hub_.set_switch(SWITCH_RUN_LED, &this->run_led_); + this->hub_.set_switch(SWITCH_OUT_LED, &this->out_led_); + this->hub_.set_out_pin_level_select(&this->out_pin_level_); + this->hub_.set_hardware_version_text_sensor(&this->hardware_version_); + } + + void boot_() { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + // Inverted and on: the LED is off, which is mode 1 + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + this->expect_and_answer_("sensorStart"); + } + + TestSwitch micro_motion_; + TestSwitch run_led_; + TestSwitch out_led_; + TestSelect out_pin_level_; + text_sensor::TextSensor hardware_version_; +}; + +TEST_F(SEN0609SettingsTest, BootWritesLedsAndReadsSettings) { + this->boot_(); + EXPECT_TRUE(this->run_led_.state); + EXPECT_FALSE(this->out_led_.state); + EXPECT_TRUE(this->micro_motion_.state); + EXPECT_EQ(this->out_pin_level_.active_index(), optional(1)); + EXPECT_EQ(this->hardware_version_.state, "V2.0"); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedLedWriteShowsAcceptedStateAgain) { + this->boot_(); + this->hub_.control_switch(SWITCH_OUT_LED, true); + EXPECT_TRUE(this->out_led_.state); + this->expect_and_answer_("sensorStop"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 2 0"); + this->feed_("setLedMode 2 0\r\nError\r\nDFRobot:/>"); + } + // The LEDs cannot be read back, so the state the module accepted at boot is shown again + EXPECT_FALSE(this->out_led_.state); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_TRUE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, FailedBootLedWriteIsRetriedLater) { + this->hub_.setup(); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setRunApp 0"); + this->expect_and_answer_("setUartOutput 1 1 1 1.0"); + for (int i = 0; i < 3; i++) { + ASSERT_EQ(this->next_command_(), "setLedMode 1 1"); + this->feed_("setLedMode 1 1\r\nError\r\nDFRobot:/>"); + } + this->expect_and_answer_("setLedMode 2 1"); + this->expect_and_answer_("getMicroMotion", "Response 1\r\n"); + this->expect_and_answer_("getGpioMode 1", "Response 1 1\r\n"); + this->expect_and_answer_("getHWV", "HardwareVersion:V2.0\r\n"); + // The sensor runs without the LED state, which is written again later + this->expect_and_answer_("sensorStart"); + EXPECT_TRUE(this->hub_.status_has_warning()); + this->feed_frames_(28); + EXPECT_EQ(this->uart_.tx, ""); + this->feed_frames_(4); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setLedMode 1 1"); + this->expect_and_answer_("sensorStart"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + +TEST_F(SEN0609SettingsTest, SwitchAndSelectChangesAreWrittenAndSaved) { + this->boot_(); + this->hub_.control_switch(SWITCH_MICRO_MOTION, false); + this->hub_.control_out_pin_level(0); + this->hub_.control_switch(SWITCH_OUT_LED, true); + this->expect_and_answer_("sensorStop"); + this->expect_and_answer_("setMicroMotion 0"); + this->expect_and_answer_("setGpioMode 1 0"); + this->expect_and_answer_("setLedMode 2 0"); + // Answers that refuse a command whose effect is already in place count as done + ASSERT_EQ(this->next_command_(), "saveConfig"); + this->feed_("saveConfig\r\nno parameter has been changed\r\nDFRobot:/>"); + ASSERT_EQ(this->next_command_(), "sensorStart"); + this->feed_("sensorStart\r\nsensor started already\r\nDFRobot:/>"); + EXPECT_EQ(this->next_command_(), ""); + EXPECT_FALSE(this->hub_.status_has_warning()); +} + } // namespace esphome::dfrobot_sen0609::testing diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml index e1805f81c5d..b3112d231dc 100644 --- a/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0609.yaml @@ -27,9 +27,20 @@ uart_mock: std::string cmd(data.begin(), data.end()); if (cmd == "\r\n") return; + static float min_range = 0.6f; + static float max_range = 12.0f; std::string reply = cmd + "\r\n"; - if (cmd == "getSWV") + if (cmd == "getRange") { + char buf[48]; + snprintf(buf, sizeof(buf), "Response %.1f %.1f\r\n", min_range, max_range); + reply += buf; + } else if (cmd.rfind("setRange ", 0) == 0) { + sscanf(cmd.c_str() + 9, "%f %f", &min_range, &max_range); + } else if (cmd == "saveConfig") { + ESP_LOGI("sim", "Saved range %.2f %.2f", min_range, max_range); + } else if (cmd == "getSWV") { reply += "SoftwareVersion:V1.0.4\r\n"; + } reply += "Done\r\nDFRobot:/>"; id(mock_uart).inject_to_rx_buffer(std::vector(reply.begin(), reply.end())); @@ -50,6 +61,13 @@ binary_sensor: has_target: name: Has Target +number: + - platform: dfrobot_sen0609 + min_range: + name: Min Range + max_range: + name: Max Range + text_sensor: - platform: dfrobot_sen0609 version: diff --git a/tests/integration/test_uart_mock_dfrobot_sen0609.py b/tests/integration/test_uart_mock_dfrobot_sen0609.py index 1146e27d27f..2931ace2f4d 100644 --- a/tests/integration/test_uart_mock_dfrobot_sen0609.py +++ b/tests/integration/test_uart_mock_dfrobot_sen0609.py @@ -1,6 +1,7 @@ """Integration test for the DFRobot SEN0609 component against a model of the module's command line. -Checks the boot sequence: the software version is read and presence frames are parsed. +Checks the boot sequence (settings read back, presence frames parsed) and that a number change +reaches the module as setRange followed by saveConfig. """ from __future__ import annotations @@ -8,7 +9,7 @@ from __future__ import annotations import asyncio from pathlib import Path -from aioesphomeapi import BinarySensorInfo, EntityState, TextSensorInfo +from aioesphomeapi import BinarySensorInfo, EntityState, NumberInfo, TextSensorInfo import pytest from .state_utils import require_entity @@ -21,7 +22,7 @@ async def test_uart_mock_dfrobot_sen0609( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Boot reads the software version; presence frames reach the binary sensor.""" + """Boot reads the module's settings; a number change is written and saved.""" external_components_path = str( Path(__file__).parent / "fixtures" / "external_components" ) @@ -29,6 +30,13 @@ async def test_uart_mock_dfrobot_sen0609( "EXTERNAL_COMPONENT_PATH", external_components_path ) + loop = asyncio.get_running_loop() + saved = loop.create_future() + + def line_callback(line: str) -> None: + if "Saved range 0.60 8.00" in line and not saved.done(): + saved.set_result(True) + states: dict[int, EntityState] = {} async def wait_for(condition, what: str) -> None: @@ -40,11 +48,13 @@ async def test_uart_mock_dfrobot_sen0609( pytest.fail(f"Timeout waiting for {what}; states: {states}") async with ( - run_compiled(yaml_config), + run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): entities, _ = await client.list_entities_services() has_target = require_entity(entities, "has_target", BinarySensorInfo) + min_range = require_entity(entities, "min_range", NumberInfo) + max_range = require_entity(entities, "max_range", NumberInfo) version = require_entity(entities, "version", TextSensorInfo) client.subscribe_states(lambda state: states.__setitem__(state.key, state)) @@ -55,3 +65,16 @@ async def test_uart_mock_dfrobot_sen0609( await wait_for(lambda: state_of(has_target) is True, "presence") await wait_for(lambda: state_of(version) == "V1.0.4", "software version") + await wait_for( + lambda: state_of(max_range) == pytest.approx(12.0), "maximum range" + ) + assert state_of(min_range) == pytest.approx(0.6) + + client.number_command(max_range.key, 8.0) + try: + await asyncio.wait_for(saved, timeout=10.0) + except TimeoutError: + pytest.fail("The new range was not written and saved") + await wait_for( + lambda: state_of(max_range) == pytest.approx(8.0), "new maximum range" + ) From a58ce2d6416ad59fbde11d2fe060e163873f90b6 Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 20:48:18 +0000 Subject: [PATCH 29/50] [as734x] [2/3] Add basic counts, calibration and saturation level (#18011) Co-authored-by: J. Nick Koston --- esphome/components/as734x/__init__.py | 94 ++++++- esphome/components/as734x/as734x.cpp | 241 ++++++++++++++++-- esphome/components/as734x/as734x.h | 83 +++++- esphome/components/as734x/sensor.py | 45 +++- tests/component_tests/as734x/test_as734x.py | 28 ++ tests/component_tests/as734x/test_as734x.yaml | 9 + tests/components/as734x/common.yaml | 23 ++ 7 files changed, 486 insertions(+), 37 deletions(-) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 6657d76bf4d..bd43f78f01e 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -1,7 +1,14 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_GAIN, CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL +from esphome.const import ( + CONF_CALIBRATION, + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_TYPE, + CONF_UPDATE_INTERVAL, +) from esphome.types import ConfigType CODEOWNERS = ["@latonita", "@mrgnr"] @@ -13,6 +20,9 @@ DOMAIN = "as734x" CONF_AS734X_ID = "as734x_id" CONF_ATIME = "atime" CONF_ASTEP = "astep" +CONF_CHANNEL_CORRECTION = "channel_correction" +CONF_DARK_CURRENT = "dark_current" +CONF_NORMALIZE_BASIC_COUNTS = "normalize_basic_counts" # Band order is a contract with the readout maps in as734x.cpp: the index selects the counts slot. BANDS_43 = ( @@ -44,11 +54,57 @@ GAINS_41 = { } GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} +Normalization = as734x_ns.enum("Normalization", True) +NORMALIZATIONS = { + "NONE": Normalization.NORMALIZATION_NONE, + "ALL": Normalization.NORMALIZATION_ALL, + "BANDS": Normalization.NORMALIZATION_BANDS, + "CLEAR": Normalization.NORMALIZATION_CLEAR, +} + +# Per-channel correction for the golden device, measured by the manufacturer. +DEFAULT_CHANNEL_CORRECTION_43 = [ + 1.055464349, + 1.043509797, + 1.029576268, + 1.0175052, + 1.00441899, + 0.987356499, + 0.957597044, + 0.995863485, + 1.014628964, + 0.996500814, + 0.933072749, + 1.052236338, + 0.999570232, +] # One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us CYCLE_STEP_SECONDS = 2.78e-6 +def _float_list(length: int) -> cv.All: + """One float per band, so the list length has to match the chip.""" + return cv.All(cv.ensure_list(cv.float_), cv.Length(min=length, max=length)) + + +def _calibration_schema(bands: tuple[str, ...]) -> cv.Schema: + correction = ( + DEFAULT_CHANNEL_CORRECTION_43 if bands == BANDS_43 else [1.0] * len(bands) + ) + return cv.Schema( + { + cv.Optional(CONF_CHANNEL_CORRECTION, default=correction): _float_list( + len(bands) + ), + cv.Optional(CONF_DARK_CURRENT, default=[0.0] * len(bands)): _float_list( + len(bands) + ), + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.positive_float, + } + ) + + def _validate_exposure(cycles: int): """Reject an exposure that is empty or longer than the update interval.""" @@ -67,7 +123,9 @@ def _validate_exposure(cycles: int): return validator -def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: +def _schema( + gains: dict, address: int, cycles: int, bands: tuple[str, ...] +) -> cv.Schema: return cv.All( cv.Schema( { @@ -75,6 +133,10 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), + cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), + cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( + NORMALIZATIONS, upper=True + ), } ) .extend(cv.polling_component_schema("60s")) @@ -85,14 +147,25 @@ def _schema(gains: dict, address: int, cycles: int) -> cv.Schema: CONFIG_SCHEMA = cv.typed_schema( { - "AS7341": _schema(GAINS_41, 0x39, cycles=2), # two SMUX steps per update - "AS7343": _schema(GAINS_43, 0x39, cycles=3), - "TCS3448": _schema(GAINS_43, 0x59, cycles=3), + # two SMUX steps per update + "AS7341": _schema(GAINS_41, 0x39, cycles=2, bands=BANDS_41), + "AS7343": _schema(GAINS_43, 0x39, cycles=3, bands=BANDS_43), + "TCS3448": _schema(GAINS_43, 0x59, cycles=3, bands=BANDS_43), }, upper=True, ) +def _per_band( + values: list[float], bands: tuple[str, ...], absent: float +) -> list[float]: + """Spread per-band calibration over the 13 slots the C++ side indexes by band.""" + spread = [absent] * len(BANDS_43) + for value, band in zip(values, bands, strict=True): + spread[BANDS_43.index(band)] = value + return spread + + async def to_code(config: ConfigType) -> None: chip = config[CONF_TYPE] cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") @@ -102,3 +175,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) cg.add(var.set_astep(config[CONF_ASTEP])) + cg.add(var.set_basic_counts_normalization(config[CONF_NORMALIZE_BASIC_COUNTS])) + + bands = BANDS_41 if chip == "AS7341" else BANDS_43 + calibration = config[CONF_CALIBRATION] + cg.add( + var.set_channel_correction( + _per_band(calibration[CONF_CHANNEL_CORRECTION], bands, 1.0) + ) + ) + cg.add(var.set_dark_current(_per_band(calibration[CONF_DARK_CURRENT], bands, 0.0))) + cg.add(var.set_glass_attenuation_factor(calibration[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 2a9de8f3df2..2331d89c5fe 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include @@ -20,6 +21,8 @@ static constexpr uint8_t CFG0_REG_BANK = 0x10; static constexpr uint8_t BANK_SPLIT = 0x80; // registers below this need REG_BANK set static constexpr uint8_t STATUS2_AVALID = 0x40; static constexpr uint8_t ASTATUS_ASAT = 0x80; +static constexpr uint8_t LED_ACT = 0x80; // LED driver on, in the chip's LED register +static constexpr uint8_t CONFIG_LED_SEL = 0x08; static constexpr uint8_t AS7341_CFG6 = 0xAF; static constexpr uint8_t AS7341_SMUX_CMD_WRITE = 0x10; // copy the SMUX image from RAM to the chain @@ -40,6 +43,9 @@ static constexpr uint8_t CLEAR_BAND = NUM_BANDS - 1; static constexpr uint8_t MAX_SMUX_LEN = 20; static constexpr uint8_t MAX_ADC_SLOTS = 18; static constexpr uint32_t MIN_COLLECTION_TIMEOUT_MS = 30 * 1000; +static constexpr uint32_t MAX_ADC_COUNT = 65535; + +PROGMEM_STRING_TABLE(BandNames, "F1", "F2", "FZ", "F3", "F4", "FY", "F5", "FXL", "F6", "F7", "F8", "NIR", "Clear"); #ifdef USE_AS7341 static const char AS7341_NAME[] PROGMEM = "AS7341"; @@ -60,11 +66,25 @@ static const uint8_t AS7341_READOUT[] PROGMEM = { N, N, N, N, N, N, 0, N, 1, 2, 3, 5, 4, // step 1: SMUX routes clear to ADC4 and NIR to ADC5 }; static_assert(sizeof(AS7341_READOUT) == AS7341_SMUX_STEPS * NUM_BANDS && AS7341_ADC_SLOTS <= MAX_ADC_SLOTS); +// This chip has no FZ, FY or FXL band, so those slots are marked absent. +// clang-format off +static const uint16_t AS7341_WAVELENGTHS[] PROGMEM = { + 415, 445, ABSENT_NM, 480, 515, ABSENT_NM, 555, ABSENT_NM, 590, 630, 680, 910, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, the same for every band. +// The last two entries stay at zero because this chip has no 1024x or 2048x gain. +static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, + 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; +static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); const ChipInfo AS7341_CHIP PROGMEM = { .name = AS7341_NAME, .init = AS7341_INIT, .smux = AS7341_SMUX, .readout = AS7341_READOUT, + .wavelengths = AS7341_WAVELENGTHS, + .gain_correction = AS7341_GAIN_CORRECTION, .init_count = 1, .astep_reg = 0xCA, .cfg0_reg = 0xA9, @@ -73,11 +93,14 @@ const ChipInfo AS7341_CHIP PROGMEM = { .id_reg = 0x92, .id_mask = 0xFC, .id_value = 0x09 << 2, + .led_reg = 0x74, + .led_sel_reg = 0x70, // CONFIG, whose LED_SEL bit connects the pin to the driver .adc_slots = AS7341_ADC_SLOTS, .smux_steps = AS7341_SMUX_STEPS, .integration_cycles = 1, .smux_len = AS7341_SMUX_LEN, .auto_smux = false, + .gain_correction_per_band = false, .extra_clear_slot = NO_SLOT, }; #endif @@ -97,12 +120,41 @@ static const uint8_t AS7343_SMUX[] PROGMEM = {0x00, 0x04, 0x65, 0x02, 0x00, 0x05 static const uint8_t AS7343_READOUT[] PROGMEM = {12, 6, 0, 7, 8, 1, 15, 2, 9, 13, 14, 3, 10}; static_assert(sizeof(AS7343_SMUX) == AS7343_CHAINS * AS7343_SMUX_LEN && AS7343_SMUX_LEN <= MAX_SMUX_LEN); static_assert(sizeof(AS7343_READOUT) == NUM_BANDS && AS7343_ADC_SLOTS <= MAX_ADC_SLOTS); +// clang-format off +static const uint16_t AS7343_WAVELENGTHS[] PROGMEM = { + 405, 425, 450, 475, 515, 555, 550, 600, 640, 690, 745, 855, WIDEBAND_NM, +}; +// clang-format on +static_assert(sizeof(AS7343_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); +// Datasheet values for the golden device: gain correction per gain step, per band. +// clang-format off +static const float AS7343_GAIN_CORRECTION[] PROGMEM = { + 1.149f, 1.100f, 1.060f, 1.070f, 1.063f, 1.051f, 1.062f, 1.056f, 1.049f, 1.040f, 1.080f, 1.038f, 1.065f, // 0.5x + 1.090f, 1.128f, 1.064f, 1.071f, 1.063f, 1.050f, 1.068f, 1.055f, 1.047f, 1.039f, 1.075f, 1.038f, 1.085f, // 1x + 1.083f, 1.086f, 1.062f, 1.070f, 1.062f, 1.049f, 1.057f, 1.053f, 1.045f, 1.038f, 1.063f, 1.037f, 1.069f, // 2x + 1.059f, 1.068f, 1.056f, 1.066f, 1.058f, 1.046f, 1.051f, 1.051f, 1.044f, 1.036f, 1.059f, 1.035f, 1.053f, // 4x + 1.100f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.076f, 1.088f, // 8x + 1.099f, 1.109f, 1.096f, 1.108f, 1.099f, 1.089f, 1.091f, 1.092f, 1.082f, 1.078f, 1.100f, 1.075f, 1.087f, // 16x + 1.088f, 1.096f, 1.085f, 1.097f, 1.087f, 1.078f, 1.079f, 1.080f, 1.071f, 1.067f, 1.087f, 1.064f, 1.076f, // 32x + 1.083f, 1.091f, 1.078f, 1.090f, 1.079f, 1.072f, 1.072f, 1.073f, 1.064f, 1.062f, 1.080f, 1.057f, 1.069f, // 64x + 1.076f, 1.084f, 1.072f, 1.085f, 1.074f, 1.066f, 1.062f, 1.067f, 1.055f, 1.056f, 1.074f, 1.051f, 1.061f, // 128x + 1.067f, 1.074f, 1.063f, 1.075f, 1.064f, 1.059f, 1.055f, 1.058f, 1.049f, 1.051f, 1.064f, 1.044f, 1.053f, // 256x + 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, 1.000f, // 512x + 1.033303857f, 1.034763813f, 1.029855013f, 1.036911249f, 0.988150537f, 1.029003501f, 1.021363258f, + 0.98762089f, 1.021713376f, 1.040108204f, 0.987417698f, 1.019481421f, 0.987270057f, // 1024x + 0.986859143f, 0.980877221f, 0.9469769f, 0.985535324f, 0.926243961f, 0.959814787f, 1.007963896f, + 0.942685187f, 1.007630587f, 1.025460005f, 0.928696275f, 1.001296639f, 1.041275263f, // 2048x +}; +// clang-format on +static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); static constexpr ChipInfo as7343_chip(const char *name) { return { .name = name, .init = AS7343_INIT, .smux = AS7343_SMUX, .readout = AS7343_READOUT, + .wavelengths = AS7343_WAVELENGTHS, + .gain_correction = AS7343_GAIN_CORRECTION, .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), .astep_reg = 0xD4, .cfg0_reg = 0xBF, @@ -111,11 +163,14 @@ static constexpr ChipInfo as7343_chip(const char *name) { .id_reg = 0x5A, .id_mask = 0xFF, .id_value = 0x81, + .led_reg = 0xCD, + .led_sel_reg = 0, // the LED register drives the pin on its own .adc_slots = AS7343_ADC_SLOTS, .smux_steps = 1, .integration_cycles = 3, .smux_len = AS7343_SMUX_LEN, .auto_smux = true, + .gain_correction_per_band = true, .extra_clear_slot = 4, }; } @@ -133,6 +188,34 @@ bool AS734XComponent::select_bank_(uint8_t reg) { return this->write_byte(pgm(this->chip_->cfg0_reg), low ? CFG0_REG_BANK : 0); } +bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { + uint8_t data = 0; + if (!this->select_bank_(reg) || !this->read_byte(reg, &data)) { + return false; + } + data = value ? (data | mask) : (data & ~mask); + return this->write_byte(reg, data); +} + +// The exposure is applied before every measurement, so a gain, ATIME or ASTEP changed at runtime +// takes effect on the next reading rather than only after a restart. +bool AS734XComponent::write_exposure_() { + const ChipInfo *chip = this->chip_; + if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || + !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { + return false; + } + this->reading_gain_ = this->gain_; + this->reading_atime_ = this->atime_; + this->reading_astep_ = this->astep_; + // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. + const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; + this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); + this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); + return true; +} + void AS734XComponent::setup() { const ChipInfo *chip = this->chip_; const uint8_t id_reg = pgm(chip->id_reg); @@ -152,9 +235,7 @@ void AS734XComponent::setup() { ok = this->select_bank_(rv.reg) && this->write_byte(rv.reg, rv.value); } // Everything from here on, including the measurement registers, is in the high bank. - ok = ok && this->select_bank_(REG_ATIME) && this->write_byte(REG_ATIME, this->atime_) && - this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) && - this->write_byte(pgm(chip->cfg1_reg), this->gain_); + ok = ok && this->write_exposure_(); if (pgm(chip->auto_smux)) { const uint8_t *smux = pgm(chip->smux); const uint8_t len = pgm(chip->smux_len); @@ -167,10 +248,6 @@ void AS734XComponent::setup() { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - // One integration cycle lasts (ATIME + 1) * (ASTEP + 1) * 2.78 us. - const uint32_t cycle_ms = (this->atime_ + 1UL) * (this->astep_ + 1UL) * 139 / 50000; - this->integration_ms_ = cycle_ms * pgm(chip->integration_cycles); - this->timeout_ms_ = std::max(MIN_COLLECTION_TIMEOUT_MS, 2 * this->integration_ms_); this->disable_loop(); } @@ -181,24 +258,61 @@ void AS734XComponent::dump_config() { " Chip: %s\n" " Gain: %gx\n" " ATIME: %u\n" - " ASTEP: %u", - LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_); + " ASTEP: %u\n" + " Glass attenuation factor: %.3f", + LOG_STR_ARG(pgm(this->chip_->name)), gain, this->atime_, this->astep_, this->glass_attenuation_factor_); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - for (auto *sens : this->sensors_) { - LOG_SENSOR(" ", "Counts", sens); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + char label[24]; + const char *name = LOG_STR_ARG(BandNames::get_log_str(band, BandNames::LAST_INDEX)); + snprintf(label, sizeof(label), "Counts %s", name); + sensor::log_sensor(TAG, " ", label, this->sensors_[band]); + snprintf(label, sizeof(label), "Basic counts %s", name); + sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); } + LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } } +void AS734XComponent::enable_led(bool enable) { + const uint8_t led_reg = pgm(this->chip_->led_reg); + if (led_reg == 0) { + return; + } + // The LED register only drives the LED while LED_SEL is set, so the pin is handed over before + // switching on and released only after switching off. The other order would leave the LED lit + // with nothing able to turn it off. + const uint8_t led_sel_reg = pgm(this->chip_->led_sel_reg); + bool ok = true; + if (enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, true); + } + ok = this->update_bits_(led_reg, LED_ACT, enable) && ok; + if (!enable && led_sel_reg != 0) { + ok = this->update_bits_(led_sel_reg, CONFIG_LED_SEL, false) && ok; + } + // The measurement path assumes the high bank, so leave the chip there for a reading in flight + ok = this->select_bank_(REG_ENABLE) && ok; + if (!ok) { + ESP_LOGW(TAG, "Failed to switch the LED"); + } +} + void AS734XComponent::update() { if (this->state_ != State::STATE_IDLE) { ESP_LOGW(TAG, "Skipping update, previous measurement still running"); return; } + if (!this->write_exposure_()) { + this->abort_measurement_(LOG_STR("Failed to apply measurement settings")); + return; + } this->step_ = 0; + this->peak_raw_count_ = 0; + this->raw_counts_.fill(0); this->enable_loop(); if (!this->start_step_()) { this->abort_measurement_(LOG_STR("Failed to start measurement")); @@ -270,14 +384,18 @@ void AS734XComponent::read_step_() { const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); for (uint8_t band = 0; band < NUM_BANDS; band++) { const uint8_t slot = progmem_read_byte(&readout[band]); - if (slot == NO_SLOT || this->sensors_[band] == nullptr) { + if (slot == NO_SLOT) { continue; } - uint32_t counts = slot_value(slot); + const uint16_t raw = slot_value(slot); + this->peak_raw_count_ = std::max(this->peak_raw_count_, raw); + uint16_t counts = raw; if (band == CLEAR_BAND && extra_clear != NO_SLOT) { - counts = (counts + slot_value(extra_clear)) / 2; + const uint16_t extra = slot_value(extra_clear); + this->peak_raw_count_ = std::max(this->peak_raw_count_, extra); + counts = (static_cast(raw) + extra) / 2; } - this->sensors_[band]->publish_state(counts); + this->raw_counts_[band] = counts; } if (++this->step_ < pgm(this->chip_->smux_steps)) { if (!this->start_step_()) { @@ -285,11 +403,104 @@ void AS734XComponent::read_step_() { } return; } + this->publish_measurement_(); this->status_clear_warning(); this->state_ = State::STATE_IDLE; this->disable_loop(); } +// A basic count is the raw count scaled to one unit of gain and one millisecond of integration +// time, which makes readings taken with different settings comparable. The manufacturer's +// conversion factors are calibrated against that definition. +void AS734XComponent::calculate_basic_counts_() { + this->basic_counts_.fill(0.0f); + this->max_basic_count_ = 0.0f; + this->max_band_basic_count_ = 0.0f; + this->clear_basic_count_ = 0.0f; + + // The gain enum counts in powers of two starting at 0.5x, so the multiplier is 2^gain / 2. + const float gain_x = static_cast(1 << this->reading_gain_) / 2.0f; + const float integration_ms = (1.0f + this->reading_atime_) * (1.0f + this->reading_astep_) * 2.78e-3f; + if (gain_x <= 0.0f || integration_ms <= 0.0f) { + return; + } + const float inv_exposure = 1.0f / (gain_x * integration_ms); + const ChipInfo *chip = this->chip_; + const uint16_t *wavelengths = pgm(chip->wavelengths); + const float *gain_correction = pgm(chip->gain_correction); + const bool per_band = pgm(chip->gain_correction_per_band); + const uint8_t correction_base = per_band ? this->reading_gain_ * NUM_BANDS : this->reading_gain_; + + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const uint16_t wavelength = pgm(wavelengths[band]); + if (wavelength == ABSENT_NM) { + continue; + } + float basic_count = this->raw_counts_[band] * inv_exposure; + basic_count -= this->dark_current_[band]; + basic_count = std::max(basic_count, 0.0f); + basic_count *= pgm(gain_correction[per_band ? correction_base + band : correction_base]); + basic_count *= this->channel_correction_[band]; + basic_count *= this->glass_attenuation_factor_; + + this->basic_counts_[band] = basic_count; + this->max_basic_count_ = std::max(this->max_basic_count_, basic_count); + if (wavelength == WIDEBAND_NM) { + this->clear_basic_count_ = basic_count; + } else if (wavelength >= VISIBLE_MIN_NM && wavelength <= VISIBLE_MAX_NM) { + this->max_band_basic_count_ = std::max(this->max_band_basic_count_, basic_count); + } + } +} + +float AS734XComponent::normalization_divisor_() const { + float divisor = 1.0f; + switch (this->normalization_) { + case Normalization::NORMALIZATION_ALL: + divisor = this->max_basic_count_; + break; + case Normalization::NORMALIZATION_BANDS: + divisor = this->max_band_basic_count_; + break; + case Normalization::NORMALIZATION_CLEAR: + divisor = this->clear_basic_count_; + break; + case Normalization::NORMALIZATION_NONE: + return 1.0f; + } + // In the dark the reference can be zero, and dividing by that would publish infinities. + return divisor > 0.0f ? divisor : 1.0f; +} + +void AS734XComponent::publish_basic_counts_() { + // Normalization describes the shape of the spectrum rather than its absolute level. Channels + // outside the chosen reference can exceed 1.0 and are left that way rather than clipped: with + // BANDS the near infrared channel legitimately sits well above the visible bands. + const float scale = 1.0f / this->normalization_divisor_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->basic_counts_sensors_[band] != nullptr) { + this->basic_counts_sensors_[band]->publish_state(this->basic_counts_[band] * scale); + } + } +} + +void AS734XComponent::publish_measurement_() { + this->calculate_basic_counts_(); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + if (this->sensors_[band] != nullptr) { + this->sensors_[band]->publish_state(this->raw_counts_[band]); + } + } + this->publish_basic_counts_(); + if (this->saturation_level_sensor_ != nullptr) { + const uint32_t max_adc = + std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); + const float level = max_adc == 0 ? 0.0f : 100.0f * this->peak_raw_count_ / max_adc; + ESP_LOGV(TAG, "Highest ADC count %u of %u (%.1f%%)", this->peak_raw_count_, static_cast(max_adc), level); + this->saturation_level_sensor_->publish_state(level); + } +} + void AS734XComponent::abort_measurement_(const LogString *reason) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(reason)); this->write_byte(REG_ENABLE, ENABLE_PON); diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h index 184084ca2b7..f2b68d380ec 100644 --- a/esphome/components/as734x/as734x.h +++ b/esphome/components/as734x/as734x.h @@ -26,7 +26,26 @@ enum Gain : uint8_t { GAIN_2048X, // AS7343 only }; -static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py +static constexpr uint8_t GAIN_COUNT = 13; // entries in Gain, for sizing the correction tables +static constexpr uint8_t NUM_BANDS = 13; // band order of BANDS_43 in __init__.py + +// What the published basic counts are divided by. The clear channel is wideband, so it is roughly +// the size of all the bands together, and the near infrared channel outgrows them under daylight. +// Dividing by the largest of every channel therefore leaves the visible bands squeezed into the +// bottom of the range. +enum class Normalization : uint8_t { + NORMALIZATION_NONE, // publish absolute basic counts + NORMALIZATION_ALL, // largest of every channel + NORMALIZATION_BANDS, // largest of the visible bands only + NORMALIZATION_CLEAR, // the wideband clear channel +}; + +// Nominal centre wavelengths in nanometres bound the visible bands. The clear channel is wideband +// and has no single centre wavelength, so it is marked with zero instead. +static constexpr uint16_t VISIBLE_MIN_NM = 380; +static constexpr uint16_t VISIBLE_MAX_NM = 700; +static constexpr uint16_t WIDEBAND_NM = 0; +static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have struct RegValue { uint8_t reg; @@ -36,9 +55,11 @@ struct RegValue { /// Per-chip constants, stored in flash. The pointed-to tables are in flash too. struct ChipInfo { const char *name; - const RegValue *init; // written in order; the bank is selected per register address - const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images - const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band uint8_t init_count; uint8_t astep_reg; uint8_t cfg0_reg; @@ -47,12 +68,15 @@ struct ChipInfo { uint8_t id_reg; uint8_t id_mask; uint8_t id_value; - uint8_t adc_slots; // 16-bit data registers read after ASTATUS - uint8_t smux_steps; // measurements per update + uint8_t led_reg; // LED driver register (0: this chip has no LED control) + uint8_t led_sel_reg; // register whose LED_SEL bit hands the pin to the driver (0: not needed) + uint8_t adc_slots; // 16-bit data registers read after ASTATUS + uint8_t smux_steps; // measurements per update uint8_t integration_cycles; - uint8_t smux_len; // bytes per SMUX image - bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup - uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) + uint8_t smux_len; // bytes per SMUX image + bool auto_smux; // the chip cycles its SMUX itself after a one-time chain setup + bool gain_correction_per_band; // the correction table is indexed by gain and band, not gain alone + uint8_t extra_clear_slot; // averaged into the clear band (0xFF: none) }; #ifdef USE_AS7341 @@ -65,7 +89,9 @@ extern const ChipInfo TCS3448_CHIP; class AS734XComponent : public PollingComponent, public i2c::I2CDevice { public: - explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) {} + // Channel correction is multiplicative, so it starts at unity rather than zero in case no + // calibration is configured. + explicit AS734XComponent(const ChipInfo *chip) : chip_(chip) { this->channel_correction_.fill(1.0f); } void setup() override; void dump_config() override; @@ -75,21 +101,58 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void set_gain(Gain gain) { this->gain_ = gain; } void set_atime(uint8_t atime) { this->atime_ = atime; } void set_astep(uint16_t astep) { this->astep_ = astep; } + + /// Turns the sensor's LED driver on or off. Meant for lambdas. + void enable_led(bool enable); + + // Calibration applied while turning raw counts into basic counts. + void set_dark_current(const std::array &values) { this->dark_current_ = values; } + void set_channel_correction(const std::array &values) { this->channel_correction_ = values; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + /// Chooses what the published basic counts are divided by. + void set_basic_counts_normalization(Normalization normalization) { this->normalization_ = normalization; } + void set_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->sensors_[band] = sensor; } + void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } + + SUB_SENSOR(saturation_level) protected: bool select_bank_(uint8_t reg); + bool update_bits_(uint8_t reg, uint8_t mask, bool value); + bool write_exposure_(); bool start_step_(); void read_step_(); void abort_measurement_(const LogString *reason); + void calculate_basic_counts_(); + void publish_measurement_(); + void publish_basic_counts_(); + float normalization_divisor_() const; std::array sensors_{}; + std::array basic_counts_sensors_{}; + std::array dark_current_{}; + std::array channel_correction_; + std::array basic_counts_{}; + std::array raw_counts_{}; const ChipInfo *chip_; // in flash, read field by field with progmem_memcpy uint32_t start_ms_{0}; uint32_t integration_ms_{0}; uint32_t timeout_ms_{0}; + float glass_attenuation_factor_{1.0f}; + float max_basic_count_{0.0f}; // largest of every channel + float max_band_basic_count_{0.0f}; // largest of the visible bands only + float clear_basic_count_{0.0f}; // the wideband clear channel uint16_t astep_{0}; + uint16_t peak_raw_count_{0}; // highest ADC value of this measurement, before the clear channels are averaged + // What the running measurement was started with, so the maths matches the data even if the + // configuration is changed while a measurement is in flight. + uint16_t reading_astep_{0}; + Gain reading_gain_{GAIN_1X}; + uint8_t reading_atime_{0}; enum class State : uint8_t { STATE_IDLE, STATE_WAIT_SMUX, STATE_READ_DATA } state_{State::STATE_IDLE}; + Normalization normalization_{Normalization::NORMALIZATION_NONE}; Gain gain_{GAIN_1X}; uint8_t atime_{0}; uint8_t step_{0}; diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py index aa1487f8a97..726d15d6fff 100644 --- a/esphome/components/as734x/sensor.py +++ b/esphome/components/as734x/sensor.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv -from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT +from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT import esphome.final_validate as fv from esphome.types import ConfigType @@ -10,7 +10,11 @@ from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent DEPENDENCIES = [DOMAIN] +CONF_BASIC_COUNTS = "basic_counts" +CONF_SATURATION_LEVEL = "saturation_level" + ICON_COUNTS = "mdi:counter" +ICON_SATURATION = "mdi:weather-sunny-alert" _COUNTS_SENSOR = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, @@ -19,12 +23,31 @@ _COUNTS_SENSOR = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ) +# Basic counts are fractional, so they need decimals where raw counts do not. +_BASIC_COUNTS_SENSOR = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_COUNTS, + accuracy_decimals=6, + state_class=STATE_CLASS_MEASUREMENT, +) + +_BAND_KEYS = (CONF_COUNTS, CONF_BASIC_COUNTS) + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), cv.Optional(CONF_COUNTS, default={}): cv.Schema( {cv.Optional(band): _COUNTS_SENSOR for band in BANDS_43} ), + cv.Optional(CONF_BASIC_COUNTS, default={}): cv.Schema( + {cv.Optional(band): _BASIC_COUNTS_SENSOR for band in BANDS_43} + ), + cv.Optional(CONF_SATURATION_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_SATURATION, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ) @@ -32,12 +55,13 @@ CONFIG_SCHEMA = cv.Schema( def _final_validate(config: ConfigType) -> ConfigType: full = fv.full_config.get() hub = full.get_config_for_path(full.get_path_for_id(config[CONF_AS734X_ID])[:-1]) - if hub[CONF_TYPE] == "AS7341" and ( - extra := [band for band in config[CONF_COUNTS] if band not in BANDS_41] - ): - raise cv.Invalid( - f"The AS7341 has no {', '.join(extra)} band", path=[CONF_COUNTS, extra[0]] - ) + if hub[CONF_TYPE] != "AS7341": + return config + for key in _BAND_KEYS: + if extra := [band for band in config.get(key, {}) if band not in BANDS_41]: + raise cv.Invalid( + f"The AS7341 has no {', '.join(extra)} band", path=[key, extra[0]] + ) return config @@ -47,7 +71,14 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS734X_ID]) counts = config[CONF_COUNTS] + basic_counts = config[CONF_BASIC_COUNTS] for i, band in enumerate(BANDS_43): if (conf := counts.get(band)) is not None: sens = await sensor.new_sensor(conf) cg.add(hub.set_counts_sensor(sens, i)) + if (conf := basic_counts.get(band)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_basic_counts_sensor(sens, i)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) diff --git a/tests/component_tests/as734x/test_as734x.py b/tests/component_tests/as734x/test_as734x.py index 98ae9571693..d668523c816 100644 --- a/tests/component_tests/as734x/test_as734x.py +++ b/tests/component_tests/as734x/test_as734x.py @@ -92,3 +92,31 @@ def test_exposure_bound( return with pytest.raises(cv.Invalid, match=error): HUB_SCHEMA(config) + + +def test_as7343_basic_counts_keep_their_index(generate_main) -> None: + main_cpp = generate_main(CONFIG) + + for band in ("f1", "fz", "clear"): + assert ( + f"hub43->set_basic_counts_sensor(b43_{band}, {BAND_INDEX[band]});" + in main_cpp + ) + + +def test_as7341_calibration_spreads_over_the_band_slots(generate_main) -> None: + """The AS7341 has ten bands, but the C++ side indexes all thirteen slots. + + The three bands it does not have keep the neutral value, so a correction or dark + current given for F3 reaches slot 3 rather than slot 2. + """ + main_cpp = generate_main(CONFIG) + + assert ( + "hub41->set_channel_correction({1.1f, 1.2f, 1.0f, 1.3f, 1.4f, 1.0f, 1.5f, " + "1.0f, 1.6f, 1.7f, 1.8f, 1.9f, 2.0f});" in main_cpp + ) + assert ( + "hub41->set_dark_current({0.1f, 0.2f, 0.0f, 0.3f, 0.4f, 0.0f, 0.5f, " + "0.0f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f});" in main_cpp + ) diff --git a/tests/component_tests/as734x/test_as734x.yaml b/tests/component_tests/as734x/test_as734x.yaml index 0ae5ce2ce10..da014d32e6a 100644 --- a/tests/component_tests/as734x/test_as734x.yaml +++ b/tests/component_tests/as734x/test_as734x.yaml @@ -11,6 +11,10 @@ i2c: as734x: - id: hub41 type: AS7341 + normalize_basic_counts: ALL + calibration: + channel_correction: [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] + dark_current: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] - id: hub43 type: AS7343 address: 0x3A @@ -45,3 +49,8 @@ sensor: f8: {id: s43_f8, name: s43 f8} nir: {id: s43_nir, name: s43 nir} clear: {id: s43_clear, name: s43 clear} + basic_counts: + f1: {id: b43_f1, name: b43 f1} + fz: {id: b43_fz, name: b43 fz} + clear: {id: b43_clear, name: b43 clear} + saturation_level: {id: sat43, name: sat 43} diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml index 5fa94d7ef5b..0ad83bac4dc 100644 --- a/tests/components/as734x/common.yaml +++ b/tests/components/as734x/common.yaml @@ -6,15 +6,24 @@ as734x: gain: X8 atime: 120 astep: 99 + normalize_basic_counts: BANDS + calibration: + channel_correction: [1.0, 1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09] + dark_current: [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + glass_attenuation_factor: 1.2 - id: as7343_hub type: AS7343 i2c_id: i2c_bus address: 0x3A gain: X512 + normalize_basic_counts: CLEAR - id: tcs3448_hub type: TCS3448 i2c_id: i2c_bus gain: X2048 + calibration: + dark_current: + [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] sensor: - platform: as734x @@ -26,6 +35,13 @@ sensor: name: AS7341 NIR clear: name: AS7341 Clear + basic_counts: + f1: + name: AS7341 F1 basic + clear: + name: AS7341 Clear basic + saturation_level: + name: AS7341 Saturation - platform: as734x as734x_id: as7343_hub counts: @@ -35,6 +51,13 @@ sensor: name: AS7343 FXL clear: name: AS7343 Clear + basic_counts: + fz: + name: AS7343 FZ basic + fy: + name: AS7343 FY basic + saturation_level: + name: AS7343 Saturation - platform: as734x as734x_id: tcs3448_hub counts: From 699e3bd0377868a0de5514862ca2c1c12d3a09c0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:03:27 -0400 Subject: [PATCH 30/50] [usb_uart] Runtime modem pin control for USB and host (#20330) 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/serial_proxy/__init__.py | 4 + .../components/serial_proxy/serial_proxy.cpp | 29 ++++- .../components/serial_proxy/serial_proxy.h | 42 ++++++- esphome/components/uart/__init__.py | 7 ++ .../components/uart/uart_component_host.cpp | 30 +++++ esphome/components/uart/uart_component_host.h | 5 + esphome/components/usb_uart/ch34x.cpp | 17 ++- esphome/components/usb_uart/cp210x.cpp | 18 +++ esphome/components/usb_uart/ft23xx.cpp | 18 ++- esphome/components/usb_uart/pl2303.cpp | 8 +- esphome/components/usb_uart/usb_uart.cpp | 107 ++++++++++++++---- esphome/components/usb_uart/usb_uart.h | 44 +++++-- esphome/core/defines.h | 1 + tests/components/serial_proxy/test.host.yaml | 14 +++ 14 files changed, 302 insertions(+), 42 deletions(-) create mode 100644 tests/components/serial_proxy/test.host.yaml diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index a8767280504..d5baac3c9df 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -109,6 +109,10 @@ async def to_code(config: ConfigType) -> None: channel = await cg.get_variable(config[CONF_UART_ID]) cg.add(var.set_usb_channel(channel)) cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") + if uart.is_host_uart(config[CONF_UART_ID], CORE.config): + host_uart = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_host_uart(host_uart)) + cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 03d289225f2..6d3b389e5f6 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -18,6 +18,10 @@ #include "esphome/components/usb_uart/usb_uart.h" #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +#include "esphome/components/uart/uart_component_host.h" +#endif + namespace esphome::serial_proxy { ESPHOME_LOG_TAG(TAG, "serial_proxy"); @@ -379,6 +383,19 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + // The UART goes first, so a failure leaves every line as it was. A line with a GPIO pin keeps + // the UART's current state; with both on GPIO there is nothing to send + bool applied = true; + if (this->rts_pin_ == nullptr || this->dtr_pin_ == nullptr) { + this->with_modem_uart_([this, dtr, rts, &applied](auto *uart) { + applied = uart->set_modem_control(this->dtr_pin_ != nullptr ? uart->get_dtr() : dtr, + this->rts_pin_ != nullptr ? uart->get_rts() : rts); + }); + } + if (!applied) { + ESP_LOGW(TAG, "Modem pins did not reach the UART on serial proxy [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_ERROR; + } if (this->rts_pin_ != nullptr) { this->rts_state_ = rts; this->rts_pin_->digital_write(rts); @@ -455,8 +472,16 @@ void SerialProxy::on_usb_connection_changed_(bool connected) { #endif uint32_t SerialProxy::get_modem_pins() const { - return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + bool rts = this->rts_state_; + bool dtr = this->dtr_state_; + this->with_modem_uart_([this, &rts, &dtr](auto *uart) { + if (this->rts_pin_ == nullptr) + rts = uart->get_rts(); + if (this->dtr_pin_ == nullptr) + dtr = uart->get_dtr(); + }); + return (rts ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (dtr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index 2b8f933f889..3c7e92baa71 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -29,6 +29,12 @@ struct UsbDeviceInfo; } // namespace esphome::usb_host #endif +#ifdef USE_SERIAL_PROXY_HOST_UART +namespace esphome::uart { +class HostUartComponent; +} // namespace esphome::uart +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -162,10 +168,12 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values uint32_t get_modem_pins() const; - /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values + /// Get the modem pins this instance can drive as a bitmask of SerialProxyLineStateFlag values. + /// A line is driven by its GPIO pin when one is configured, otherwise by the UART if it can. uint32_t get_configured_modem_pins() const { - return (this->rts_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | - (this->dtr_pin_ != nullptr ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); + const bool uart_lines = this->with_modem_uart_([](auto *) {}); + return (this->rts_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_pin_ != nullptr || uart_lines ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } /// Flush the serial port (block until all TX data is sent) @@ -183,6 +191,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } #endif +#ifdef USE_SERIAL_PROXY_HOST_UART + /// Attach the host UART behind this port (from code generation) + void set_host_uart(uart::HostUartComponent *host_uart) { this->host_uart_ = host_uart; } +#endif + #ifdef USE_API /// Send this port's identity to one client void send_identity(api::APIConnection *api_connection); @@ -237,6 +250,24 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif + /// Call f with the UART behind this port if that UART drives the modem lines itself. + /// Returns false when it cannot. Typed pointers, so the calls are direct. + template bool with_modem_uart_(F &&f) const { +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + f(this->usb_channel_); + return true; + } +#endif +#ifdef USE_SERIAL_PROXY_HOST_UART + if (this->host_uart_ != nullptr) { + f(this->host_uart_); + return true; + } +#endif + return false; + } + /// Time the wire needs for the given number of bytes at the current framing uint32_t wire_time_ms_(size_t bytes) const; @@ -318,6 +349,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// The USB UART channel behind this port; nullptr on non-USB ports usb_uart::USBUartChannel *usb_channel_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_HOST_UART + /// The host UART behind this port; nullptr on other ports + uart::HostUartComponent *host_uart_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index d1ac806b5fd..1cf01f4b486 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -55,6 +55,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType from esphome.yaml_util import make_data_base _LOGGER = getLogger(__name__) @@ -125,6 +126,12 @@ MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True +def is_host_uart(uart_id: ID, full_config: ConfigType) -> bool: + return CORE.is_host and any( + conf[CONF_ID] == uart_id for conf in full_config.get(DOMAIN) or [] + ) + + def validate_raw_data(value): if isinstance(value, str): return value.encode("utf-8") diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 833ccc425d3..2cb9d497241 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -285,6 +285,36 @@ UARTFlushResult HostUartComponent::flush() { return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; } +bool HostUartComponent::set_modem_control(bool dtr, bool rts) { + if (this->file_descriptor_ == -1) { + return false; + } + // One TIOCMSET so the device never sees a state in between the old and the new one + int bits; + if (ioctl(this->file_descriptor_, TIOCMGET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + bits = (bits & ~(TIOCM_DTR | TIOCM_RTS)) | (dtr ? TIOCM_DTR : 0) | (rts ? TIOCM_RTS : 0); + if (ioctl(this->file_descriptor_, TIOCMSET, &bits) == -1) { + this->update_error_(strerror(errno)); + return false; + } + return true; +} + +bool HostUartComponent::get_dtr() const { return (this->get_modem_bits_() & TIOCM_DTR) != 0; } + +bool HostUartComponent::get_rts() const { return (this->get_modem_bits_() & TIOCM_RTS) != 0; } + +int HostUartComponent::get_modem_bits_() const { + int bits = 0; + if (this->file_descriptor_ != -1) { + ioctl(this->file_descriptor_, TIOCMGET, &bits); + } + return bits; +} + void HostUartComponent::update_error_(const std::string &error) { if (this->first_error_.empty()) { this->first_error_ = error; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index 27869e969c4..55e89d892a9 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -21,10 +21,15 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; + /// Drive DTR/RTS. Returns false when the port is closed or the ioctl fails. + bool set_modem_control(bool dtr, bool rts); + bool get_dtr() const; + bool get_rts() const; void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); + int get_modem_bits_() const; void check_logger_conflict() override {} std::string port_name_; std::string first_error_; diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index dce07a64a53..ad4e6a2140b 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -10,6 +10,11 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t CH341_REQ_MODEM_CTRL = 0xA4; +// Modem control bits, sent inverted +static constexpr uint8_t CH341_BIT_DTR = 1 << 5; +static constexpr uint8_t CH341_BIT_RTS = 1 << 6; + struct CH34xEntry { const char *name; uint16_t pid; @@ -145,13 +150,23 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo return true; } case 1: - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeCH34X::modem_control_transfer(USBUartChannelBase *channel) { + uint8_t request = CH341_REQ_MODEM_CTRL + channel->index_; + if (channel->index_ >= 2) + request += 0xE; + const uint8_t control = (channel->dtr_ ? CH341_BIT_DTR : 0) | (channel->rts_ ? CH341_BIT_RTS : 0); + ESP_LOGD(TAG, "MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + // Integer promotion makes this 16 bits wide, as in Linux: 0xFF9F with both lines on + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, request, static_cast(~control), 0); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 34b76f88541..0629e8f77ad 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -43,6 +43,12 @@ static constexpr uint8_t SET_BAUDRATE = 0x1E; // Set the baud rate. static constexpr uint8_t SET_CHARS = 0x19; // Set special characters. static constexpr uint8_t VENDOR_SPECIFIC = 0xFF; // Vendor specific command. +// SET_MHS value bits. The high byte selects which lines the low byte changes. +static constexpr uint16_t CONTROL_DTR = 0x0001; +static constexpr uint16_t CONTROL_RTS = 0x0002; +static constexpr uint16_t CONTROL_WRITE_DTR = 0x0100; +static constexpr uint16_t CONTROL_WRITE_RTS = 0x0200; + std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev_hdl) { const usb_config_desc_t *config_desc; const usb_device_desc_t *device_desc; @@ -119,10 +125,22 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); return true; } + case 3: // Assert DTR+RTS (init only) + if (reload) + return false; + this->modem_control_transfer(channel); + return true; default: return false; } } + +void USBUartTypeCP210X::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + CONTROL_WRITE_DTR | CONTROL_WRITE_RTS | (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "SET_MHS: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_MHS, value, channel->index_); +} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index 805a4adfce3..45acdb08f90 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -13,6 +13,13 @@ namespace esphome::usb_uart { using namespace bytebuffer; +static constexpr uint8_t SIO_SET_MODEM_CTRL = 0x01; +// The high byte selects which lines the low byte changes +static constexpr uint16_t SIO_SET_DTR_MASK = 0x0100; +static constexpr uint16_t SIO_SET_RTS_MASK = 0x0200; +static constexpr uint16_t SIO_DTR_HIGH = 0x0001; +static constexpr uint16_t SIO_RTS_HIGH = 0x0002; + // FTDI chip family identifiers. These map to USB device bcdDevice values // and determine how baudrate divisors and clock sources are calculated. enum FtdiChipType { @@ -373,14 +380,21 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b case 3: // set modem control DTR+RTS (init only) if (reload) return false; - this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeFT23XX::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = + SIO_SET_DTR_MASK | SIO_SET_RTS_MASK | (channel->dtr_ ? SIO_DTR_HIGH : 0) | (channel->rts_ ? SIO_RTS_HIGH : 0); + ESP_LOGD(TAG, "SIO_SET_MODEM_CTRL: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, SIO_SET_MODEM_CTRL, value, + channel->cdc_dev_.bulk_interface_number + 1); +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 0430b1eeacb..89bcaa6dad3 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -282,13 +282,19 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR + RTS (init only) if (reload) return false; - this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypePL2303::modem_control_transfer(USBUartChannelBase *channel) { + const uint16_t value = (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0); + ESP_LOGD(TAG, "PL2303: SET_CONTROL_REQUEST DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, value, channel->cdc_dev_.bulk_interface_number); +} + } // namespace esphome::usb_uart #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/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 744b79f4e43..4c48aa1fd5b 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -499,16 +499,17 @@ void USBUartTypeCdcAcm::on_disconnected() { channel->initialised_.store(false); // The descriptors these point into are freed with the device channel->cdc_dev_ = {}; + channel->dtr_ = true; + channel->rts_ = true; } USBClient::on_disconnected(); } +static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; + bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) { - static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; - static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; - static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS switch (step) { case 0: { @@ -537,38 +538,61 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // Assert DTR+RTS to signal DTE is present (init only). if (reload) return false; - this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number); + this->modem_control_transfer(channel); return true; default: return false; } } +void USBUartTypeCdcAcm::modem_control_transfer(USBUartChannelBase *channel) { + static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; + static constexpr uint16_t CDC_CONTROL_LINE_DTR = 1 << 0; + static constexpr uint16_t CDC_CONTROL_LINE_RTS = 1 << 1; + const uint16_t value = (channel->dtr_ ? CDC_CONTROL_LINE_DTR : 0) | (channel->rts_ ? CDC_CONTROL_LINE_RTS : 0); + ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_)); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, value, + channel->cdc_dev_.interrupt_interface_number); +} + void USBUartComponent::enable_channels() { this->cfg_single_ = nullptr; - this->cfg_pending_reload_ = nullptr; + // Init sends every channel's current line settings and DTR/RTS + for (auto *channel : this->channels_) { + channel->reload_pending_ = false; + channel->modem_pending_ = false; + } this->cfg_channel_idx_ = 0; - this->start_config_(false); + this->start_config_(ConfigMode::CONFIG_MODE_INIT); } void USBUartComponent::apply_channel_settings(USBUartChannelBase *channel) { if (this->cfg_active_) { // A config sequence is already running. Defer this reload until it finishes to preserve // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an - // in-flight callback complete against fresh state). The pending slot coalesces multiple + // in-flight callback complete against fresh state). The per-channel flag coalesces multiple // requests; the channel's live settings are read when the reload eventually runs. - // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. - this->cfg_pending_reload_ = channel; + channel->reload_pending_ = true; return; } this->cfg_single_ = channel; - this->start_config_(true); + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); } -void USBUartComponent::start_config_(bool reload) { - this->cfg_reload_ = reload; - this->cfg_device_phase_ = !reload; +void USBUartComponent::apply_modem_control(USBUartChannelBase *channel) { + if (this->cfg_active_) { + // Deferred like a reload. The transfer sends whatever dtr_/rts_ hold when it runs, so a + // channel that is set several times before then only sends its latest state. + channel->modem_pending_ = true; + return; + } + this->cfg_single_ = channel; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); +} + +void USBUartComponent::start_config_(ConfigMode mode) { + this->cfg_mode_ = mode; + this->cfg_device_phase_ = mode == ConfigMode::CONFIG_MODE_INIT; this->cfg_step_ = 0; this->cfg_ok_ = true; this->cfg_in_flight_ = false; @@ -621,8 +645,9 @@ bool USBUartComponent::run_config_machine_() { // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing // cfg_done_ with acquire ordering), so it is safe to read. - ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, - this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + const bool init = this->cfg_mode_ == ConfigMode::CONFIG_MODE_INIT; + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d mode=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, static_cast(this->cfg_mode_), this->cfg_ok_); // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. if (this->cfg_device_phase_) { @@ -644,17 +669,24 @@ bool USBUartComponent::run_config_machine_() { if (!this->cfg_ok_) { // A previous step in this channel's sequence failed. Abort the rest. On a full init, // mark the channel uninitialised so data flow isn't started on a misconfigured channel; - // on a reload, leave the already-working channel as it was. - if (!this->cfg_reload_) + // otherwise, leave the already-working channel as it was. + if (init) channel->initialised_.store(false); - } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + } else if (this->cfg_mode_ == ConfigMode::CONFIG_MODE_MODEM) { + // A single transfer, so only the first step issues one + if (this->cfg_step_ == 0) { + this->modem_control_transfer(channel); + this->cfg_in_flight_ = true; + return true; + } + } else if (this->config_step(channel, this->cfg_step_, !init, this->cfg_ok_, this->cfg_response_)) { this->cfg_in_flight_ = true; return true; } } // Channel finished (or aborted). On full init, kick off data flow if still initialised. - if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { + if (channel != nullptr && init && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); @@ -672,11 +704,24 @@ bool USBUartComponent::run_config_machine_() { this->report_connected_(); } - // If the machine just went idle and a reload was requested while it was busy, start it now. - if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { - this->cfg_single_ = this->cfg_pending_reload_; - this->cfg_pending_reload_ = nullptr; - this->start_config_(true); + // If the machine just went idle and a run was requested while it was busy, start it now. + // A channel's pending modem change waits for its pending reload: the reload's init-only + // steps are skipped, so it cannot undo the modem lines. + if (!this->cfg_active_) { + for (auto *pending : this->channels_) { + if (pending->reload_pending_) { + pending->reload_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_RELOAD); + break; + } + if (pending->modem_pending_) { + pending->modem_pending_ = false; + this->cfg_single_ = pending; + this->start_config_(ConfigMode::CONFIG_MODE_MODEM); + break; + } + } } return true; } @@ -712,6 +757,18 @@ void USBUartChannelBase::load_settings(bool /*dump_config*/) { this->parent_->apply_channel_settings(this); } +bool USBUartChannelBase::set_modem_control(bool dtr, bool rts) { + if (!this->initialised_.load()) + return false; + const bool changed = dtr != this->dtr_ || rts != this->rts_; + this->dtr_ = dtr; + this->rts_ = rts; + // Nothing to send when the request matches the previous one + if (changed) + this->parent_->apply_modem_control(this); + return true; +} + } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index ba073404e2f..56525f29159 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -104,6 +104,13 @@ static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE // Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; +/// What the config state machine is applying +enum class ConfigMode : uint8_t { + CONFIG_MODE_INIT, ///< Full initialisation of every channel after the device connected + CONFIG_MODE_RELOAD, ///< Re-apply the line settings of one already-open channel + CONFIG_MODE_MODEM, ///< Apply the DTR/RTS state of one already-open channel +}; + class RingBuffer { public: RingBuffer(uint16_t buffer_size) : buffer_size_(buffer_size), buffer_(new uint8_t[buffer_size]) {} @@ -200,6 +207,11 @@ class USBUartChannelBase : public uart::UARTComponent, public Parenteddtr_; } + bool get_rts() const { return this->rts_; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -225,6 +237,12 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented &data = {}); - // (Re)start the config state machine. reload=false runs full init over all channels; - // reload=true re-applies settings to cfg_single_ only. - void start_config_(bool reload); + // (Re)start the config state machine. INIT runs over all channels; the other modes act + // on cfg_single_ only. + void start_config_(ConfigMode mode); // Advance the config state machine; called from loop(). Returns true if it did work. bool run_config_machine_(); @@ -285,6 +306,9 @@ class USBUartComponent : public usb_host::USBClient { // Optional one-time device-level setup run before the per-channel phase on init only // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // Issue the one control transfer, via config_transfer_(), that puts the channel's dtr_ + // and rts_ on the wire. + virtual void modem_control_transfer(USBUartChannelBase *channel) = 0; // The device is only usable once the config machine has applied every channel's line // settings, so the connected report waits for run_config_machine_() to finish the init @@ -293,14 +317,13 @@ class USBUartComponent : public usb_host::USBClient { std::vector channels_{}; // Config state machine - USBUartChannelBase *cfg_single_{nullptr}; // non-null: reload of a single channel - USBUartChannelBase *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy - std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads - uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + USBUartChannelBase *cfg_single_{nullptr}; // non-null: single-channel run (RELOAD or MODEM) + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; + ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT}; bool cfg_active_{false}; - bool cfg_reload_{false}; bool cfg_device_phase_{false}; bool cfg_in_flight_{false}; bool cfg_ok_{true}; @@ -316,6 +339,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; // Each claimed interface pins one host hardware channel per endpoint; skipping // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). bool claim_comm_interface_{true}; @@ -328,6 +352,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -341,6 +366,7 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; private: @@ -359,6 +385,7 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; uint8_t chip_type_{255}; }; @@ -382,6 +409,7 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + void modem_control_transfer(USBUartChannelBase *channel) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c8a128e76cb..607bc7a42b4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -719,6 +719,7 @@ #define USE_ESPHOME_HOST_MAC_ADDRESS \ { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE +#define USE_SERIAL_PROXY_HOST_UART // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART #define USE_SOCKET_IMPL_BSD_SOCKETS diff --git a/tests/components/serial_proxy/test.host.yaml b/tests/components/serial_proxy/test.host.yaml new file mode 100644 index 00000000000..c186d71b9b5 --- /dev/null +++ b/tests/components/serial_proxy/test.host.yaml @@ -0,0 +1,14 @@ +# The host UART drives DTR/RTS itself, which exercises the host modem control path +network: + +api: + +uart: + - id: uart_bus + port: "/dev/ttyS0" + baud_rate: 9600 + +serial_proxy: + - id: serial_proxy_1 + name: Test Serial Port + port_type: RS232 From 27c6b7caf5d17c3361619e0047ea1651f297456c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 11:36:36 -1000 Subject: [PATCH 31/50] [ci] Add hardware-test agent skill (#20817) --- .agents/skills/hardware-test/SKILL.md | 123 ++++++++++++++ .../skills/hardware-test/reference/boards.md | 138 +++++++++++++++ .../skills/hardware-test/reference/recipes.md | 117 +++++++++++++ .../hardware-test/scripts/flash_bk72xx.py | 51 ++++++ .../scripts/flash_rtl87xx_kit.py | 87 ++++++++++ .agents/skills/hardware-test/scripts/ports.py | 123 ++++++++++++++ .../hardware-test/scripts/serial_capture.py | 157 ++++++++++++++++++ .../hardware-test/scripts/wifisave_hammer.sh | 133 +++++++++++++++ .../templates/client-wifisave.yaml | 77 +++++++++ .../templates/dut-fallback-ap.yaml | 31 ++++ .../templates/example-client.yaml | 18 ++ .../hardware-test/templates/example-dut.yaml | 16 ++ 12 files changed, 1071 insertions(+) create mode 100644 .agents/skills/hardware-test/SKILL.md create mode 100644 .agents/skills/hardware-test/reference/boards.md create mode 100644 .agents/skills/hardware-test/reference/recipes.md create mode 100644 .agents/skills/hardware-test/scripts/flash_bk72xx.py create mode 100644 .agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py create mode 100644 .agents/skills/hardware-test/scripts/ports.py create mode 100644 .agents/skills/hardware-test/scripts/serial_capture.py create mode 100755 .agents/skills/hardware-test/scripts/wifisave_hammer.sh create mode 100644 .agents/skills/hardware-test/templates/client-wifisave.yaml create mode 100644 .agents/skills/hardware-test/templates/dut-fallback-ap.yaml create mode 100644 .agents/skills/hardware-test/templates/example-client.yaml create mode 100644 .agents/skills/hardware-test/templates/example-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md new file mode 100644 index 00000000000..8ad7cd5d847 --- /dev/null +++ b/.agents/skills/hardware-test/SKILL.md @@ -0,0 +1,123 @@ +--- +name: hardware-test +description: Test an ESPHome change on real boards over USB. Use this whenever a task asks to test, verify, reproduce, bench, A/B or soak a pull request, a branch or a crash on hardware, or mentions flashing, serial logs, esphome upload, a captive portal or fallback AP test, or results to post on a PR. Covers finding the serial ports, building from the right checkout, flashing each chip family (including the LibreTiny kits that stock tools cannot flash), bounded log capture, proven test flows and how to report results. +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# Hardware testing + +The goal of a hardware test is evidence a reviewer can trust: which build ran on which board, what was +driven, what the logs show, and the same flow on a `dev` build when a quirk could be pre-existing. +`$SKILL` below is this directory (`.agents/skills/hardware-test`). + +## Ground rules + +1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that + port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py "`, + naming the port so a capture on another board survives; the bracket keeps the pattern from matching + the shell that runs it. Never run `esphome logs` in a + `while true` loop: the loop respawns it and holds the port. +2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with + `!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs. +3. **Build from the tree under test.** `python -m esphome` puts the current directory first on + `sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by + its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the + checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep + the build's `src/` for the change before trusting any number. +4. **PR build and baseline build get different `name:` values.** Builds share + `.esphome/build/`, so the same name means `esphome upload` can flash the wrong binary. Saved + Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh + name every run. +5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD` + checked out in a throwaway worktree), with the baseline commit named in the table. When a delta + looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it. +6. **Prove a quirk is pre-existing before blaming the PR:** run the identical flow on a `dev` build in + the same session and say so in the results. +7. **Pipe safely.** Set `PYTHONUNBUFFERED=1` on any `esphome run` or `esphome logs` whose output is + piped, or lines land minutes late. Bound every long command with `timeout` (coreutils) or + `perl -e 'alarm shift; exec @ARGV' ` on macOS without it. + +## Workflow + +1. **Find the boards:** `python $SKILL/scripts/ports.py` (any Python with pyserial, for example the + ESPHome venv). Port names follow the USB hub port and change on every replug, so never reuse a name + from an earlier session. CH340 adapters (D1 mini, BW15 kit, BK7238, CB3S) all look the same; tell + them apart with a 10 s capture using `serial_capture.py --reset` (ESP8266 prints its ROM banner at + 74880 baud, LibreTiny boards print `[LT]` and ESPHome lines, BK7238 prints its bootloader version). + `ports.py --wait 2e8a` blocks until a Pico re-enumerates. +2. **Make a scratch directory** outside the checkout, with `secrets.yaml` and the templates you need. + One yaml per board per variant, file name equal to `name:`. +3. **Compile** in the right tree: + `(cd && PYTHONUNBUFFERED=1 timeout 1500 python -m esphome compile > -compile.log 2>&1)`. + Keep the RAM and flash lines at the end. Two ESP-IDF or two LibreTiny builds at the same time race + over tool caches; run them one at a time. +4. **Flash** per chip family (table below). Stop the capture on that port first. +5. **Capture** with `serial_capture.py PORT SECONDS OUT` in the background, then poll the file with + `grep` in an `until ... sleep 2` loop with a cap. USB CDC boards (Pico, nRF52, native USB ESP32) + drop the port on reboot; the script reopens it until its deadline. +6. **Drive** the test: a second board running a client config, `curl`, an aioesphomeapi script that + presses a template button and subscribes to logs, MQTT publishes, or `interval:` and `on_boot:` + automations in the config itself. +7. **Analyze** with `grep -a -c` counts over the capture and keep an `-events.txt` extract of the lines + that matter. Decode crashes with the platform's `addr2line` (see `reference/boards.md`). +8. **Report** on the PR as a table: build SHA, board and framework, what was driven, counts, result, + plus the `dev` control row and the exact flow in a `
` block. No credentials, no port names, + no IP addresses. + +## Flashing by chip family + +| Board | Flash | Log | +|---|---|---| +| ESP8266 D1 mini (CH340) | `python -m esphome upload --device ` | `serial_capture.py` at 115200; `esphome logs` on some CH340s emits garbage after the RTS reset. A failed esptool write can wedge the adapter until the board is replugged. `esptool erase_flash` before a test that needs an unprovisioned device: the preference sector survives a reflash | +| ESP32 devkit (CP2102 or CH340) | `esphome upload --device `, ESP-IDF or Arduino | `serial_capture.py`. Default Wi-Fi light sleep delays API driven actions by seconds; judge timing from device logs. `power_save_mode: none` for OTA overlap tests | +| ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device .local` | +| Pico W / Pico 2 W | `esphome upload --device ` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture | +| BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d /.pioenvs//*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` | +| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test | +| LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash | +| nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second | +| Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port | +| Any reachable device | `esphome upload --device `; `--file ` for a prebuilt image | `esphome logs --device ` bounded with `timeout` | + +ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA port is 8892. + +## Scripts in `$SKILL/scripts` + +- `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`, + `--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and + `--serial-of PORT` (the serial number behind a path). +- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped + capture, ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the + deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. +- `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot + strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. +- `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: + the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the + templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), + `RESULT FAIL` (exit 3: a crash, a reboot after the AP came up, or the device never joined the network), + `RESULT INVALID` + (exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run) + or exits 1 when a build, flash or port step failed. + +## Templates in `$SKILL/templates` + +- `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and + serves the captive portal. `example-dut.yaml` is the per-run file with the platform block. +- `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real + credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a + plain provisioning check. + +## When something looks wrong + +- A capture stopped early or a line is missing: check the timestamps before trusting it. +- The client never joins the AP: another board may still run an AP with the same SSID, or the device + booted with saved credentials because the name was reused. One AP device at a time on the bench. +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). +- Multicast from the host never arrives on some access points: test it device to device. Broadcast + is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate. +- `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for + soak tests. + +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven +flows. Add new findings there. diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md new file mode 100644 index 00000000000..fd2cd448785 --- /dev/null +++ b/.agents/skills/hardware-test/reference/boards.md @@ -0,0 +1,138 @@ +# Board facts + +Ports are not listed on purpose: they follow the USB hub port and change on every replug. Run +`scripts/ports.py`. + +## ESP8266 (D1 mini, NodeMCU, CH340 or CP2102) + +- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset` + pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`). +- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset; + a plain pyserial capture at 115200 is reliable. +- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug. +- The preference sector survives a reflash: a provisioned API key or saved Wi-Fi credentials stay + active. `esptool erase_flash` gives a clean device. +- `Panic core_esp8266_main.cpp:191 __yield` with `ctx: sys` means a yield from lwIP context (a web + handler, an lwIP callback). Decode stacks with + `xtensa-lx106-elf-addr2line -e /.pioenvs//firmware.elf -f -C -i ` from the + toolchain under `~/.platformio/packages/toolchain-xtensa/bin/` (PlatformIO builds) or the ESPHome + cache directory (native toolchain builds). +- Constant data lives in RAM unless marked `PROGMEM`; with 80 KB of RAM a few hundred bytes are worth a + row in the PR. +- AsyncMqttClient drops inbound topics over 128 characters; topics of 255 or more wrap a counter and + stall the link until the broker drops TCP. + +## ESP32 classic devkit + +- Takes ESP-IDF and Arduino. Default Wi-Fi light sleep delays API driven actions by up to 6 s; judge + timings from device logs, not from the host. `power_save_mode: none` for OTA overlap tests (an IDF web + upload of 739 KB took 107 s with power save and 45 s without). +- Receives only a few percent of IPv6 multicast on some access points with power save on; a raw lwIP + socket loses the same, so that is the AP or driver, not a PR. +- Decode with `xtensa-esp32-elf-addr2line` from the IDF tools directory against + `/.pioenvs//firmware.elf` (PlatformIO) or `/build/.elf` (IDF native). +- Clean reboot between runs with the capture stopped: `esptool --after hard_reset chip_id`. +- Trigger a forced crash only after the app is marked valid (45 s after boot is plenty), never on an + image still pending OTA rollback. +- Signed OTA testing: build with `esp32.framework.advanced.signed_ota_verification.verification_keys` + under a different name, sign with `python -m espsecure sign_data --version 2 --keyfile + tests/components/esp32/dummy_signing_key.pem`, upload with `esphome upload --file`. The verifier + refuses unsigned images afterwards, so restore with a signed plain build. + +## ESP32-S3, C3, C6, H2 with native USB + +- `/dev/cu.usbmodem*`, esptool works, `logger: hardware_uart: USB_SERIAL_JTAG`. +- The USB console can die at the moment the app takes over the console during a reboot (log stops after + `Disabling RNG early entropy source`, then 0 bytes, esptool reports `No serial data received`). Only a + USB replug fixes it. Fallback: `esphome logs --device .local` (API log stream, which drops lines + while Wi-Fi is off). +- The ESP32-P4 before silicon revision 3 is bricked by rev3-only firmware (boot loop at bootloader entry). + +## RP2040 / RP2350 (Pico W, Pico 2 W) + +- The CDC port re-enumerates after every reboot and after `esphome upload` (1200 baud touch). Re-detect + with `ports.py --wait 2e8a` before capturing; `serial_capture.py` reopens on its own if the port + vanishes mid-capture. +- First flash ever: BOOTSEL, then `esphome upload --device /Volumes/RPI-RP2` (or `RP2350`). +- arduino-pico creates the lwIP netif in `WiFi.begin()`, so any IGMP join during component setup fails; + joins must wait for `network::is_connected()`. +- Parallel Pico builds race on package extraction after a PlatformIO package wipe; one at a time. +- An RTS pulse from pyserial does not reset the board; reboot with `App.safe_reboot()` or the touch. + +## BW15 kit (RTL8720CF, LibreTiny rtl87xx) + +- The kit's CH340 wires RTS to CEN and DTR to PA00 (boot strap). A plain pyserial open asserts both and + holds the chip in reset, which is why `esphome upload`, plain `ltchiptool` and `esphome logs` all fail + ("Timeout while linking", or silence). `scripts/flash_rtl87xx_kit.py` sets DTR high and RTS released + on every open, pulses RTS once so the ROM answers `ping`, and after the command drops DTR and pulses + RTS to boot the app. Run it with the PlatformIO LibreTiny penv Python. +- `flash write -d ` takes no family argument; `flash read -d -s 0 -l + realtek-ambz2 ` does. +- Capture with `serial_capture.py --released`. The ESPHome log is on the flashing UART already. +- The macOS CH340 driver only pushes DTR/RTS to the wire once a session has driven them, so the first + plain open after a replug streams logs and the second is silent; always open with the lines released. +- Driver quirks seen on `dev`: `WiFi.begin` can refuse the real network on the first try after bogus + credentials (it connects after two cooldowns); right after connecting while the AP is still up the + driver can bus-fault in `rtw_is_adapter_up` (`R0 = 0xdeadbeef`) and reboot; the AP restarts on every + STA retry, so stations cannot stay joined; `WiFi.begin` fails synchronously for an absent network and + lands in a cooldown at once; connecting blocks the loop 2.5 to 4.1 s. +- Decode faults with `arm-none-eabi-addr2line -e /.pioenvs//raw_firmware.elf -f -C -i ` + from `~/.platformio/packages/toolchain-gccarmnoneeabi/bin/` (`raw_firmware.elf`, not `firmware.elf`). + Fault dumps print PC, LR and R0 to R3. +- The AmebaZ2 SDK sets `LWIP_TCPIP_CORE_LOCKING 0`. + +## BK7238, WB3S, CB3S (BK7231N) (LibreTiny bk72xx, CH340, RTS on CEN) + +- Stock ltchiptool closes and reopens the port after its reset, and pyserial raises both lines on open, + holding CEN low through the BootROM window. `scripts/flash_bk72xx.py` opens with both released and + pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset + reaches the downloader; no software reboot does. +- ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. +- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one + capture open for the whole test and never reopen mid-phase. +- Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms; + radio enable 160 to 278 ms, disable about 200 ms. + +## BW16 kit (RTL8720DN, AmebaD) + +- The kit's own USB-C (CH340) is on LOG_UART and flashes fine. Its two-transistor circuit resets while + RTS alone is asserted and straps BURN while DTR alone is; both asserted does nothing. ROM entry is + esptool's classic sequence (`dtr:false,rts:true` 100 ms, `dtr:true,rts:false` 400 ms, both false); the + first pulse after a replug can be lost, retry up to three times. +- Any reopen resets the chip, 460800 is flaky, 115200 is solid: one open per session, in-place baud + changes only. The ROM and the loader NAK continuously while idle, so ACK scans must drain first. +- The AmebaD core has no mDNS responder (`mdns: disabled: true`). + +## LN882H + +- ltchiptool forces 1 Mbaud for writes; adapters that cannot hold it fail with "Timeout while linking" + or die mid-write. Patch `LN882H_YM_BAUDRATE` in the penv's `ltchiptool/soc/ln882h/util/ln882htool.py` + to 115200 (lost on reprovision). Weak 3.3 V from the adapter: feed the module's 5 V pad instead. Short + BOOT to GND while the tool connects. OTA afterwards. +- `USE_WIFI_AP_EXCLUSIVE` (one radio mode at a time) exists only on LN882x; compile-check changes there. + +## nRF52840 + +- Nordic PCA10059 dongle: the factory Open DFU bootloader enumerates as `1915:521F` on its own port + name; the app as `2FE3:0100` with two CDC ports, ESPHome log on the second. Only Nordic + `nrfutil nrf5sdk-tools` works (`pkg generate --hw-version 52 --sd-req 0x00 --application-version 1 + --application zephyr.hex app.zip`, then `dfu usb-serial -pkg app.zip -p `); ESPHome downloads + it on the first build. GPREGRET does not enter the bootloader; a pin reset through P0.19 does, which + `dfu: true` wires up on this board. A pulsing red LED means bootloader; the side RESET button + re-enters it. +- Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume, + adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24), + button P0.18. + +## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) + +- S3 host on USB JTAG serial, H2 RCP on its own USB. Spinel UART1 from the host: `rx_pin: GPIO17`, + `tx_pin: GPIO18`, RCP reset `GPIO7` active low, 460800. The config needs `network: enable_ipv6: true`. +- The shipped H2 firmware fails `CheckRadioCapabilities` with IDF 5.5; build `examples/openthread/ot_rcp` + for esp32h2 with ESPHome's own IDF (`IDF_PATH` at the cached framework, its penv and tools, + `IDF_PYTHON_CHECK_CONSTRAINTS=no`) and flash with esptool at 0x0, 0x8000 and 0x10000. +- A crash-looping S3 pulses the H2 reset every boot, so the H2 cannot be flashed and the S3 will not + enter its bootloader over USB. Hold S3 BOOT, tap S3 RST, flash the H2, release, then replug both. +- `dns-sd -L _meshcop._udp local.` shows the `omr` prefix; it disappears when routing is dead. + Keep one capture per chip; never reset the H2 over USB mid-test unless that is the test (the host + logs `Unexpected RCP reset: RESET_OTHER` and recovers). diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md new file mode 100644 index 00000000000..32944ce75c4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes.md @@ -0,0 +1,117 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +## Captive portal `/wifisave` hammer (#20802, #20784) + +Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. +Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, +`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the +device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown +is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on +ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). + +Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash +markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. +A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters +the adapter restart), no panic, and the real credentials connect the device within about 10 s. + +Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in +`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag +(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive +portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection +refused proves the portal's server is gone. + +## `reboot_timeout` with a fallback AP (#9958) + +Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, +`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and +ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: +`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles +are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot +with no station joined, no reboot for minutes while one is, then a reboot within a second of the last +disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. + +## MQTT (#20433, #17935, #20762, #20767, #20768) + +A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives +each subscription's QoS, the device address (`New client connected from as -`) and exact +subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. +Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right +after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a +leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. +`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a +paho script publishes and polls the serial capture file. + +## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) + +A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device +multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock +listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate +(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi +reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` +(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action +rejects `%.*s`; use a lambda. + +## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) + +All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release +after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A +refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. +`pkill -f "a\|b"` does not alternate; use two pkills. + +## DHCP lease cache (#17767) + +Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the +`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep +sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use +`upload` then `logs`. + +## HDMI-CEC two-board wire (#9189) + +Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` +filter on the reply handler, template buttons for each target plus lambdas calling +`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, +round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep +lags the press). + +## LD2450 polygon zones (#20154) + +To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and +switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. +Writing those numbers blocks the ld2450 loop about 50 ms. + +## Scheduler and loop wake A/B (#20356) + +An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; +the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout +logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus +`button_command`). Build the same yaml twice with different names, one per tree. + +## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) + +Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over +two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with +`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. + +## ESP-NOW on the ESP8266 (#14407) + +The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is +up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only +mode the device transmits from the soft-AP MAC. + +## Wi-Fi Easy Connect (DPP) enrollee (#17934) + +Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's +QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. +`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the +supplicant state machine. + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. + +## nRF52840 dongle boards (#19009, #20814) + +Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a +10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/scripts/flash_bk72xx.py b/.agents/skills/hardware-test/scripts/flash_bk72xx.py new file mode 100644 index 00000000000..1f63179325a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_bk72xx.py @@ -0,0 +1,51 @@ +"""ltchiptool for boards whose RTS drives CEN: reset and link on one open port. + +Stock ltchiptool closes and reopens the port after its DTR/RTS reset, and +pyserial raises both lines on open, holding CEN low through the BootROM's +listen window. Open with both lines released and pulse the reset right +before the LinkChecks instead. +""" + +import sys + +import bk7231tools.serial.main as bk_main +from ltchiptool.__main__ import cli +from ltchiptool.soc.bk72xx.flash import BK72XXFlash +from serial import Serial + + +class ReleasedSerial(Serial): + def __init__(self, port=None, **kwargs): + super().__init__(None, **kwargs) + self.dtr = False + self.rts = False + self.port = port + self.open() + + +bk_main.Serial = ReleasedSerial + +_connect = BK72XXFlash.flash_connect + + +def flash_hw_reset(self) -> None: + self._reset_pending = True + + +def flash_connect(self) -> None: + pending = getattr(self, "_reset_pending", False) + if self.bk and self.conn.linked and not pending: + return + self.flash_build_protocol() + if pending: + self._reset_pending = False + self.bk.hw_reset() + self.conn.linked = False # the chip rebooted, so link again below + _connect(self) + + +BK72XXFlash.flash_hw_reset = flash_hw_reset +BK72XXFlash.flash_connect = flash_connect + +sys.argv = ["ltchiptool", *sys.argv[1:]] +cli() diff --git a/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py new file mode 100644 index 00000000000..55752ec0bb0 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/flash_rtl87xx_kit.py @@ -0,0 +1,87 @@ +"""ltchiptool for the BW15 kit whose CH340 drives CEN (RTS) and PA00 (DTR). + +A plain open asserts both lines and holds the chip in reset. Here every open keeps DTR high and RTS +released; the first open pulses RTS so the ROM comes up; after the command DTR low plus an RTS pulse +boots the app. The exit status is ltchiptool's; 2 means the flash succeeded but the app was not +booted (boot pulse failed, or no -d PORT to send it to), so the board sits in the ROM until reset. + +Usage: python flash_rtl87xx_kit.py flash write -d firmware.uf2 +""" + +import sys +import time + +import serial + +_first = [True] +_orig_open = serial.Serial.open + + +def _open(self): + self.dtr = True # PA00 high: ROM download mode on the next reset + self.rts = False # CEN released + _orig_open(self) + if _first[0]: + _first[0] = False + self.rts = True + time.sleep(0.1) + self.rts = False + time.sleep(0.3) # reset into the ROM + self.reset_input_buffer() + + +serial.Serial.open = _open + +from ltchiptool.__main__ import cli_entrypoint # noqa: E402 + + +def port_argument(argv: list[str]) -> str | None: + for i, a in enumerate(argv): + if a in ("-d", "--device") and i + 1 < len(argv): + return argv[i + 1] + if a.startswith(("-d=", "--device=")): + return a.split("=", 1)[1] + if a.startswith("-d") and len(a) > 2: + return a[2:] + return None + + +def boot_app(port: str) -> None: + s = serial.Serial(port=None) + s.port = port + s.dtr = False + s.rts = False + _orig_open(s) + s.rts = True + time.sleep(0.1) + s.rts = False + s.close() + + +def main() -> int: + rc = 1 + try: + result = cli_entrypoint.main(args=sys.argv[1:], standalone_mode=False) + rc = int(result) if isinstance(result, int) else 0 + finally: + port = port_argument(sys.argv) + if not port: + print( + "[flash_rtl87xx_kit] no -d PORT given, the application was not booted", + file=sys.stderr, + ) + if rc == 0: + rc = 2 + else: + try: + boot_app(port) + print("[flash_rtl87xx_kit] application booted") + except (serial.SerialException, OSError) as err: + print(f"[flash_rtl87xx_kit] boot pulse failed: {err}", file=sys.stderr) + if rc == 0: + rc = 2 # flashed, but the board is still in the ROM: press reset + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/ports.py b/.agents/skills/hardware-test/scripts/ports.py new file mode 100644 index 00000000000..b7aa730a364 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/ports.py @@ -0,0 +1,123 @@ +"""List the boards on USB, or wait for one to enumerate. + + ports.py table: port, vid:pid, product, serial number, guess + ports.py --wait 2e8a print the first port with VID 0x2e8a once it appears (default 60 s) + ports.py --wait 1a86:7523 --timeout 30 + ports.py --product "USB Serial" first port whose USB product string matches (case-insensitive) + ports.py --wait 2e8a --serial SN the port of one specific board, whatever name it comes back under + ports.py --serial-of PORT print the USB serial number behind a port name + +Run with a Python that has pyserial (any ESPHome venv). Port names follow the hub port on macOS, so +re-run this after every replug instead of trusting a name from an earlier session. +""" + +import argparse +import sys +import time + +from serial.tools import list_ports + +GUESS = [ + ( + (0x1A86, 0x7523), + "CH340: D1 mini, BW15 kit, BK7238/CB3S or LN882H adapter; identify by the boot banner", + ), + ((0x1A86, 0x55D3), "CH343: ESP32-P4 devkit console"), + ((0x1A86, 0x55D4), "CH343: ESP32 devkit"), + ((0x1A86, None), "WCH CH34x USB serial bridge"), + ((0x10C4, 0xEA60), "CP2102: classic ESP32 devkit (esp32dev)"), + ( + (0x303A, 0x1001), + "Espressif USB JTAG serial: S3/C3/C6/H2 native USB (logger hardware_uart USB_SERIAL_JTAG)", + ), + ( + (0x2E8A, None), + "Raspberry Pi Pico W / Pico 2 W CDC; the port vanishes and comes back on every reboot", + ), + ((0x1915, 0x521F), "nRF52840 Dongle (PCA10059) in the Open DFU bootloader"), + ( + (0x2FE3, 0x0100), + "nRF52840 Dongle running a Zephyr app; two CDC ports, ESPHome log on the second", + ), + ((0x1915, 0xCAFE), "Makerdiary nRF52840 MDK dongle running a Zephyr app"), + ((0x239A, None), "Adafruit style nRF52 board (UF2 bootloader family)"), +] + + +def guess(vid: int, pid: int) -> str: + for (gv, gp), text in GUESS: + if vid == gv and (gp is None or pid == gp): + return text + return "" + + +def parse_id(text: str) -> tuple[int, int | None]: + vid, _, pid = text.partition(":") + return int(vid, 16), (int(pid, 16) if pid else None) + + +def matches( + port, vid: int | None, pid: int | None, product: str | None, serial: str | None +) -> bool: + if vid is not None and (port.vid != vid or (pid is not None and port.pid != pid)): + return False + if product and (port.product or "").lower() != product.lower(): + return False + return not serial or (port.serial_number or "") == serial + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--wait", + metavar="VID[:PID]", + help="block until a matching port appears, print it", + ) + ap.add_argument("--product", help="USB product string to match") + ap.add_argument("--serial", help="USB serial number to match") + ap.add_argument( + "--serial-of", metavar="PORT", help="print the USB serial number of PORT" + ) + ap.add_argument("--timeout", type=float, default=60) + args = ap.parse_args() + + if args.serial_of: + for p in list_ports.comports(): + if p.device == args.serial_of and p.serial_number: + print(p.serial_number) + return 0 + print(f"no USB serial number for {args.serial_of}", file=sys.stderr) + return 1 + + vid = pid = None + if args.wait: + vid, pid = parse_id(args.wait) + + if args.wait or args.product or args.serial: + deadline = time.time() + args.timeout + while True: + for p in sorted(list_ports.comports(), key=lambda p: p.device): + if p.vid and matches(p, vid, pid, args.product, args.serial): + print(p.device) + return 0 + if time.time() >= deadline: + print("no matching port", file=sys.stderr) + return 1 + time.sleep(1) + + rows = [p for p in list_ports.comports() if p.vid] + if not rows: + print("no USB serial ports") + return 1 + for p in sorted(rows, key=lambda p: p.device): + print( + f"{p.device:32} {p.vid:04x}:{p.pid:04x} {p.product or '?':34} " + f"{p.serial_number or '':18} {guess(p.vid, p.pid)}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py new file mode 100644 index 00000000000..d93b3ca720f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -0,0 +1,157 @@ +"""Bounded, timestamped serial capture that always exits and closes the port. + + serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] + + default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico. + --released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin + (DTR) and reset (RTS), so a plain open holds the chip in reset. + --reset pulse reset after opening (DTR low, RTS high 100 ms, RTS low) to capture a fresh boot. + --baud 115200 by default; 74880 shows the ESP8266 ROM boot messages. + --append keep what OUTFILE already holds; by default it is truncated so counts cover this run only. + +Exit status 1 when nothing was received (wrong port name, or a port another capture still holds). +Text ESPHome marks as secret (LOG_SECRET, the Wi-Fi password) is written as . +Lines are written as [HH:MM:SS]text with ANSI colour stripped and flushed one by one, so a shell +loop can grep the file while the capture runs. If the port is missing or disappears (USB CDC boards +re-enumerate on reboot) the script retries until the deadline, and follows the board by its USB serial +number when it comes back under another device path. + +Never run an open-ended serial session: every capture has a deadline, and a flash on the same port +needs the capture stopped first (pkill -f "[s]erial_capture.py" does not match the killing shell). +""" + +import argparse +import contextlib +import pathlib +import re +import sys +import time + +import serial +from serial.tools import list_ports + +ANSI = re.compile(rb"\x1b\[[0-9;]*[A-Za-z]") +# ESPHome's LOG_SECRET span (a Wi-Fi password); a span cut off before its end marker is secret to the end +SECRET = re.compile(rb"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +PORT_ERRORS = (serial.SerialException, OSError) + + +def identify(port: str) -> tuple[str | None, int]: + """USB serial number of the port and its position among that device's ports (a dongle has two).""" + ports = sorted(list_ports.comports(), key=lambda p: p.device) + serial_number = next((p.serial_number for p in ports if p.device == port), None) + siblings = [ + p.device for p in ports if serial_number and p.serial_number == serial_number + ] + return serial_number, siblings.index(port) if port in siblings else 0 + + +def current_path(port: str, serial_number: str | None, index: int) -> str: + """The port's current device path: a USB CDC board can come back under a new name after a reboot.""" + if serial_number: + ports = sorted(list_ports.comports(), key=lambda p: p.device) + siblings = [p.device for p in ports if p.serial_number == serial_number] + if index < len(siblings): + return siblings[index] + return port + + +def open_port(args: argparse.Namespace, path: str) -> serial.Serial: + s = serial.Serial(port=None, baudrate=args.baud, timeout=0.2) + s.port = path + if args.released: + s.dtr = False + s.rts = False + s.open() + if args.reset: + try: + s.dtr = False + s.rts = True + time.sleep(0.1) + s.rts = False + s.reset_input_buffer() + except PORT_ERRORS: + s.close() + raise + return s + + +def write_line(out, tag: bytes, data: bytes) -> None: + stamp = time.strftime("[%H:%M:%S]").encode() + out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"", data)) + b"\n") + out.flush() + + +def note(out, text: str) -> None: + write_line(out, b"[capture] ", text.encode()) + + +def capture(args: argparse.Namespace, out) -> int: + """Returns the number of bytes received.""" + deadline = time.time() + args.seconds + buf = b"" + s = None + received = 0 + serial_number, index = None, 0 + while time.time() < deadline: + if s is None: + path = current_path(args.port, serial_number, index) + try: + s = open_port(args, path) + args.reset = False # only reset on the first open + if serial_number is None: + serial_number, index = identify(path) + if path != args.port: + note(out, f"reopened as {path}") + except PORT_ERRORS as err: + note(out, f"open failed: {err}") + time.sleep(1) + continue + try: + data = s.read(4096) + except PORT_ERRORS as err: + if buf: # keep the cut-off line apart from the next boot's first line + write_line(out, b"[partial]", buf) + buf = b"" + note(out, f"port lost: {err}") + with contextlib.suppress(*PORT_ERRORS): + s.close() + s = None + time.sleep(1) + continue + if not data: + continue + received += len(data) + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + write_line(out, b"", line.rstrip(b"\r")) + if buf: + write_line(out, b"[partial]", buf) + if s is not None: + s.close() + return received + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("port") + ap.add_argument("seconds", type=float) + ap.add_argument("outfile") + ap.add_argument("--baud", type=int, default=115200) + ap.add_argument("--released", action="store_true") + ap.add_argument("--reset", action="store_true") + ap.add_argument("--append", action="store_true") + args = ap.parse_args() + with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out: + received = capture(args, out) + if received == 0: + print(f"[capture] nothing received from {args.port}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/hardware-test/scripts/wifisave_hammer.sh b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh new file mode 100755 index 00000000000..4ef0d67e2f8 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/wifisave_hammer.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# wifisave_hammer.sh: the device under test (DUT) falls back to its AP; a client board joins it and posts +# /wifisave repeatedly, then the real credentials. Compiles, flashes, captures both serial logs and +# prints counts. Configs come from templates/example-dut.yaml and templates/example-client.yaml. +# +# Usage: wifisave_hammer.sh DUT_KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE_S] +# DUT_KIND esp (esphome upload), pico (upload, then re-find the CDC port by USB serial number), +# bw15 (flash_rtl87xx_kit.py) or bk (flash_bk72xx.py) +# *_YAML absolute path; the file name without .yaml must equal the config's `name` +# *_TREE checkout or worktree whose esphome code must build that yaml (cwd for python -m esphome) +# SETTLE_S seconds to keep capturing after the client reports the real save (default 40; BW15 wants 90) +# Env: PY (python with esphome; default DUT_TREE/venv/bin/python, else python3 on PATH; an explicit PY must exist), LTPY +# (LibreTiny penv python with ltchiptool; default ~/.platformio/penv/.libretiny/bin/python) +# Output next to each yaml: NAME-compile.log, NAME-upload.log, NAME-serial.log, NAME-capture.err, and DUT NAME-events.txt +# (addresses redacted so the extract can go on a PR; the serial logs themselves stay local). +# Exit status: 0 RESULT PASS (no crash marker or reboot after the AP came up, the device joined the network, +# and real stimulus: saves answered and a cooldown exit); 3 RESULT FAIL (the counts show +# a failure); 2 RESULT INVALID (a precondition or the stimulus was missed, or a capture did not last the run); +# 1 when a build, flash or port step fails (nothing is reported). +set -u +SKILL=$(cd "$(dirname "$0")/.." && pwd) +KIND=$1; DPORT=$2; DYAML=$3; DTREE=$4; CPORT=$5; CYAML=$6; CTREE=$7; SETTLE=${8:-40} +if [ -n "${PY:-}" ]; then [ -x "$PY" ] || { echo "[hammer] FAILED: PY=$PY is not executable"; exit 1; } +else PY=$DTREE/venv/bin/python; [ -x "$PY" ] || PY=$(command -v python3) || { echo "[hammer] FAILED: no python found, set PY"; exit 1; }; fi +LTPY=${LTPY:-$HOME/.platformio/penv/.libretiny/bin/python} +DDIR=$(dirname "$DYAML"); DNAME=$(basename "$DYAML" .yaml) +CDIR=$(dirname "$CYAML"); CNAME=$(basename "$CYAML" .yaml) +DLOG=$DDIR/$DNAME-serial.log; CLOG=$CDIR/$CNAME-serial.log +INVALID="" +AP_WAIT=120; UPLOAD_WAIT=300; SAVE_WAIT=240 +# Crash signatures (extended regex), not bare words: "aborting connection attempt" is a normal Wi-Fi retry line +CRASH_RE='Panic |abort\(\)|Fatal exception|Exception \(|Guru Meditation|HardFault|Bus Fault|Backtrace:|Soft WDT reset|wdt reset|Brownout|rst cause:[24]' +# Boot banners and port losses are counted apart: a Pico reboot shows as a port loss (and maybe a banner too), +# while on a UART board a port loss comes from the adapter or the hub, not the chip +BANNER_RE='Log initialized'; LOSS_RE='\[capture\] port lost' +DUT_CAPTURE=$((AP_WAIT + UPLOAD_WAIT + SAVE_WAIT + SETTLE + 60)); CLIENT_CAPTURE=$((SAVE_WAIT + SETTLE + 60)) + +bound() { local t=$1; shift; if command -v timeout >/dev/null; then timeout "$t" "$@"; else perl -e 'alarm shift; exec @ARGV' "$t" "$@"; fi; } +count() { LC_ALL=C grep -a -c "$1" "$2"; } +wait_for() { # wait_for FILE PATTERN SECONDS -> prints elapsed, status 1 on timeout + local n=0; until LC_ALL=C grep -a -q "$2" "$1"; do sleep 2; n=$((n+2)); [ $n -ge "$3" ] && { echo "$n"; return 1; }; done; echo "$n"; } +# Only this run's captures: another capture on the host (a soak on a third board) must survive +stop_tails() { pkill -f "[s]erial_capture.py $DPORT " 2>/dev/null; pkill -f "[s]erial_capture.py $CPORT " 2>/dev/null; sleep 1; } +fail() { echo "[hammer] FAILED: $1"; stop_tails; exit 1; } +port_never_opened() { # an open failure and no line that is not a capture note: the port, not the firmware + LC_ALL=C grep -a -q '\[capture\] open failed' "$1" && ! LC_ALL=C grep -a -q -v '^\[[0-9:]*\]\[capture\]' "$1"; } +compile() { # compile YAML TREE + (cd "$2" && PYTHONUNBUFFERED=1 bound 1500 "$PY" -m esphome compile "$1" > "${1%.yaml}-compile.log" 2>&1); local rc=$? + echo "[hammer] compile $(basename "$1"): rc=$rc"; return $rc; } +esp_upload() { # esp_upload YAML PORT TREE + (cd "$3" && bound $UPLOAD_WAIT "$PY" -m esphome upload "$1" --device "$2" > "${1%.yaml}-upload.log" 2>&1) + local ok; ok=$(count 'Successfully uploaded' "${1%.yaml}-upload.log"); echo "[hammer] upload $(basename "$1"): ok=$ok"; [ "$ok" -ge 1 ]; } + +stop_tails +compile "$DYAML" "$DTREE" || fail "DUT build failed, see $DDIR/$DNAME-compile.log" +compile "$CYAML" "$CTREE" || fail "client build failed, see $CDIR/$CNAME-compile.log" + +TAILOPT="" +case $KIND in + esp) esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" ;; + pico) + SN=$("$PY" "$SKILL/scripts/ports.py" --serial-of "$DPORT") || fail "no USB serial number for $DPORT" + esp_upload "$DYAML" "$DPORT" "$DTREE" || fail "DUT upload failed" + # The Pico drops its CDC port on reboot and may already be back: take whatever path this board has now; + # the capture follows the serial number if the path changes again + DPORT=$("$PY" "$SKILL/scripts/ports.py" --wait 2e8a --serial "$SN" --timeout 60) || fail "pico did not re-enumerate" + echo "[hammer] pico port after upload: $DPORT"; sleep 2 ;; + bw15|bk) + UF2=$(ls "$DDIR/.esphome/build/$DNAME/.pioenvs/$DNAME/"*.uf2 2>/dev/null | head -1) + [ -n "$UF2" ] || fail "no uf2 under $DDIR/.esphome/build/$DNAME" + echo "[hammer] uf2: ${UF2##*/}" + W=$SKILL/scripts/flash_rtl87xx_kit.py; [ "$KIND" = bk ] && W=$SKILL/scripts/flash_bk72xx.py + (cd "$DDIR" && bound 400 "$LTPY" "$W" flash write -d "$DPORT" "$UF2" > "$DDIR/$DNAME-upload.log" 2>&1); rc=$? + echo "[hammer] $KIND flash: rc=$rc"; [ $rc -eq 0 ] || fail "DUT flash failed, see $DDIR/$DNAME-upload.log" + TAILOPT="--released" ;; + *) echo "unknown DUT_KIND $KIND"; exit 1 ;; +esac + +: > "$DLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$DPORT" $DUT_CAPTURE "$DLOG" $TAILOPT 2> "${DLOG%-serial.log}-capture.err" &) +if ! n=$(wait_for "$DLOG" "Captive portal\|Starting fallback AP" $AP_WAIT); then + [ -s "$DLOG" ] || fail "the DUT capture produced nothing (the capture itself failed), see ${DLOG%-serial.log}-capture.err" + port_never_opened "$DLOG" && fail "could not open $DPORT (held by another process, or the wrong port), see $DLOG" + fail "DUT never raised its AP within ${n}s, see $DLOG" +fi +echo "[hammer] DUT fallback AP after ${n}s"; sleep 2 +# reboots are counted from here; the first banner may predate the capture +BANNER_BASE=$(count "$BANNER_RE" "$DLOG"); LOSS_BASE=$(count "$LOSS_RE" "$DLOG") + +esp_upload "$CYAML" "$CPORT" "$CTREE" || fail "client upload failed" +: > "$CLOG"; ("$PY" "$SKILL/scripts/serial_capture.py" "$CPORT" $CLIENT_CAPTURE "$CLOG" 2> "${CLOG%-serial.log}-capture.err" &) +if n=$(wait_for "$CLOG" "real save status\|posting the real" $SAVE_WAIT); then + echo "[hammer] client posted the real credentials after ${n}s; settling ${SETTLE}s"; sleep "$SETTLE" +else + [ -s "$CLOG" ] || fail "the client capture produced nothing (the capture itself failed), see ${CLOG%-serial.log}-capture.err" + port_never_opened "$CLOG" && fail "could not open $CPORT (held by another process, or the wrong port), see $CLOG" + INVALID="client never reached the real save within ${n}s"; echo "[hammer] WARNING: $INVALID" +fi + +# A verdict needs complete evidence: both captures must have lasted the run and logged no error of their own +for spec in "$DPORT:${DLOG%-serial.log}-capture.err" "$CPORT:${CLOG%-serial.log}-capture.err"; do + port=${spec%%:*}; err=${spec#*:} + pgrep -f "[s]erial_capture.py $port " >/dev/null || INVALID="${INVALID:+$INVALID; }the capture on $port ended before the run did, see $err" + [ -s "$err" ] && INVALID="${INVALID:+$INVALID; }the capture on $port reported an error, see $err" +done +CRASHES=$(LC_ALL=C grep -a -c -E "$CRASH_RE" "$DLOG") +BANNERS=$(( $(count "$BANNER_RE" "$DLOG") - BANNER_BASE )); LOSSES=$(( $(count "$LOSS_RE" "$DLOG") - LOSS_BASE )) +if [ "$KIND" = pico ]; then + REBOOTS=$BANNERS; [ "$LOSSES" -gt "$REBOOTS" ] && REBOOTS=$LOSSES # one reboot can show as both, so take the larger +else + REBOOTS=$BANNERS; [ "$LOSSES" -gt 0 ] && INVALID="${INVALID:+$INVALID; }the DUT port dropped $LOSSES time(s) mid-run (adapter or hub, not the chip)" +fi +STA=$(LC_ALL=C grep -a 'IP Address:' "$DLOG" | grep -v -c '192\.168\.4\.'); REAL=$(LC_ALL=C grep -a -o 'real save status [-0-9]*' "$CLOG" | tail -1 | awk '{print $4}') +SAVES=$(count 'Requested WiFi Settings Change' "$DLOG"); EXITS=$(count 'cooldown early' "$DLOG"); ANSWERED=$(count 'bogus save status 200' "$CLOG") +echo "[hammer] $DNAME: saves requested $SAVES, cooldown exits $EXITS, crash markers $CRASHES, after the AP came up: boot banners $BANNERS, port losses $LOSSES, reboots $REBOOTS; STA addresses $STA" +FAILED=$(count 'bogus save status -1' "$CLOG") +echo "[hammer] $CNAME: bogus saves answered $ANSWERED, failed $FAILED, real save status ${REAL:-none}" +# Only whole messages, matched where the message starts, so a credential line or an odd SSID never gets in +LC_ALL=C grep -a -E "^\[[0-9:]+\](\[[A-Z]\]\[[a-z_]+:[0-9]+\]: *(Exiting cooldown early|Starting fallback AP|Captive portal|Requested WiFi Settings Change|Retry phase|Connected\$|IP Address: )|\[capture\] port lost|($CRASH_RE))" "$DLOG" | LC_ALL=C tr -cd '\11\12\15\40-\176' | sed -E 's/(IP Address: )[^ ]+/\1/' | cut -c1-110 | head -80 > "$DDIR/$DNAME-events.txt" +echo "[hammer] events: $DDIR/$DNAME-events.txt" +stop_tails +[ -z "$INVALID" ] || { echo "[hammer] RESULT INVALID: $INVALID"; exit 2; } +# Provisioning is proven by the device joining the network; the client's answer to the real save can time +# out because the device leaves the AP before replying, so its status is information only +if [ "$CRASHES" -gt 0 ] || [ "$REBOOTS" -gt 0 ] || [ "$STA" -lt 1 ]; then + echo "[hammer] RESULT FAIL: crash markers $CRASHES, reboots $REBOOTS, STA addresses $STA, real save status ${REAL:-none}"; exit 3 +fi +# A pass needs the stimulus the flow is about: at least half the configured bogus saves answered and one cooldown exit +CONFIGURED=$(LC_ALL=C grep -a -E '^[[:space:]]*bogus_saves:' "$CYAML" | head -1 | tr -dc '0-9'); CONFIGURED=${CONFIGURED:-15} +if [ "$EXITS" -lt 1 ] || [ $((ANSWERED * 2)) -lt "$CONFIGURED" ]; then + echo "[hammer] RESULT INVALID: insufficient stimulus (bogus saves answered $ANSWERED of $CONFIGURED, cooldown exits $EXITS)"; exit 2 +fi +[ "$FAILED" -eq 0 ] || echo "[hammer] WARNING: $FAILED of $CONFIGURED bogus saves got no answer" +echo "[hammer] RESULT PASS"; exit 0 diff --git a/.agents/skills/hardware-test/templates/client-wifisave.yaml b/.agents/skills/hardware-test/templates/client-wifisave.yaml new file mode 100644 index 00000000000..c65581ba65f --- /dev/null +++ b/.agents/skills/hardware-test/templates/client-wifisave.yaml @@ -0,0 +1,77 @@ +# Package for the client board: it joins the device under test's AP (priority 10), posts +# `bogus_saves` bogus /wifisave requests `save_interval` apart, then posts the real credentials once. +# Include from a per-run yaml that holds the platform block and the substitutions (example-client.yaml); +# real_ssid and real_psk come from the scratch directory's secrets.yaml and must be URL safe. +# Set bogus_saves to 1 for a plain provisioning check. Log lines the driver script counts: +# "bogus save N", "bogus save status 200", "posting the real credentials", "real save status 200" +# The hammer is a single-mode script, so a reconnect to the AP (the device under test rebooted) does not +# start a second series on top of the first. +substitutions: + ap_password: hwtestpass + bogus_saves: "15" + save_interval: 3s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +http_request: + id: http + verify_ssl: false + timeout: 10s + +wifi: + id: wifi_id + networks: + - ssid: ${ap_ssid} + password: ${ap_password} + priority: 10 + - ssid: ${real_ssid} + password: ${real_psk} + on_connect: + - lambda: |- + char b[40]; + ESP_LOGI("test", "connected, ip %s", id(wifi_id).get_ip_addresses()[0].str_to(b)); + - if: + condition: + lambda: |- + char b[40]; + return strncmp(id(wifi_id).get_ip_addresses()[0].str_to(b), "192.168.4.", 10) == 0; + then: + - script.execute: hammer + +script: + - id: hammer + mode: single + then: + - delay: 3s + - logger.log: "hammering /wifisave with bogus credentials" + - repeat: + count: ${bogus_saves} + then: + - lambda: ESP_LOGI("test", "bogus save %u", (unsigned) iteration + 1); + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${bogus_ssid}&psk=${bogus_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "bogus save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "bogus save status -1"); + - delay: ${save_interval} + - logger.log: "posting the real credentials" + - http_request.get: + url: "http://192.168.4.1/wifisave?ssid=${real_ssid}&psk=${real_psk}" + capture_response: true + on_response: + - lambda: ESP_LOGI("test", "real save status %d", response->status_code); + on_error: + - lambda: ESP_LOGW("test", "real save status -1"); + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml new file mode 100644 index 00000000000..4c6e4d10d4a --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-fallback-ap.yaml @@ -0,0 +1,31 @@ +# Package for a device under test that never finds its network, falls back to its AP and serves the +# captive portal. Include it from a per-run yaml that holds the platform block and the substitutions +# (see example-dut.yaml). Bump `name` on every run: saved credentials are keyed by it, and a reused +# name boots straight onto the real network and never raises the AP. +substitutions: + ap_password: hwtestpass + ap_timeout: 15s + bogus_ssid: hwtest-no-such-network + bogus_psk: nosuchpassword + +esphome: + name: ${name} + +logger: + level: DEBUG + +wifi: + networks: + - ssid: ${bogus_ssid} + password: ${bogus_psk} + ap: + ssid: ${ap_ssid} + password: ${ap_password} + ap_timeout: ${ap_timeout} + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/example-client.yaml b/.agents/skills/hardware-test/templates/example-client.yaml new file mode 100644 index 00000000000..7b31309ba32 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-client.yaml @@ -0,0 +1,18 @@ +# Client board for the /wifisave hammer. Copy into a scratch directory next to a secrets.yaml; +# ap_ssid must match the device under test, real_ssid and real_psk must be URL safe. +substitutions: + name: hwtest-client-1 + ap_ssid: hwtest-ap + real_ssid: !secret wifi_ssid + real_psk: !secret wifi_password + bogus_saves: "15" + +esp32: + board: esp32dev + framework: + type: esp-idf + +# esp8266: {board: d1_mini} + +packages: + hwtest: !include client-wifisave.yaml diff --git a/.agents/skills/hardware-test/templates/example-dut.yaml b/.agents/skills/hardware-test/templates/example-dut.yaml new file mode 100644 index 00000000000..b15236ad019 --- /dev/null +++ b/.agents/skills/hardware-test/templates/example-dut.yaml @@ -0,0 +1,16 @@ +# Device under test for the /wifisave hammer. Copy into a scratch directory, keep the file name equal +# to `name`, pick one platform block, bump `name` every run. +substitutions: + name: hwtest-dut-1 + ap_ssid: hwtest-ap + +esp8266: + board: d1_mini + +# esp32: {board: esp32dev, framework: {type: esp-idf}} +# rtl87xx: {board: bw15} +# bk72xx: {board: generic-bk7238} # plus logger: {hardware_uart: UART1}, BK72xx logs on UART2 by default +# rp2040: {board: rpipico2w} + +packages: + hwtest: !include dut-fallback-ap.yaml From c0d8a0dfe9df6cd015e227b87f95aae589f9c33a Mon Sep 17 00:00:00 2001 From: Anton Viktorov Date: Thu, 15 Oct 2026 21:38:15 +0000 Subject: [PATCH 32/50] [as734x] [3/3] Add illuminance, photopic irradiance, PAR, PPFD, colour temperature and RGB (#18012) Co-authored-by: J. Nick Koston --- esphome/components/as734x/as734x.cpp | 162 ++++++++++++++++++++ esphome/components/as734x/as734x.h | 47 +++++- esphome/components/as734x/color_helpers.cpp | 63 ++++++++ esphome/components/as734x/color_helpers.h | 22 +++ esphome/components/as734x/sensor.py | 62 +++++++- esphome/components/as734x/text_sensor.py | 28 ++++ esphome/core/defines.h | 1 + tests/components/as734x/common.yaml | 20 +++ 8 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 esphome/components/as734x/color_helpers.cpp create mode 100644 esphome/components/as734x/color_helpers.h create mode 100644 esphome/components/as734x/text_sensor.py diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 2331d89c5fe..5337b3918eb 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -1,10 +1,12 @@ #include "as734x.h" +#include "color_helpers.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include +#include namespace esphome::as734x { @@ -78,6 +80,41 @@ static_assert(sizeof(AS7341_WAVELENGTHS) == NUM_BANDS * sizeof(uint16_t)); static const float AS7341_GAIN_CORRECTION[] PROGMEM = {1.0577f, 1.0491f, 1.0479f, 1.0491f, 1.0207f, 1.0158f, 1.0109f, 1.0000f, 1.0003f, 0.9873f, 0.9593f, 0.0f, 0.0f}; static_assert(sizeof(AS7341_GAIN_CORRECTION) == GAIN_COUNT * sizeof(float)); +// clang-format off +// The three bands this chip does not have contribute nothing to the weighted sums. +static const ChannelContribution AS7341_CONTRIBUTIONS[] PROGMEM = { + {0.005261294f, 5.936230324f, 0.020518273f}, // F1 + {0.242615252f, 3.572528495f, 0.013525490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.174482552f, 2.914113437f, 0.011585145f}, // F3 + {1.479499075f, 2.452851227f, 0.010604986f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {1.906818555f, 1.940656433f, 0.008890531f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.162058142f, 1.533976606f, 0.007441544f}, // F6 + {0.462374845f, 1.819838250f, 0.009393590f}, // F7 + {-0.017794572f, 1.232942274f, 0.006890812f}, // F8 + {-0.260730901f, -1.102368000f, -0.004647911f}, // NIR + {-0.022604075f, -0.033304813f, -0.000129106f}, // Clear +}; +static const ChannelTristimulus AS7341_TRISTIMULUS[] PROGMEM = { + {0.39814f, 0.01396f, 1.95010f}, // F1 + {1.29540f, 0.16748f, 6.45490f}, // F2 + {0.0f, 0.0f, 0.0f}, // FZ, absent + {0.36956f, 0.23538f, 2.78010f}, // F3 + {0.10902f, 1.42750f, 0.18501f}, // F4 + {0.0f, 0.0f, 0.0f}, // FY, absent + {0.71942f, 1.88670f, 0.15325f}, // F5 + {0.0f, 0.0f, 0.0f}, // FXL, absent + {1.78180f, 1.14200f, 0.09539f}, // F6 + {1.10110f, 0.46497f, 0.10563f}, // F7 + {-0.03991f, -0.02702f, 0.08866f}, // F8 + {-0.27597f, -0.24468f, -0.61140f}, // NIR + {-0.02347f, -0.01993f, -0.00938f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7341_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7341_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); const ChipInfo AS7341_CHIP PROGMEM = { .name = AS7341_NAME, .init = AS7341_INIT, @@ -85,6 +122,8 @@ const ChipInfo AS7341_CHIP PROGMEM = { .readout = AS7341_READOUT, .wavelengths = AS7341_WAVELENGTHS, .gain_correction = AS7341_GAIN_CORRECTION, + .contributions = AS7341_CONTRIBUTIONS, + .tristimulus = AS7341_TRISTIMULUS, .init_count = 1, .astep_reg = 0xCA, .cfg0_reg = 0xA9, @@ -147,6 +186,40 @@ static const float AS7343_GAIN_CORRECTION[] PROGMEM = { }; // clang-format on static_assert(sizeof(AS7343_GAIN_CORRECTION) == GAIN_COUNT * NUM_BANDS * sizeof(float)); +// clang-format off +static const ChannelContribution AS7343_CONTRIBUTIONS[] PROGMEM = { + {-0.028559089f, 0.450293995f, 0.001266925f}, // F1 + {-0.042150948f, 3.566379984f, 0.012515925f}, // F2 + {0.000661721f, 2.877424672f, 0.010243005f}, // FZ + {-0.108791278f, 2.273837581f, 0.009376157f}, // F3 + {-0.167172180f, 1.519298503f, 0.006017556f}, // F4 + {5.726660294f, 4.199345859f, 0.019357905f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {-0.241492014f, 1.185259160f, 0.006516325f}, // FXL + {-0.047665296f, 1.759469826f, 0.009780714f}, // F6 + {-0.012516990f, 1.702006611f, 0.009950228f}, // F7 + {-0.026424974f, -1.059196137f, -0.005425656f}, // F8 + {-0.007565221f, -0.064827881f, -0.000232267f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +static const ChannelTristimulus AS7343_TRISTIMULUS[] PROGMEM = { + {-0.07879f, -0.03269f, -0.31295f}, // F1 + {-0.12235f, -0.01297f, -0.57885f}, // F2 + {1.99879f, -0.04011f, 10.00197f}, // FZ + {-0.33364f, -0.06889f, -0.31281f}, // F3 + {-0.06795f, -0.17453f, -0.19657f}, // F4 + {-0.45419f, 5.69083f, -0.11077f}, // FY + {0.0f, 0.0f, 0.0f}, // F5 + {5.27242f, -0.22956f, -0.06132f}, // FXL + {-0.05072f, -0.04762f, -0.03536f}, // F6 + {-0.04666f, -0.01295f, -0.06025f}, // F7 + {-0.03931f, -0.02797f, -0.12174f}, // F8 + {0.0f, 0.0f, 0.0f}, // NIR + {0.0f, 0.0f, 0.0f}, // Clear +}; +// clang-format on +static_assert(sizeof(AS7343_CONTRIBUTIONS) == NUM_BANDS * sizeof(ChannelContribution)); +static_assert(sizeof(AS7343_TRISTIMULUS) == NUM_BANDS * sizeof(ChannelTristimulus)); static constexpr ChipInfo as7343_chip(const char *name) { return { .name = name, @@ -155,6 +228,8 @@ static constexpr ChipInfo as7343_chip(const char *name) { .readout = AS7343_READOUT, .wavelengths = AS7343_WAVELENGTHS, .gain_correction = AS7343_GAIN_CORRECTION, + .contributions = AS7343_CONTRIBUTIONS, + .tristimulus = AS7343_TRISTIMULUS, .init_count = sizeof(AS7343_INIT) / sizeof(RegValue), .astep_reg = 0xD4, .cfg0_reg = 0xBF, @@ -272,6 +347,14 @@ void AS734XComponent::dump_config() { sensor::log_sensor(TAG, " ", label, this->basic_counts_sensors_[band]); } LOG_SENSOR(" ", "Saturation level", this->saturation_level_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Irradiance photopic", this->irradiance_photopic_sensor_); + LOG_SENSOR(" ", "Irradiance PAR", this->irradiance_par_sensor_); + LOG_SENSOR(" ", "PPFD", this->ppfd_sensor_); + LOG_SENSOR(" ", "Color temperature", this->color_temperature_sensor_); +#ifdef USE_AS734X_RGB + LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_); +#endif if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } @@ -484,6 +567,83 @@ void AS734XComponent::publish_basic_counts_() { } } +// Each band contributes a fixed amount per basic count to every integrated quantity, so the +// totals are a weighted sum over the bands. +// The weighted sums can go negative under light the tables were not fitted for, such as pure blue or +// far red, so the published values are floored at zero. +void AS734XComponent::publish_light_metrics_() { + float irradiance_photopic = 0.0f; + float irradiance_par = 0.0f; + float ppfd = 0.0f; + + const ChannelContribution *contributions = pgm(this->chip_->contributions); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelContribution contribution = pgm(contributions[band]); + + irradiance_photopic += contribution.irradiance_photopic * basic_count; + irradiance_par += contribution.irradiance_par * basic_count; + ppfd += contribution.ppfd * basic_count; + } + irradiance_photopic = std::max(irradiance_photopic, 0.0f); + irradiance_par = std::max(irradiance_par, 0.0f); + ppfd = std::max(ppfd, 0.0f) * 1e3f; + const float illuminance = irradiance_photopic * LUMENS_PER_WATT; + + ESP_LOGV(TAG, "Photopic irradiance %.4f, PAR %.4f, PPFD %.4f, illuminance %.2f lx", irradiance_photopic, + irradiance_par, ppfd, illuminance); + if (this->illuminance_sensor_ != nullptr) { + this->illuminance_sensor_->publish_state(illuminance); + } + if (this->irradiance_photopic_sensor_ != nullptr) { + this->irradiance_photopic_sensor_->publish_state(irradiance_photopic); + } + if (this->irradiance_par_sensor_ != nullptr) { + this->irradiance_par_sensor_->publish_state(irradiance_par); + } + if (this->ppfd_sensor_ != nullptr) { + this->ppfd_sensor_->publish_state(ppfd); + } +} + +void AS734XComponent::publish_color_() { + float tri_x = 0.0f; + float tri_y = 0.0f; + float tri_z = 0.0f; + + const ChannelTristimulus *table = pgm(this->chip_->tristimulus); + for (uint8_t band = 0; band < NUM_BANDS; band++) { + const float basic_count = this->basic_counts_[band]; + const ChannelTristimulus tristimulus = pgm(table[band]); + + tri_x += tristimulus.x * basic_count; + tri_y += tristimulus.y * basic_count; + tri_z += tristimulus.z * basic_count; + } + + const float color_temperature = tristimulus_to_cct(tri_x, tri_y, tri_z); + ESP_LOGV(TAG, "XYZ %.4f, %.4f, %.4f -> CCT %.0f K", tri_x, tri_y, tri_z, color_temperature); + if (this->color_temperature_sensor_ != nullptr) { + this->color_temperature_sensor_->publish_state(color_temperature); + } + +#ifdef USE_AS734X_RGB + if (this->rgb_hex_sensor_ == nullptr) { + return; + } + // Convert to chromaticity first, so the colour describes the hue rather than the brightness. + // Absolute tristimulus values clamp to white under anything but dim light. + char rgb_hex[7] = "000000"; // no usable chromaticity reads as black rather than an empty string + const float sum = tri_x + tri_y + tri_z; + if (sum > MIN_TRISTIMULUS_SUM) { + uint8_t rgb[3]; + tristimulus_to_rgb(tri_x / sum, tri_y / sum, tri_z / sum, rgb[0], rgb[1], rgb[2]); + format_hex_to(rgb_hex, rgb, sizeof(rgb)); + } + this->rgb_hex_sensor_->publish_state(rgb_hex); +#endif +} + void AS734XComponent::publish_measurement_() { this->calculate_basic_counts_(); for (uint8_t band = 0; band < NUM_BANDS; band++) { @@ -492,6 +652,8 @@ void AS734XComponent::publish_measurement_() { } } this->publish_basic_counts_(); + this->publish_light_metrics_(); + this->publish_color_(); if (this->saturation_level_sensor_ != nullptr) { const uint32_t max_adc = std::min((this->reading_atime_ + 1UL) * (this->reading_astep_ + 1UL), MAX_ADC_COUNT); diff --git a/esphome/components/as734x/as734x.h b/esphome/components/as734x/as734x.h index f2b68d380ec..95c694da9ab 100644 --- a/esphome/components/as734x/as734x.h +++ b/esphome/components/as734x/as734x.h @@ -7,6 +7,9 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" +#ifdef USE_AS734X_RGB +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::as734x { @@ -47,6 +50,24 @@ static constexpr uint16_t VISIBLE_MAX_NM = 700; static constexpr uint16_t WIDEBAND_NM = 0; static constexpr uint16_t ABSENT_NM = 0xFFFF; // band this chip does not have +// Luminous efficacy at 555 nm, for turning photopic irradiance into lux. +static constexpr float LUMENS_PER_WATT = 683.002f; + +// How much one basic count of a band contributes to each integrated quantity. The factors come +// from the manufacturer's golden-device calibration, convolved for a reconstructed spectrum. +struct ChannelContribution { + float irradiance_photopic; // weighted by the CIE 1931 V(lambda) curve + float irradiance_par; // 400-700 nm only + float ppfd; // 400-700 nm only, scaled by 1e3 before publishing +}; + +// CIE 1931 tristimulus contribution of one basic count of a band. +struct ChannelTristimulus { + float x; + float y; + float z; +}; + struct RegValue { uint8_t reg; uint8_t value; @@ -55,11 +76,13 @@ struct RegValue { /// Per-chip constants, stored in flash. The pointed-to tables are in flash too. struct ChipInfo { const char *name; - const RegValue *init; // written in order; the bank is selected per register address - const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images - const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) - const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band - const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band + const RegValue *init; // written in order; the bank is selected per register address + const uint8_t *smux; // AS7341: SMUX RAM image per step; AS7343: the three chain images + const uint8_t *readout; // per step, the ADC slot of each band (0xFF: not read in this step) + const uint16_t *wavelengths; // per band, WIDEBAND_NM for clear and ABSENT_NM for a missing band + const float *gain_correction; // GAIN_COUNT entries, or GAIN_COUNT * NUM_BANDS when per band + const ChannelContribution *contributions; // per band + const ChannelTristimulus *tristimulus; // per band uint8_t init_count; uint8_t astep_reg; uint8_t cfg0_reg; @@ -117,6 +140,15 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void set_basic_counts_sensor(sensor::Sensor *sensor, uint8_t band) { this->basic_counts_sensors_[band] = sensor; } SUB_SENSOR(saturation_level) + SUB_SENSOR(illuminance) + SUB_SENSOR(irradiance_photopic) + SUB_SENSOR(irradiance_par) + SUB_SENSOR(ppfd) + SUB_SENSOR(color_temperature) + +#ifdef USE_AS734X_RGB + void set_rgb_hex_sensor(text_sensor::TextSensor *sensor) { this->rgb_hex_sensor_ = sensor; } +#endif protected: bool select_bank_(uint8_t reg); @@ -126,6 +158,8 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { void read_step_(); void abort_measurement_(const LogString *reason); void calculate_basic_counts_(); + void publish_light_metrics_(); + void publish_color_(); void publish_measurement_(); void publish_basic_counts_(); float normalization_divisor_() const; @@ -157,6 +191,9 @@ class AS734XComponent : public PollingComponent, public i2c::I2CDevice { uint8_t atime_{0}; uint8_t step_{0}; bool bank_low_{true}; // unknown until setup() writes CFG0 +#ifdef USE_AS734X_RGB + text_sensor::TextSensor *rgb_hex_sensor_{nullptr}; +#endif }; } // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.cpp b/esphome/components/as734x/color_helpers.cpp new file mode 100644 index 00000000000..b456038eda7 --- /dev/null +++ b/esphome/components/as734x/color_helpers.cpp @@ -0,0 +1,63 @@ +#include "color_helpers.h" + +#include +#include + +namespace esphome::as734x { + +namespace { + +// Range McCamy's approximation is meaningful over. +constexpr float CCT_MIN_K = 1000.0f; +constexpr float CCT_MAX_K = 25000.0f; + +#ifdef USE_AS734X_RGB +// sRGB transfer function. +float gamma_correct(float channel) { + if (channel <= 0.0031308f) { + return 12.92f * channel; + } + return 1.055f * std::pow(channel, 1.0f / 2.4f) - 0.055f; +} + +uint8_t to_byte(float channel) { return static_cast(std::clamp(channel, 0.0f, 1.0f) * 255.0f); } +#endif + +} // namespace + +float tristimulus_to_cct(float x, float y, float z) { + const float sum = x + y + z; + // The conversion matrices carry negative coefficients, so a spectrum unlike the one they were + // calibrated against can drive the sum to zero or below. Chromaticity is undefined there, and + // McCamy's formula would still return a plausible looking number from it. + if (sum <= MIN_TRISTIMULUS_SUM) { + return NAN; + } + const float chroma_x = x / sum; + const float chroma_y = y / sum; + + // McCamy's approximation: cct = 437 n^3 + 3601 n^2 + 6861 n + 5517. The pole at + // chroma_y = 0.1858 sends the cubic off to either infinity, and it stays meaningless well + // either side of that, so the answer is only reported where the approximation holds. + const float denominator = 0.1858f - chroma_y; + if (std::abs(denominator) < 1e-4f) { + return NAN; + } + const float n = (chroma_x - 0.3320f) / denominator; + const float cct = ((437.0f * n + 3601.0f) * n + 6861.0f) * n + 5517.0f; + return (cct >= CCT_MIN_K && cct <= CCT_MAX_K) ? cct : NAN; +} + +#ifdef USE_AS734X_RGB +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b) { + const float r_linear = 3.2406f * x - 1.5372f * y - 0.4986f * z; + const float g_linear = -0.9689f * x + 1.8758f * y + 0.0415f * z; + const float b_linear = 0.0557f * x - 0.2040f * y + 1.0570f * z; + + r = to_byte(gamma_correct(r_linear)); + g = to_byte(gamma_correct(g_linear)); + b = to_byte(gamma_correct(b_linear)); +} +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/color_helpers.h b/esphome/components/as734x/color_helpers.h new file mode 100644 index 00000000000..1bf382c4bc6 --- /dev/null +++ b/esphome/components/as734x/color_helpers.h @@ -0,0 +1,22 @@ +#pragma once +#include + +#include "esphome/core/defines.h" + +namespace esphome::as734x { + +// Smallest tristimulus sum that still carries a usable hue. +constexpr float MIN_TRISTIMULUS_SUM = 0.001f; + +// Correlated colour temperature in kelvin from CIE 1931 tristimulus values, using McCamy's +// approximation. Returns NAN when the input carries no usable hue, or when the result falls +// outside the range the approximation holds over. +float tristimulus_to_cct(float x, float y, float z); + +#ifdef USE_AS734X_RGB +// sRGB components for CIE 1931 tristimulus values. Pass chromaticity (x + y + z == 1) rather than +// absolute values, otherwise anything but dim light clamps to white. +void tristimulus_to_rgb(float x, float y, float z, uint8_t &r, uint8_t &g, uint8_t &b); +#endif + +} // namespace esphome::as734x diff --git a/esphome/components/as734x/sensor.py b/esphome/components/as734x/sensor.py index 726d15d6fff..85fa6635320 100644 --- a/esphome/components/as734x/sensor.py +++ b/esphome/components/as734x/sensor.py @@ -2,7 +2,18 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv -from esphome.const import CONF_COUNTS, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_COUNTS, + CONF_ILLUMINANCE, + CONF_TYPE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_IRRADIANCE, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, + UNIT_PERCENT, +) import esphome.final_validate as fv from esphome.types import ConfigType @@ -11,10 +22,21 @@ from . import BANDS_41, BANDS_43, CONF_AS734X_ID, DOMAIN, AS734XComponent DEPENDENCIES = [DOMAIN] CONF_BASIC_COUNTS = "basic_counts" +CONF_IRRADIANCE_PAR = "irradiance_par" +CONF_IRRADIANCE_PHOTOPIC = "irradiance_photopic" +CONF_PPFD = "ppfd" CONF_SATURATION_LEVEL = "saturation_level" +UNIT_IRRADIANCE = "W/m\u00b2" +UNIT_PPFD = "\u00b5mol/m\u00b2/s" + ICON_COUNTS = "mdi:counter" ICON_SATURATION = "mdi:weather-sunny-alert" +ICON_ILLUMINANCE = "mdi:weather-sunny" +ICON_IRRADIANCE_PHOTOPIC = "mdi:sun-wireless-outline" +ICON_PAR = "mdi:sprout" +ICON_PPFD = "mdi:sprout-outline" +ICON_COLOR_TEMPERATURE = "mdi:sun-thermometer-outline" _COUNTS_SENSOR = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, @@ -48,6 +70,39 @@ CONFIG_SCHEMA = cv.Schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_ILLUMINANCE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PHOTOPIC): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_IRRADIANCE_PHOTOPIC, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_IRRADIANCE_PAR): sensor.sensor_schema( + unit_of_measurement=UNIT_IRRADIANCE, + icon=ICON_PAR, + accuracy_decimals=3, + device_class=DEVICE_CLASS_IRRADIANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PPFD): sensor.sensor_schema( + unit_of_measurement=UNIT_PPFD, + icon=ICON_PPFD, + accuracy_decimals=3, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_COLOR_TEMPERATURE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), } ) @@ -82,3 +137,8 @@ async def to_code(config: ConfigType) -> None: sensors = sensor.sub_sensors(config) await sensors(CONF_SATURATION_LEVEL, hub.set_saturation_level_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_IRRADIANCE_PHOTOPIC, hub.set_irradiance_photopic_sensor) + await sensors(CONF_IRRADIANCE_PAR, hub.set_irradiance_par_sensor) + await sensors(CONF_PPFD, hub.set_ppfd_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/esphome/components/as734x/text_sensor.py b/esphome/components/as734x/text_sensor.py new file mode 100644 index 00000000000..d010f2d6a42 --- /dev/null +++ b/esphome/components/as734x/text_sensor.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_AS734X_ID, DOMAIN, AS734XComponent + +DEPENDENCIES = [DOMAIN] + +CONF_RGB_HEX = "rgb_hex" + +ICON_PALETTE = "mdi:palette" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AS734X_ID): cv.use_id(AS734XComponent), + cv.Optional(CONF_RGB_HEX): text_sensor.text_sensor_schema(icon=ICON_PALETTE), + } +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_RGB_HEX)) is None: + return + hub = await cg.get_variable(config[CONF_AS734X_ID]) + cg.add_define("USE_AS734X_RGB") + sens = await text_sensor.new_text_sensor(conf) + cg.add(hub.set_rgb_hex_sensor(sens)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 607bc7a42b4..c901ff3e844 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -45,6 +45,7 @@ #define USE_AREAS #define USE_AS7341 #define USE_AS7343 +#define USE_AS734X_RGB #define USE_BINARY_SENSOR #define USE_BINARY_SENSOR_CLICK_TRIGGER #define USE_BINARY_SENSOR_FILTER diff --git a/tests/components/as734x/common.yaml b/tests/components/as734x/common.yaml index 0ad83bac4dc..48f77ac907e 100644 --- a/tests/components/as734x/common.yaml +++ b/tests/components/as734x/common.yaml @@ -42,6 +42,16 @@ sensor: name: AS7341 Clear basic saturation_level: name: AS7341 Saturation + illuminance: + name: AS7341 Illuminance + irradiance_photopic: + name: AS7341 Photopic irradiance + irradiance_par: + name: AS7341 PAR + ppfd: + name: AS7341 PPFD + color_temperature: + name: AS7341 Colour temperature - platform: as734x as734x_id: as7343_hub counts: @@ -58,6 +68,10 @@ sensor: name: AS7343 FY basic saturation_level: name: AS7343 Saturation + illuminance: + name: AS7343 Illuminance + color_temperature: + name: AS7343 Colour temperature - platform: as734x as734x_id: tcs3448_hub counts: @@ -65,3 +79,9 @@ sensor: name: TCS3448 F1 fy: name: TCS3448 FY + +text_sensor: + - platform: as734x + as734x_id: as7343_hub + rgb_hex: + name: AS7343 RGB From c101b3c101fce1f341c2b59eb1980c465c4e175a Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:38:28 +0100 Subject: [PATCH 33/50] [mipi_spi] Honour show_test_card when pages or a lambda are set (#20807) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/display/display.cpp | 1 - esphome/components/mipi_spi/mipi_spi.h | 6 +- tests/components/display/test_test_card.cpp | 80 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/components/display/test_test_card.cpp diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index f006c64be76..ba8f85463f4 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -843,7 +843,6 @@ constexpr uint8_t TESTCARD_FONT[3][8] PROGMEM = {{0x41, 0x7F, 0x7F, 0x09, 0x19, void Display::test_card() { int w = get_width(), h = get_height(), image_w, image_h; this->clear(); - this->show_test_card_ = false; image_w = std::min(w - 20, 310); image_h = std::min(h - 20, 255); int shift_x = (w - image_w) / 2; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 3a772862f31..f55ae723780 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -570,12 +570,12 @@ class MipiSpiBuffer if (this->auto_clear_enabled_) { this->clear(); } - if (this->page_ != nullptr) { + if (this->show_test_card_) { + this->test_card(); + } else if (this->page_ != nullptr) { this->page_->get_writer()(*this); } else if (this->writer_.has_value()) { (*this->writer_)(*this); - } else { - this->test_card(); } #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE esph_log_v(TAG, "Drawing from line %d took %dms", this->start_line_, millis() - lap); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp new file mode 100644 index 00000000000..555611bc093 --- /dev/null +++ b/tests/components/display/test_test_card.cpp @@ -0,0 +1,80 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/core/application.h" + +namespace esphome::display::testing { + +// Records the colour of every pixel written, so a drawn test card shows up as non-black pixels. +class TestCardDisplay : public Display { + public: + void draw_pixel_at(int x, int y, Color color) override { this->colors_.push_back(color.raw_32); } + void update() override { this->do_update_(); } + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + + bool drew_test_card() const { + return std::any_of(this->colors_.begin(), this->colors_.end(), [](uint32_t c) { return c != 0; }); + } + void reset() { this->colors_.clear(); } + + protected: + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + std::vector colors_; +}; + +namespace { +class TestCard : public ::testing::Test { + protected: + void SetUp() override { + // test_card() stops the poller, which needs the scheduler; the test main does not construct App. + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + } + int writer_calls_{0}; + display_writer_t count_writer_() { + return [this](Display &) { this->writer_calls_++; }; + } +}; +} // namespace + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfLambda) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, DrawnOnEveryUpdateInsteadOfPages) { + TestCardDisplay display; + DisplayPage page(this->count_writer_()); + display.set_pages({&page}); + display.show_test_card(); + for (int i = 0; i != 3; i++) { + display.reset(); + display.update(); + EXPECT_TRUE(display.drew_test_card()) << "update " << i; + } + EXPECT_EQ(this->writer_calls_, 0); +} + +TEST_F(TestCard, LambdaRunsWithoutTestCard) { + TestCardDisplay display; + display.set_writer(this->count_writer_()); + display.update(); + display.update(); + EXPECT_FALSE(display.drew_test_card()); + EXPECT_EQ(this->writer_calls_, 2); +} + +} // namespace esphome::display::testing From 5b234bcc201b44774a678bccf4adf1f39000b2bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 12:53:51 -1000 Subject: [PATCH 34/50] [wifi] Leave the cooldown from the loop after a credential save, not from the caller's context (#20802) --- esphome/components/wifi/wifi_component.cpp | 28 ++++++++-------------- esphome/components/wifi/wifi_component.h | 2 -- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 156ba6e9ffa..c37ec44384b 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -854,10 +854,11 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - // Skip cooldown if new credentials were provided while connecting + // Credentials not tried yet: retry whatever the driver reports. The flag outlives an adapter restart, + // which only lands back here, and start_connecting() clears it once they are tried if (this->skip_cooldown_next_cycle_) { - this->skip_cooldown_next_cycle_ = false; - this->check_connecting_finished(now); + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); break; } // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP @@ -1183,7 +1184,7 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; - // When new credentials are set (e.g., from improv), skip cooldown to retry immediately + // New credentials end the next cooldown early; start_connecting() clears this once they are tried this->skip_cooldown_next_cycle_ = true; #ifdef USE_WIFI_LEASE_CACHE // Index 0 is a different network now, so its cached lease must not be re-applied @@ -1252,29 +1253,18 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { WiFiAP sta{}; sta.set_ssid(ssid); sta.set_password(password); + // No retry here: a captive portal save runs in lwIP's context on the ESP8266, where retry_connect() yields and + // panics. set_sta() flags the credentials and loop() tries them this->set_sta(sta); - - // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) - this->connect_soon_(); -} - -void WiFiComponent::connect_soon_() { #ifdef USE_WIFI_AP_EXCLUSIVE - // New credentials from the portal: the AP pauses for them once its answer - // is out, and the STA state machine picks them up from there. + // The AP pauses for the new credentials once its answer is out, and the STA state machine picks them up if (this->ap_setup_) { this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { if (this->ap_setup_) this->pause_exclusive_ap_(); }); - return; } #endif - // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing - if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { - ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); - this->retry_connect(); - } } void WiFiComponent::start_connecting(const WiFiAP &ap) { @@ -1366,6 +1356,8 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; + // Every attempt passes through here, so the credentials set_sta() flagged are tried now + this->skip_cooldown_next_cycle_ = false; if (!this->wifi_sta_connect_(ap)) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 8cfdaaff00c..1048df19c39 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -822,8 +822,6 @@ class WiFiComponent final : public Component { return true; } - void connect_soon_(); - bool wifi_loop_(); #ifdef USE_ESP8266 void process_pending_callbacks_(); From 69215deb588e56e19571d89c10f85d582e32e6b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 12:54:34 -1000 Subject: [PATCH 35/50] [core] Make the YAML load listener hook public (#20805) --- esphome/yaml_util.py | 17 ++++++++++------- tests/unit_tests/test_yaml_util.py | 24 ++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 6b7e49d3802..d887cab959f 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -54,8 +54,11 @@ _LOADED_SECRET_NAMES: set[str] = set() # Set while a secret_values_registered context is active; the dumper records # every emitted `!secret` name into it. _EMITTED_SECRET_NAMES: set[str] | None = None -# Not thread-safe — config processing is single-threaded today. -_load_listeners: list[Callable[[Path], None]] = [] +# Called with every file the YAML loader opens; ``track_yaml_loads`` builds on it. +# Used by device-builder (esphome/device-builder), whose listener filters by +# thread because the hook is process-global; keep the name and the per-file +# ``Path`` signature. Not otherwise a supported API: use at your own risk. +load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] @@ -127,11 +130,11 @@ def track_yaml_loads() -> Generator[list[Path]]: def _on_load(fname: Path) -> None: loaded.append(Path(fname).resolve()) - _load_listeners.append(_on_load) + load_listeners.append(_on_load) try: yield loaded finally: - _load_listeners.remove(_on_load) + load_listeners.remove(_on_load) class ESPHomeDataBase: @@ -667,7 +670,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: secrets.add(Path(fname).resolve()) with track_yaml_loads() as loaded: - _load_listeners.append(_capture_secret) + load_listeners.append(_capture_secret) try: try: data = load_yaml(config_path) @@ -679,7 +682,7 @@ def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles: data, warn_on_unresolved=False, warn_on_load_error=False ) finally: - _load_listeners.remove(_capture_secret) + load_listeners.remove(_capture_secret) # Deduplicate while preserving first-seen order. seen: set[Path] = set() @@ -1120,7 +1123,7 @@ def load_yaml( def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> Any: """Load a YAML file.""" - for listener in _load_listeners: + for listener in tuple(load_listeners): listener(fname) try: with fname.open(encoding="utf-8") as f_handle: diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 2a4b5f8e5c5..8ce554f0a52 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -431,12 +431,32 @@ def test_track_yaml_loads_empty_outside_context(tmp_path: Path) -> None: def test_track_yaml_loads_cleanup_on_exception(tmp_path: Path) -> None: """Listener is removed even if the body raises.""" - before = len(yaml_util._load_listeners) + before = len(yaml_util.load_listeners) with pytest.raises(RuntimeError), yaml_util.track_yaml_loads(): raise RuntimeError("boom") - assert len(yaml_util._load_listeners) == before + assert len(yaml_util.load_listeners) == before + + +def test_load_listener_removing_itself_does_not_skip_the_next( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A listener that removes itself while notified does not skip the one after it.""" + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key: value\n") + listeners: list[Callable[[Path], None]] = [] + monkeypatch.setattr(yaml_util, "load_listeners", listeners) + seen: list[Path] = [] + + def first(fname: Path) -> None: + listeners.remove(first) + + listeners.extend((first, seen.append)) + yaml_util.load_yaml(yaml_file) + + assert seen == [yaml_file.resolve()] + assert listeners == [seen.append] def test_track_yaml_loads_no_duplicate_load_on_top_level_include_failure( From c838d4826e1143993f717c049a174aee3c65fd89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 13:09:02 -1000 Subject: [PATCH 36/50] [core] Add ESPHOME_strstr_P (#20811) --- esphome/core/progmem.h | 2 ++ tests/components/core/test_progmem_string_table.cpp | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 677ef0e2afb..02ec4778237 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -26,6 +26,7 @@ #define ESPHOME_strncmp_P strncmp_P #define ESPHOME_strncasecmp_P strncasecmp_P #define ESPHOME_strlen_P strlen_P +#define ESPHOME_strstr_P strstr_P // Type for pointers to PROGMEM strings (for use with ESPHOME_F return values) using ProgmemStr = const __FlashStringHelper *; // Storage class for PROGMEM_STRING_TABLE data; LOG_STR_ARG treats a LogString as PGM_P @@ -43,6 +44,7 @@ using ProgmemStr = const __FlashStringHelper *; #define ESPHOME_strncmp_P strncmp #define ESPHOME_strncasecmp_P strncasecmp #define ESPHOME_strlen_P strlen +#define ESPHOME_strstr_P strstr // Type for pointers to strings (no PROGMEM on non-ESP8266 platforms) using ProgmemStr = const char *; // No-op on non-ESP8266 platforms where PROGMEM itself is a no-op. diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp index 4069bd4d2ab..e6820ec50de 100644 --- a/tests/components/core/test_progmem_string_table.cpp +++ b/tests/components/core/test_progmem_string_table.cpp @@ -62,4 +62,12 @@ TEST(ProgmemStringTable, LargeTableIsScanned) { "3123456789012345678901234567890123456789"); } +TEST(ProgmemStringTable, StrstrFindsTableEntryInRamString) { + const char *line = "MODE HEAT ON"; + const auto *heat = reinterpret_cast(SmallStrings::get_progmem_str(1, SmallStrings::LAST_INDEX)); + const auto *cool = reinterpret_cast(SmallStrings::get_progmem_str(2, SmallStrings::LAST_INDEX)); + EXPECT_EQ(ESPHOME_strstr_P(line, heat), line + 5); + EXPECT_EQ(ESPHOME_strstr_P(line, cool), nullptr); +} + } // namespace esphome::core::testing From 1e89abdb134e402a88d0e05b7e3cc236902830dd Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 16 Oct 2026 01:17:46 +0200 Subject: [PATCH 37/50] [http_request] Allow a per-request timeout (#20821) Co-authored-by: p1ngb4ck Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/http_request/__init__.py | 5 +++ .../components/http_request/http_request.h | 33 ++++++++++++++----- .../http_request/http_request_arduino.cpp | 12 ++++--- .../http_request/http_request_arduino.h | 3 +- .../http_request/http_request_host.cpp | 3 +- .../http_request/http_request_host.h | 3 +- .../http_request/http_request_idf.cpp | 13 +++++--- .../http_request/http_request_idf.h | 3 +- .../components/http_request/http_request.yaml | 4 +++ 9 files changed, 57 insertions(+), 22 deletions(-) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 89fe5f8b1cc..a97aee14622 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -270,6 +270,7 @@ HTTP_REQUEST_ACTION_SCHEMA = cv.Schema( cv.Optional(CONF_ON_RESPONSE): automation.validate_automation(single=True), cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), cv.Optional(CONF_MAX_RESPONSE_BUFFER_SIZE, default="1kB"): cv.validate_bytes, + cv.Optional(CONF_TIMEOUT): cv.templatable(cv.positive_time_period_milliseconds), } ) HTTP_REQUEST_GET_ACTION_SCHEMA = automation.maybe_conf( @@ -347,6 +348,10 @@ async def http_request_action_to_code( cg.add(var.set_max_response_buffer_size(config[CONF_MAX_RESPONSE_BUFFER_SIZE])) + if CONF_TIMEOUT in config: + template_ = await cg.templatable(config[CONF_TIMEOUT], args, cg.uint32) + cg.add(var.set_timeout(template_)) + if CONF_BODY in config: template_ = await cg.templatable(config[CONF_BODY], args, cg.std_string) cg.add(var.set_body(template_)) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 424fcf84e86..713de247c5b 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -252,6 +252,8 @@ class HttpContainer : public Parented { virtual void end() = 0; void set_secure(bool secure) { this->secure_ = secure; } + void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } + uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } void set_chunked(bool chunked) { this->is_chunked_ = chunked; } size_t get_bytes_read() const { return this->bytes_read_; } @@ -283,6 +285,7 @@ class HttpContainer : public Parented { protected: size_t bytes_read_{0}; + uint32_t watchdog_timeout_{0}; bool secure_{false}; bool is_chunked_{false}; ///< True if response uses chunked transfer encoding std::vector
response_headers_{}; @@ -339,6 +342,11 @@ class HttpRequestComponent : public Component { uint32_t get_timeout() const { return this->timeout_; } void set_watchdog_timeout(uint32_t watchdog_timeout) { this->watchdog_timeout_ = watchdog_timeout; } uint32_t get_watchdog_timeout() const { return this->watchdog_timeout_; } + uint32_t get_watchdog_timeout(uint32_t timeout_ms) const { + if (this->watchdog_timeout_ == 0 || timeout_ms <= this->timeout_) + return this->watchdog_timeout_; + return this->watchdog_timeout_ + 3 * (timeout_ms - this->timeout_); + } void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } @@ -412,7 +420,7 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads - return this->perform(url, method, body, request_headers, {}); + return this->perform(url, method, body, request_headers, {}, this->timeout_); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers) { @@ -437,7 +445,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower, this->timeout_); } // Remove before 2027.1.0 @@ -453,7 +461,7 @@ class HttpRequestComponent : public Component { lower.push_back(str_lower_case(h)); // NOLINT } return this->perform(url.c_str(), method.c_str(), body, - std::vector
(request_headers.begin(), request_headers.end()), lower); + std::vector
(request_headers.begin(), request_headers.end()), lower, this->timeout_); } // Remove before 2027.1.0 @@ -463,13 +471,19 @@ class HttpRequestComponent : public Component { const std::vector &lower_case_collect_headers) { return this->perform(url.c_str(), method.c_str(), body, std::vector
(request_headers.begin(), request_headers.end()), - lower_case_collect_headers); + lower_case_collect_headers, this->timeout_); } std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, request_headers, lower_case_collect_headers); + return this->perform(url, method, body, request_headers, lower_case_collect_headers, this->timeout_); + } + std::shared_ptr start(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { + return this->perform(url, method, body, request_headers, lower_case_collect_headers, timeout_ms); } std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, @@ -480,7 +494,8 @@ class HttpRequestComponent : public Component { protected: virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) = 0; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; uint16_t redirect_limit_{}; @@ -494,6 +509,7 @@ template class HttpRequestSendAction final : public Action class HttpRequestSendAction final : public Actiontimeout_.has_value() ? this->timeout_.value(x...) : this->parent_->get_timeout(); auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, - request_headers, this->lower_case_collect_headers_); + request_headers, this->lower_case_collect_headers_, timeout); auto captured_args = std::make_tuple(x...); @@ -565,7 +582,7 @@ template class HttpRequestSendAction final : public Actionparent_->get_timeout(); + const uint32_t read_timeout = timeout; while (container->get_bytes_read() < max_length) { int read_or_error = container->read(buf + read_index, std::min(max_length - read_index, 512)); App.feed_wdt(); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index deb9c0fec25..2062122adf6 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -26,7 +26,8 @@ static constexpr int ESP8266_SSL_ERR_OOM = -1000; std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); @@ -40,8 +41,9 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); + container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(container->get_watchdog_timeout()); if (this->follow_redirects_) { container->client_.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS); @@ -90,7 +92,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons } container->client_.setReuse(true); - container->client_.setTimeout(this->timeout_); + container->client_.setTimeout(timeout_ms); if (this->useragent_ != nullptr) { container->client_.setUserAgent(this->useragent_); @@ -203,7 +205,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons // and sets content_length = bytes_read_ so is_read_complete() returns true. int HttpContainerArduino::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); WiFiClient *stream_ptr = this->client_.getStreamPtr(); if (stream_ptr == nullptr) { @@ -398,7 +400,7 @@ int HttpContainerArduino::read_chunked_(uint8_t *buf, size_t max_len, WiFiClient } void HttpContainerArduino::end() { - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); this->client_.end(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 62737f4d0d1..d5e3f499da3 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -56,7 +56,8 @@ class HttpRequestArduino final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; #ifdef USE_ESP8266 uint16_t tls_buffer_size_rx_{512}; uint16_t tls_buffer_size_tx_{512}; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 1856b97e6e9..65d9fb0255e 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -20,7 +20,8 @@ ESPHOME_LOG_TAG(TAG, "http_request"); std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t) { if (!network::is_connected()) { this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 0ae9f2e27b9..245aba7340e 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -20,7 +20,8 @@ class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } protected: diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index e429df9b971..879abb662b7 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -52,7 +52,8 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) { + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) { if (!network::is_connected()) { this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); @@ -82,7 +83,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.url = url; config.method = method_idf; - config.timeout_ms = this->timeout_; + config.timeout_ms = timeout_ms; config.disable_auto_redirect = !this->follow_redirects_; config.max_redirection_count = this->redirect_limit_; config.auth_type = HTTP_AUTH_TYPE_BASIC; @@ -104,7 +105,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch config.buffer_size_tx = this->buffer_size_tx_; const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); + const uint32_t watchdog_timeout = this->get_watchdog_timeout(timeout_ms); + watchdog::WatchdogManager wdm(watchdog_timeout); config.event_handler = http_event_handler; @@ -117,6 +119,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch std::shared_ptr container = std::make_shared(client); container->set_parent(this); + container->set_watchdog_timeout(watchdog_timeout); container->set_secure(secure); @@ -261,7 +264,7 @@ bool HttpContainerIDF::is_read_complete() const { // This is passed through as a negative return value, which callers treat as an error. int HttpContainerIDF::read(uint8_t *buf, size_t max_len) { const uint32_t start = millis(); - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); // Check if we've already read all expected content (non-chunked and no-body only). // Use the base class check here, NOT the override: esp_http_client_is_complete_data_received() @@ -304,7 +307,7 @@ void HttpContainerIDF::end() { if (this->client_ == nullptr) { return; // Already cleaned up } - watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout()); + watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); esp_http_client_close(this->client_); esp_http_client_cleanup(this->client_); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index f84dc9576bd..9e7864bb56c 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -43,7 +43,8 @@ class HttpRequestIDF final : public HttpRequestComponent { protected: std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, - const std::vector &lower_case_collect_headers) override; + const std::vector &lower_case_collect_headers, + uint32_t timeout_ms) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; uint16_t buffer_size_tx_{}; diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index b82c0581d1b..180db583469 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -31,6 +31,10 @@ esphome: request_headers: Content-Type: application/json body: "Some data" + timeout: 15s + - http_request.get: + url: https://esphome.io + timeout: !lambda "return 10000;" - http_request.post: url: https://esphome.io request_headers: From cfdaf958e907b39206d472c8e24a5720a15c01d9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 15 Oct 2026 19:32:35 -0400 Subject: [PATCH 38/50] [audio][i2s_audio] Fix 16-bit mono playback at 44.1 kHz on the original ESP32 (#20806) --- .../audio/audio_transfer_buffer.cpp | 17 ++++++++ .../components/audio/audio_transfer_buffer.h | 6 +++ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 10 ++++- .../i2s_audio/speaker/i2s_audio_speaker.h | 6 ++- .../speaker/i2s_audio_speaker_standard.cpp | 40 ++++++++++++++----- .../speaker/i2s_audio_speaker_standard.h | 3 ++ 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index 01fd4bb68a6..b129efd7f42 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -268,6 +268,23 @@ void RingBufferAudioSource::clear_buffered_data() { this->ring_buffer_->reset(); } +size_t RingBufferAudioSource::expose_padded_partial(size_t frame_bytes) { + if (this->current_available_ > 0 || this->queued_length_ > 0) { + return 0; + } + // Moves any trailing bytes still inside a held item into the splice buffer + this->release_item_(); + const size_t real_bytes = this->splice_length_; + if (real_bytes == 0 || frame_bytes == 0 || real_bytes % frame_bytes != 0) { + return 0; + } + std::memset(this->splice_buffer_ + real_bytes, 0, this->alignment_bytes_ - real_bytes); + this->splice_length_ = 0; + this->current_data_ = this->splice_buffer_; + this->current_available_ = this->alignment_bytes_; + return real_bytes; +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index 074684f0686..d28328138db 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -254,6 +254,12 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. void clear_buffered_data(); + /// @brief Zero-pads held whole frames short of an alignment unit and exposes them, so a stream's tail is not + /// stranded. A held partial frame is left to wait for its remaining bytes. + /// @param frame_bytes Bytes per audio frame + /// @return Real (unpadded) bytes exposed, or 0 if nothing is exposable + size_t expose_padded_partial(size_t frame_bytes); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c644ece5428..85c0e6358a2 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -372,10 +372,18 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea this->current_stream_info_.bytes_to_samples(bytes_read)); } +bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const { +#ifdef USE_ESP32_VARIANT_ESP32 + return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16; +#else + return false; +#endif // USE_ESP32_VARIANT_ESP32 +} + void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { + if (this->esp32_mono_swap_needed_()) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index d73d2da8794..07b7e5d1905 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -160,9 +160,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); + /// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped. + bool esp32_mono_swap_needed_() const; + /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is - /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. + /// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 1a6b021fffa..d6c77d28b42 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -44,10 +44,10 @@ static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURAT // it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back // from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a // noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. -static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info, uint32_t frame_multiple) { const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); - return std::min(frames_from_duration, max_frames); + return (std::min(frames_from_duration, max_frames) / frame_multiple) * frame_multiple; } void I2SAudioSpeaker::dump_config() { @@ -85,10 +85,12 @@ void I2SAudioSpeaker::run_speaker_task() { // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); - // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and - // avoids unnecessary single-frame splices. + // Hand over whole ESP32 mono sample pairs so writes stay word-aligned + const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_(); + const bool pair_aligned = source_alignment > bytes_per_frame; + // Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices. const size_t ring_buffer_size = - (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / source_alignment) * source_alignment; // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. const uint8_t channels = this->current_stream_info_.get_channels(); @@ -108,7 +110,8 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes( + dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_())); } // dma_buffer_bytes counts output-format bytes; convert with the output stream info. const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); @@ -128,7 +131,7 @@ void I2SAudioSpeaker::run_speaker_task() { std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, - static_cast(bytes_per_frame)); + static_cast(source_alignment)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -295,9 +298,20 @@ void I2SAudioSpeaker::run_speaker_task() { // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source // currently exposes. Frame counts are shared between input and output; only the byte widths differ. - const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); const uint32_t frames_room = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + uint32_t padding_frames = 0; + if (pair_aligned && frames_available == 0 && frames_room >= 2 && !audio_source->has_buffered_data()) { + // Silence padding follows, so write a held half pair now with a silent partner rather than strand it. + // Only the real frame is reported as played. + const size_t real_bytes = audio_source->expose_padded_partial(bytes_per_frame); + if (real_bytes > 0) { + this->apply_software_volume_(audio_source->mutable_data(), real_bytes); + frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + padding_frames = frames_available - this->current_stream_info_.bytes_to_frames(real_bytes); + } + } const uint32_t frames_to_write = std::min(frames_available, frames_room); if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer @@ -326,13 +340,17 @@ void I2SAudioSpeaker::run_speaker_task() { ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, (unsigned) output_bytes); audio_source->consume(input_bytes); - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; partial_write_failure = true; break; } audio_source->consume(input_bytes); bytes_written_total += output_bytes; - real_frames_total += frames_to_write; + real_frames_total += frames_to_write - padding_frames; + if (padding_frames > 0) { + // The source was already empty; skip waiting on another fill() before the silence padding + break; + } } if (real_frames_total > 0) { last_data_received_time = millis(); @@ -440,7 +458,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream this->current_stream_info_ = audio_stream_info; // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. - uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 4b52dcd52a9..01390dfdf70 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -24,6 +24,9 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase { void run_speaker_task() override; esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + /// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned. + uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; } + I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; }; From 68179603656125b6f2d7eb66c5c0d9796e7fc184 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 15 Oct 2026 19:38:03 -0400 Subject: [PATCH 39/50] [audio] Deprecate AudioSourceTransferBuffer (#20801) --- esphome/components/audio/audio_decoder.cpp | 2 +- esphome/components/audio/audio_resampler.cpp | 5 +- .../audio/audio_transfer_buffer.cpp | 68 ++++++++++--------- .../components/audio/audio_transfer_buffer.h | 25 +++++-- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 2 +- .../speaker/i2s_audio_speaker_standard.cpp | 2 +- .../micro_wake_word/micro_wake_word.cpp | 2 +- .../mixer/speaker/mixer_speaker.cpp | 2 +- .../components/sound_level/sound_level.cpp | 3 +- .../voice_assistant/voice_assistant.cpp | 7 +- 10 files changed, 65 insertions(+), 53 deletions(-) diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 13dea3f9bc4..a069bbda427 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -188,7 +188,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it // processed, so the source does not need to accumulate or stitch chunks across fill() calls. - this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); const size_t available_before_decode = this->input_buffer_->available(); diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index bef62ce190b..8f35bac9012 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -129,9 +129,8 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource - // (there is no intermediate transfer buffer to compact). - this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); + // Expose a chunk of the ring buffer's internal storage. + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); if (this->audio_source_->available() == 0) { // No samples available to process diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index b129efd7f42..2082a270820 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -20,16 +20,6 @@ std::unique_ptr AudioSinkTransferBuffer::create(size_t return sink_buffer; } -std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { - std::unique_ptr source_buffer = make_unique(); - - if (!source_buffer->allocate_buffer_(buffer_size)) { - return nullptr; - } - - return source_buffer; -} - size_t AudioTransferBuffer::free() const { if (this->buffer_size_ == 0) { return 0; @@ -132,27 +122,6 @@ void AudioTransferBuffer::deallocate_buffer_() { this->buffer_length_ = 0; } -size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { - if (pre_shift) { - // Shift data in buffer to start - if (this->buffer_length_ > 0) { - std::memmove(this->buffer_, this->data_start_, this->buffer_length_); - } - this->data_start_ = this->buffer_; - } - - size_t bytes_to_read = AudioTransferBuffer::free(); - size_t bytes_read = 0; - if (bytes_to_read > 0) { - if (this->ring_buffer_ != nullptr) { - bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); - } - - this->increase_buffer_length(bytes_read); - } - return bytes_read; -} - size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bool post_shift) { size_t bytes_written = 0; if (this->available()) { @@ -192,9 +161,44 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->available() > 0); } +// AudioSourceTransferBuffer is deprecated. Remove before 2027.5.0. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +std::unique_ptr AudioSourceTransferBuffer::create(size_t buffer_size) { + std::unique_ptr source_buffer = make_unique(); + + if (!source_buffer->allocate_buffer_(buffer_size)) { + return nullptr; + } + + return source_buffer; +} + +size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_wait, bool pre_shift) { + if (pre_shift) { + // Shift data in buffer to start + if (this->buffer_length_ > 0) { + std::memmove(this->buffer_, this->data_start_, this->buffer_length_); + } + this->data_start_ = this->buffer_; + } + + size_t bytes_to_read = AudioTransferBuffer::free(); + size_t bytes_read = 0; + if (bytes_to_read > 0) { + if (this->ring_buffer_ != nullptr) { + bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); + } + + this->increase_buffer_length(bytes_read); + } + return bytes_read; +} + size_t AudioSourceTransferBuffer::free() const { return AudioTransferBuffer::free(); } bool AudioSourceTransferBuffer::has_buffered_data() const { return AudioTransferBuffer::has_buffered_data(); } +#pragma GCC diagnostic pop void ConstAudioSourceBuffer::set_data(const uint8_t *data, size_t length) { this->data_start_ = data; @@ -293,7 +297,7 @@ bool RingBufferAudioSource::has_buffered_data() const { return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0); } -size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) { +size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait) { if (this->current_available_ > 0) { // Caller has not finished consuming the current exposure return 0; diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index d28328138db..e7a103fdc8b 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/ring_buffer/ring_buffer.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #ifdef USE_SPEAKER #include "esphome/components/speaker/speaker.h" @@ -152,13 +153,20 @@ class AudioReadableBuffer { /// @brief Refills the buffer from its source. No-op by default for read-only buffers. /// @param ticks_to_wait FreeRTOS ticks to block while waiting for data - /// @param pre_shift If true, shifts existing data to the start of the buffer before reading /// @return Number of bytes read - virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return 0; } - size_t fill(TickType_t ticks_to_wait) { return this->fill(ticks_to_wait, true); } + virtual size_t fill(TickType_t ticks_to_wait) { return 0; } + + // Remove before 2027.5.0 + ESPDEPRECATED("Use fill(ticks_to_wait) instead. Removed in 2027.5.0.", "2026.11.0") + virtual size_t fill(TickType_t ticks_to_wait, bool pre_shift) { return this->fill(ticks_to_wait); } }; -class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadableBuffer { +// Remove before 2027.5.0 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +class ESPDEPRECATED("Use RingBufferAudioSource instead. Removed in 2027.5.0.", "2026.11.0") AudioSourceTransferBuffer + : public AudioTransferBuffer, + public AudioReadableBuffer { /* * @brief A class that implements a transfer buffer for audio sources. * Supports reading audio data from a ring buffer into the transfer buffer for processing. @@ -189,10 +197,12 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab size_t free() const override; void consume(size_t bytes) override { this->decrease_buffer_length(bytes); } bool has_buffered_data() const override; + size_t fill(TickType_t ticks_to_wait) override { return this->transfer_data_from_source(ticks_to_wait); } size_t fill(TickType_t ticks_to_wait, bool pre_shift) override { return this->transfer_data_from_source(ticks_to_wait, pre_shift); } }; +#pragma GCC diagnostic pop /// @brief A lightweight read-only audio buffer for const data sources (e.g., flash memory). /// Does not allocate memory or transfer data from external sources. @@ -246,9 +256,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { size_t available() const override { return this->current_available_; } void consume(size_t bytes) override; bool has_buffered_data() const override; - /// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed - /// exposure stays in place and fill() returns 0 until it is fully consumed. - size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// An unconsumed exposure stays in place and fill() returns 0 until it is fully consumed. + size_t fill(TickType_t ticks_to_wait) override; + // Keeps the deprecated fill(ticks_to_wait, pre_shift) overload visible. Remove before 2027.5.0. + using AudioReadableBuffer::fill; /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 5054478ceb2..3938d777717 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -334,7 +334,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { if (!this->pause_state_) { while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) { if (audio_source->available() == 0) { - size_t bytes_read = audio_source->fill(read_timeout_ticks, false); + size_t bytes_read = audio_source->fill(read_timeout_ticks); if (bytes_read == 0) { break; // No upstream data within the read budget; silence-pad the remainder. } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index d6c77d28b42..16abb2f22ef 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -289,7 +289,7 @@ void I2SAudioSpeaker::run_speaker_task() { if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { - size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); + size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2); if (bytes_read > 0) { // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6b33a3a1b56..872dd7bd79a 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -221,7 +221,7 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); // The frontend buffers samples internally and only emits a feature once it has a full window, so we can // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 23867aef914..f52199093f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -302,7 +302,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptrfill(ticks_to_wait, false); + size_t bytes_read = audio_source->fill(ticks_to_wait); uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index 1e51e45b7b3..a9bd0e257f5 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -81,8 +81,7 @@ void SoundLevelComponent::loop() { } // Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop. - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); if (this->audio_source_->available() == 0) { // No new audio available for processing diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 1d66ca5950f..a0fa321812c 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -224,11 +224,11 @@ void VoiceAssistant::stream_api_audio_() { while (true) { // fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available() // reports the currently exposed bytes either way. - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); size_t available2 = 0; if (this->audio_source2_ != nullptr) { - this->audio_source2_->fill(0, false); + this->audio_source2_->fill(0); available2 = this->audio_source2_->available(); } @@ -383,14 +383,13 @@ void VoiceAssistant::loop() { break; // State changed when udp server port received } case State::STREAMING_MICROPHONE: { - // pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact). if (this->audio_mode_ == AUDIO_MODE_API) { this->stream_api_audio_(); } else { // UDP (will eventually be deprecated) // Only the primary microphone channel is used while (true) { - this->audio_source_->fill(0, false); + this->audio_source_->fill(0); size_t available = this->audio_source_->available(); if (available == 0) { break; From 0399379c0dd22134c42ca549bf530e7f657dc2de Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:56:40 -0400 Subject: [PATCH 40/50] [lp5562] Pin the test fixture to the shared I2C bus (#20856) --- tests/components/lp5562/common.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/lp5562/common.yaml b/tests/components/lp5562/common.yaml index c1608f1a3cf..5bf3406938b 100644 --- a/tests/components/lp5562/common.yaml +++ b/tests/components/lp5562/common.yaml @@ -1,6 +1,7 @@ lp5562: - address: 0x30 id: lp5562_1 + i2c_id: i2c_bus output: - platform: lp5562 From 6dbd15b90375c709f86b4fba29483f5f216571c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 15:02:26 -1000 Subject: [PATCH 41/50] [pca9505] Validate interrupt_pin with the shared expander validator (#20853) --- esphome/components/pca9505/__init__.py | 4 ++-- tests/component_tests/gpio_expander/test_init.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 7b0d5ac1732..d50e8205a17 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -32,7 +32,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCA9505Component), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/tests/component_tests/gpio_expander/test_init.py b/tests/component_tests/gpio_expander/test_init.py index 806b1775d29..34e77d84a2f 100644 --- a/tests/component_tests/gpio_expander/test_init.py +++ b/tests/component_tests/gpio_expander/test_init.py @@ -49,6 +49,7 @@ def test_allow_other_uses_rejected(stage_esp32: None) -> None: "pi4ioe5v6408", "mcp23016", "mcp23017", + "pca9505", ], ) def test_component_schemas_route_through_validator( From 6555da29063a38a46734bb1a29b355d6daca4915 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 19:57:11 -1000 Subject: [PATCH 42/50] [display] Fall back per pixel when a bulk blit does not match the panel (#20652) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/display/display.h | 4 + .../components/ili9xxx/ili9xxx_display.cpp | 30 +++++- esphome/components/mipi_dsi/display.py | 1 + esphome/components/mipi_dsi/mipi_dsi.cpp | 92 +++++++++++++------ esphome/components/mipi_dsi/mipi_dsi.h | 34 +++++++ esphome/components/mipi_rgb/display.py | 1 + esphome/components/mipi_rgb/mipi_rgb.cpp | 85 ++++++++++++----- esphome/components/mipi_rgb/mipi_rgb.h | 32 +++++++ esphome/components/mipi_spi/mipi_spi.h | 66 ++++++++++++- esphome/components/qspi_dbi/qspi_dbi.cpp | 37 +++++++- esphome/components/qspi_dbi/qspi_dbi.h | 3 + .../components/rpi_dpi_rgb/rpi_dpi_rgb.cpp | 13 ++- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 + esphome/components/st7701s/st7701s.cpp | 13 ++- esphome/components/st7701s/st7701s.h | 2 + 15 files changed, 346 insertions(+), 69 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index f17b7d86f71..b43a3221518 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -364,6 +364,10 @@ class Display : public PollingComponent { * \param x_pad How many pixels are in each line after the end of the pixels to be copied. * * The length of each source buffer line (stride) will be x_offset + w + x_pad. + * + * An override that copies the block as is must send any other format, byte order, a software + * rotation or an active clipping rectangle through this base version, which draws per pixel. + * The block must lie on the screen. */ virtual 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); diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index 0ed18c45da0..870d4e7098d 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -4,6 +4,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::ili9xxx { static const uint16_t SPI_SETUP_US = 100; // estimated fixed overhead in microseconds for an SPI write @@ -313,7 +316,8 @@ void ILI9XXXDisplay::display_() { this->y_high_ = 0; } -// note that this bypasses the buffer and writes directly to the display. +// Outside update() this bypasses the buffer and writes directly to the display. Inside it the block goes into the +// buffer, or the flush at the end of update() would paint the old buffer contents over it. void ILI9XXXDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { @@ -322,14 +326,34 @@ void ILI9XXXDisplay::draw_pixels_at(int x_start, int y_start, int w, int h, cons // if color mapping or software rotation is required, hand this off to the parent implementation. This will // do color conversion pixel-by-pixel into the buffer and draw it later. If this is happening the user has not // configured the renderer well. - if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || !big_endian) { + if (this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || bitness != display::COLOR_BITNESS_565 || !big_endian || + order != display::COLOR_ORDER_RGB || this->is_clipping() || + (this->prossing_update_ && this->buffer_color_mode_ != BITS_16)) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } - this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); // x_ and y_offset are offsets into the source buffer, unrelated to our own offsets into the display. auto stride = x_offset + w + x_pad; + if (this->prossing_update_) { + if (!this->check_buffer_()) + return; + // The buffer holds big endian RGB565, the same layout as the block + const int first_col = std::max(0, -x_start), end_col = std::min(w, this->get_width_internal() - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, this->get_height_internal() - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * 2, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); + return; + } + this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); if (!this->is_18bitdisplay_) { if (x_offset == 0 && x_pad == 0 && y_offset == 0) { // we could deal here with a non-zero y_offset, but if x_offset is zero, y_offset probably will be so don't bother diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 6bf94bf364b..97af7a33243 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -198,6 +198,7 @@ async def to_code(config: ConfigType) -> None: model.get_dimensions(config) ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) + cg.add(var.set_big_endian(config[CONF_BYTE_ORDER] == BYTE_ORDER_BIG)) sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 64166012c20..31bf69b178c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -3,6 +3,9 @@ #include "mipi_dsi.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::mipi_dsi { // Maximum bytes to log for init commands (truncated if larger) @@ -180,6 +183,7 @@ void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -189,7 +193,13 @@ void MipiDsi::update() { } else { this->stop_poller(); } - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + this->updating_ = false; + this->flush_dirty_(); +} + +void MipiDsi::flush_dirty_() { + // Inside update() the one flush at its end sends everything + if (this->updating_ || this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_); int w = this->x_high_ - this->x_low_ + 1; @@ -207,11 +217,34 @@ void MipiDsi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != this->color_depth_) { + if (bitness != this->color_depth_ || order != display::COLOR_ORDER_RGB || + (bitness == display::COLOR_BITNESS_565 && big_endian != this->big_endian_) || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + this->mark_dirty_(x_start, y_start, w, h); + this->flush_dirty_(); + return; + } + if (this->buffer_ != nullptr) { + // With a buffer the block goes through it, or a flush of the old contents would paint over it + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t bytes_per_pixel = 3 - this->color_depth_; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * bytes_per_pixel, + ptr + ((y_offset + y) * stride + x_offset + first_col) * bytes_per_pixel, + (end_col - first_col) * bytes_per_pixel); + } + if (this->updating_) { + this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); + } else { + this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, + x_offset + first_col, y_offset + first_row, x_pad + w - end_col); + } return; } this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); @@ -259,26 +292,37 @@ bool MipiDsi::check_buffer_() { return true; } +// Widen the dirty box to the drawn rectangle, even where the buffer already held the same values. +void MipiDsi::mark_dirty_(int x_start, int y_start, int w, int h) { + int x1 = x_start, y1 = y_start; + int x2 = x_start + w - 1, y2 = y_start + h - 1; + this->rotate_(x1, y1); + this->rotate_(x2, y2); + const int right = static_cast(this->width_) - 1; + const int bottom = static_cast(this->height_) - 1; + const int left = std::clamp(std::min(x1, x2), 0, right); + const int top = std::clamp(std::min(y1, y2), 0, bottom); + x2 = std::clamp(std::max(x1, x2), 0, right); + y2 = std::clamp(std::max(y1, y2), 0, bottom); + // An empty box is low above high, so it is replaced rather than widened + if (this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) { + this->x_low_ = left; + this->x_high_ = x2; + this->y_low_ = top; + this->y_high_ = y2; + return; + } + this->x_low_ = std::min(this->x_low_, left); + this->x_high_ = std::max(this->x_high_, x2); + this->y_low_ = std::min(this->y_low_, top); + this->y_high_ = std::max(this->y_high_, y2); +} + void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (this->is_point_clipped(x, y)) return; - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } + this->rotate_(x, y); if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } @@ -288,9 +332,7 @@ void MipiDsi::draw_pixel_at(int x, int y, Color color) { switch (this->color_depth_) { case display::COLOR_BITNESS_565: { auto *ptr_16 = reinterpret_cast(this->buffer_); - uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); - uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); - uint16_t new_color = lo_byte | (hi_byte << 8); // little endian + const uint16_t new_color = this->to_panel_565_(color); if (ptr_16[pos] == new_color) return; ptr_16[pos] = new_color; @@ -328,9 +370,7 @@ void MipiDsi::fill(Color color) { switch (this->color_depth_) { case display::COLOR_BITNESS_565: { auto *ptr_16 = reinterpret_cast(this->buffer_); - uint8_t hi_byte = static_cast(color.r & 0xF8) | (color.g >> 5); - uint8_t lo_byte = static_cast((color.g & 0x1C) << 3) | (color.b >> 3); - uint16_t new_color = lo_byte | (hi_byte << 8); // little endian + const uint16_t new_color = this->to_panel_565_(color); std::fill_n(ptr_16, this->width_ * this->height_, new_color); break; } diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index ba3f29ca05a..dd1b0dc5f90 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -6,16 +6,20 @@ // only applicable on ESP32-P4 #ifdef USE_ESP32_VARIANT_ESP32P4 #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/gpio.h" #include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_io.h" #include "esp_lcd_mipi_dsi.h" +#include + namespace esphome::mipi_dsi { ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); @@ -39,6 +43,7 @@ class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } @@ -81,6 +86,33 @@ class MipiDsi final : public display::Display { void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); + /// Send the region touched since the last flush to the panel. + void flush_dirty_(); + // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them + void rotate_(int &x, int &y) const { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = this->width_ - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = this->height_ - y - 1; + break; + } + } + void mark_dirty_(int x_start, int y_start, int w, int h); + /// Pack a colour as the 565 value the panel's byte order expects in memory. + uint16_t to_panel_565_(Color color) const { + const uint16_t rgb565 = display::ColorUtil::color_to_565(color); + return this->big_endian_ ? convert_big_endian(rgb565) : rgb565; + } GPIOPin *reset_pin_{nullptr}; std::vector enable_pins_{}; size_t width_{}; @@ -101,6 +133,8 @@ class MipiDsi final : public display::Display { bool invert_colors_{}; display::ColorBitness color_depth_; + bool big_endian_{false}; + bool updating_{false}; uint8_t pixel_mode_{}; esp_lcd_panel_handle_t handle_{}; diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 71234c2f25b..6e508dd7c44 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -290,6 +290,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) + cg.add(var.set_big_endian(config[CONF_BYTE_ORDER] == BYTE_ORDER_BIG)) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 4928b6f6672..2bc300e5b82 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -4,6 +4,9 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" + +#include +#include #include #include #include @@ -185,6 +188,7 @@ void MipiRgb::update() { if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -194,7 +198,13 @@ void MipiRgb::update() { } else { this->stop_poller(); } - if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) + this->updating_ = false; + this->flush_dirty_(); +} + +void MipiRgb::flush_dirty_() { + // Inside update() the one flush at its end sends everything + if (this->updating_ || this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; ESP_LOGV(TAG, "x_low %d, y_low %d, x_high %d, y_high %d", this->x_low_, this->y_low_, this->x_high_, this->y_high_); int w = this->x_high_ - this->x_low_ + 1; @@ -212,12 +222,28 @@ void MipiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || big_endian != this->big_endian_ || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); - this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(this->buffer_), x_start, y_start, - this->width_ - w - x_start); + this->mark_dirty_(x_start, y_start, w, h); + this->flush_dirty_(); + } else if (this->buffer_ != nullptr) { + // With a buffer the block goes through it, or a flush of the old contents would paint over it + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + (y_start + y) * this->width_ + x_start + first_col, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + if (this->updating_) { + this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); + } else { + this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, + x_offset + first_col, y_offset + first_row, x_pad + w - end_col); + } } else { this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } @@ -260,33 +286,44 @@ bool MipiRgb::check_buffer_() { return true; } +// Widen the dirty box to the drawn rectangle, even where the buffer already held the same values. +void MipiRgb::mark_dirty_(int x_start, int y_start, int w, int h) { + int x1 = x_start, y1 = y_start; + int x2 = x_start + w - 1, y2 = y_start + h - 1; + this->rotate_(x1, y1); + this->rotate_(x2, y2); + const int right = static_cast(this->width_) - 1; + const int bottom = static_cast(this->height_) - 1; + const int left = std::clamp(std::min(x1, x2), 0, right); + const int top = std::clamp(std::min(y1, y2), 0, bottom); + x2 = std::clamp(std::max(x1, x2), 0, right); + y2 = std::clamp(std::max(y1, y2), 0, bottom); + // An empty box is low above high, so it is replaced rather than widened + if (this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) { + this->x_low_ = left; + this->x_high_ = x2; + this->y_low_ = top; + this->y_high_ = y2; + return; + } + this->x_low_ = std::min(this->x_low_, left); + this->x_high_ = std::max(this->x_high_, x2); + this->y_low_ = std::min(this->y_low_, top); + this->y_high_ = std::max(this->y_high_, y2); +} + void MipiRgb::draw_pixel_at(int x, int y, Color color) { if (this->is_point_clipped(x, y) || this->is_failed()) return; - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } + this->rotate_(x, y); if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; - uint16_t new_color = convert_big_endian(display::ColorUtil::color_to_565(color)); + uint16_t new_color = this->to_panel_565_(color); if (this->buffer_[pos] == new_color) return; this->buffer_[pos] = new_color; @@ -311,7 +348,7 @@ void MipiRgb::fill(Color color) { } auto *ptr_16 = reinterpret_cast(this->buffer_); - uint16_t new_color = convert_big_endian(display::ColorUtil::color_to_565(color)); + uint16_t new_color = this->to_panel_565_(color); std::fill_n(ptr_16, this->width_ * this->height_, new_color); this->x_low_ = 0; this->y_low_ = 0; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 01b1ebee91f..393e052b26b 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -4,7 +4,9 @@ #include "esphome/core/gpio.h" #include "esphome/core/log.h" #include "esphome/components/display/display.h" +#include "esphome/components/display/display_color_utils.h" #include +#include #ifdef USE_SPI #include "esphome/components/spi/spi.h" #endif @@ -39,6 +41,7 @@ class MipiRgb : public display::Display { display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } + void set_big_endian(bool big_endian) { this->big_endian_ = big_endian; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } void add_data_pin(InternalGPIOPin *data_pin, size_t index) { this->data_pins_[index] = data_pin; }; @@ -71,6 +74,33 @@ class MipiRgb : public display::Display { void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); bool check_buffer_(); + /// Pack a colour as the 565 value the panel's byte order expects in memory. + uint16_t to_panel_565_(Color color) const { + const uint16_t rgb565 = display::ColorUtil::color_to_565(color); + return this->big_endian_ ? convert_big_endian(rgb565) : rgb565; + } + /// Send the region touched since the last flush to the panel. + void flush_dirty_(); + // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them + void rotate_(int &x, int &y) const { + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = this->width_ - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = this->width_ - x - 1; + y = this->height_ - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = this->height_ - y - 1; + break; + } + } + void mark_dirty_(int x_start, int y_start, int w, int h); void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); @@ -91,6 +121,8 @@ class MipiRgb : public display::Display { const char *model_{"Unknown"}; bool invert_colors_{}; display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; + bool big_endian_{true}; + bool updating_{false}; size_t width_; size_t height_; uint16_t *buffer_{nullptr}; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index f55ae723780..cbf025e8c31 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "esphome/components/spi/spi.h" @@ -205,9 +207,8 @@ class MipiSpi : public display::Display, return; if (w <= 0 || h <= 0) return; - if (get_pixel_mode(bitness) != BUFFERPIXEL || big_endian != IS_BIG_ENDIAN) { - // note that the usual logging macros are banned in header files, so use their replacement - esph_log_e(TAG, "Unsupported color depth or bit order"); + if (!this->is_native_block_(order, bitness, big_endian)) { + this->draw_pixels_slow(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } this->write_to_display_(x_start, y_start, w, h, reinterpret_cast(ptr), x_offset, y_offset, @@ -419,6 +420,18 @@ class MipiSpi : public display::Display, } } + // True when the block is already in the buffer pixel format and can be copied without per pixel work + bool is_native_block_(display::ColorOrder order, display::ColorBitness bitness, bool big_endian) const { + return get_pixel_mode(bitness) == BUFFERPIXEL && big_endian == IS_BIG_ENDIAN && order == display::COLOR_ORDER_RGB && + (HAS_HARDWARE_ROTATION || this->rotation_ == display::DISPLAY_ROTATION_0_DEGREES) && !this->is_clipping(); + } + + // Without a buffer there is nothing to convert a foreign block into, so it is dropped + virtual void draw_pixels_slow(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) { + esph_log_e(TAG, "Unsupported color depth, order, bit order or rotation"); + } + /** * Writes a buffer to the display. * @@ -570,6 +583,7 @@ class MipiSpiBuffer if (this->auto_clear_enabled_) { this->clear(); } + this->updating_ = true; if (this->show_test_card_) { this->test_card(); } else if (this->page_ != nullptr) { @@ -577,6 +591,7 @@ class MipiSpiBuffer } else if (this->writer_.has_value()) { (*this->writer_)(*this); } + this->updating_ = false; #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE esph_log_v(TAG, "Drawing from line %d took %dms", this->start_line_, millis() - lap); lap = millis(); @@ -610,6 +625,42 @@ class MipiSpiBuffer #endif } + // While update() runs the writer, a block goes into the buffer slice, or the slice flush would paint over it. + // Outside update() (e.g. LVGL) it is written straight to the display. + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (!this->updating_) { + MipiSpiBuffer::MipiSpi::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, + y_offset, x_pad); + return; + } + if (this->is_failed() || w <= 0 || h <= 0) + return; + if (!this->is_native_block_(order, bitness, big_endian)) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + // Only the rows inside the current slice are kept + const int first_col = std::max(0, -x_start); + const int end_col = std::min(w, this->get_width_internal() - x_start); + const int first_row = std::max(0, this->start_line_ - y_start); + const int end_row = std::min(h, this->end_line_ - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + const size_t stride = x_offset + w + x_pad; + const size_t buffer_stride = round_buffer(this->get_width_internal()); + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + (y_start + y - this->start_line_) * buffer_stride + x_start + first_col, + ptr + ((y_offset + y) * stride + x_offset + first_col) * sizeof(BUFFERTYPE), + (end_col - first_col) * sizeof(BUFFERTYPE)); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); + } + // With hardware rotation the buffer is written in drawn coordinates. bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } @@ -665,8 +716,12 @@ class MipiSpiBuffer } protected: - // Rotate the coordinates to match the display orientation. - + void draw_pixels_slow(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, + int x_pad) override { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { if constexpr (BUFFERPIXEL == PIXEL_MODE_8) { @@ -688,6 +743,7 @@ class MipiSpiBuffer uint16_t y_high_{0}; uint16_t start_line_{0}; uint16_t end_line_{1}; + bool updating_{false}; }; } // namespace esphome::mipi_spi diff --git a/esphome/components/qspi_dbi/qspi_dbi.cpp b/esphome/components/qspi_dbi/qspi_dbi.cpp index 5b4974b6317..ce0df1beb50 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.cpp +++ b/esphome/components/qspi_dbi/qspi_dbi.cpp @@ -3,6 +3,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::qspi_dbi { // Maximum bytes to log in verbose hex output @@ -32,7 +35,9 @@ void QspiDbi::update() { if (!this->setup_complete_) { return; } + this->updating_ = true; this->do_update_(); + this->updating_ = false; if (this->buffer_ == nullptr || this->x_low_ > this->x_high_ || this->y_low_ > this->y_high_) return; // Some chips require that the drawing window be aligned on certain boundaries @@ -139,9 +144,14 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 if (w <= 0 || h <= 0) return; if (bitness != display::COLOR_BITNESS_565 || order != this->color_mode_ || - big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST)) { + big_endian != (this->bit_order_ == spi::BIT_ORDER_MSB_FIRST) || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; + } else if (this->updating_) { + // Inside update() the block goes into the buffer, or the flush at its end would paint over it + this->copy_to_buffer_(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; } else if (this->draw_from_origin_) { auto stride = x_offset + w + x_pad; for (int y = 0; y != h; y++) { @@ -159,6 +169,31 @@ void QspiDbi::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } +void QspiDbi::copy_to_buffer_(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 buffer holds big endian RGB565 + if (!big_endian || order != display::COLOR_ORDER_RGB) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); + const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); + if (first_col >= end_col || first_row >= end_row) + return; + this->check_buffer_(); + if (this->buffer_ == nullptr) + return; + const size_t stride = x_offset + w + x_pad; + for (int y = first_row; y != end_row; y++) { + memcpy(this->buffer_ + ((y_start + y) * this->width_ + x_start + first_col) * 2, + ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + } + this->x_low_ = std::min(this->x_low_, x_start + first_col); + this->y_low_ = std::min(this->y_low_, y_start + first_row); + this->x_high_ = std::max(this->x_high_, x_start + end_col - 1); + this->y_high_ = std::max(this->y_high_, y_start + end_row - 1); +} + void QspiDbi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad) { this->set_addr_window_(x_start, y_start, x_start + w - 1, y_start + h - 1); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index eca062f4711..1c2565625b2 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -116,6 +116,8 @@ class QspiDbi final : public display::DisplayBuffer, display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, int x_pad); + void copy_to_buffer_(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 RM67162 in quad SPI mode seems to work like this (not in the datasheet, this is deduced from the * sample code.) @@ -160,6 +162,7 @@ class QspiDbi final : public display::DisplayBuffer, bool mirror_x_{}; bool mirror_y_{}; bool draw_from_origin_{false}; + bool updating_{false}; unsigned draw_rounding_{2}; uint8_t brightness_{0xD0}; const char *model_{"Unknown"}; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index f2f25741f33..9f92a850f55 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -51,13 +51,17 @@ void RpiDpiRgb::draw_pixels_at(int x_start, int y_start, int w, int h, const uin display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } + this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); +} + +void RpiDpiRgb::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, + int y_offset, int x_pad) { x_start += this->offset_x_; y_start += this->offset_y_; esp_err_t err = ESP_OK; @@ -122,8 +126,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { } auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); - this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, - 0, 0, 0); + this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0); this->feed_wdt_per_pixel_(); } diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index ad2f6e80c26..5f1f3be0191 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -62,6 +62,8 @@ class RpiDpiRgb final : public display::Display { void dump_config() override; protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } void reset_display_() const; diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 47b200c2de6..756473dae71 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -54,13 +54,17 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { if (w <= 0 || h <= 0) return; - // if color mapping is required, pass the buck. - // note that endianness is not considered here - it is assumed to match! - if (bitness != display::COLOR_BITNESS_565) { + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || !big_endian || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); return; } + this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); +} + +void ST7701S::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { x_start += this->offset_x_; y_start += this->offset_y_; esp_err_t err; @@ -105,8 +109,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { } auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); - this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, - 0, 0, 0); + this->write_to_display_(x, y, 1, 1, (const uint8_t *) &pixel, 0, 0, 0); this->feed_wdt_per_pixel_(); } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index fe29d794141..e34b980e73b 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -78,6 +78,8 @@ class ST7701S final : public display::Display, // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); void write_command_(uint8_t value); void write_data_(uint8_t value); void write_sequence_(uint8_t cmd, size_t len, const uint8_t *bytes); From 51b1fdcc8b509db28e9e59b1c8a8a4121c26a23a Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Fri, 16 Oct 2026 09:35:46 +0200 Subject: [PATCH 43/50] [i2s_audio] Add full duplex support (#19959) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/i2s_audio/__init__.py | 88 ++++++++++- esphome/components/i2s_audio/i2s_audio.cpp | 94 ++++++++++++ esphome/components/i2s_audio/i2s_audio.h | 64 +++++++- .../microphone/i2s_audio_microphone.cpp | 84 +++++++---- .../microphone/i2s_audio_microphone.h | 9 +- .../components/i2s_audio/speaker/__init__.py | 4 +- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 10 +- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 76 +++++++++- .../i2s_audio/speaker/i2s_audio_speaker.h | 19 ++- .../speaker/i2s_audio_speaker_standard.cpp | 80 ++++++---- .../speaker/i2s_audio_speaker_standard.h | 18 ++- esphome/core/defines.h | 1 + .../test_full_duplex_multiple_speakers.yaml | 42 ++++++ .../i2s_audio/test_i2s_audio.py | 141 ++++++++++++++++++ .../i2s_audio/common-full_duplex.yaml | 26 ++++ .../i2s_audio/test-full_duplex.esp32-idf.yaml | 9 ++ .../test-full_duplex.esp32-s3-idf.yaml | 9 ++ 17 files changed, 695 insertions(+), 79 deletions(-) create mode 100644 esphome/components/i2s_audio/i2s_audio.cpp create mode 100644 tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml create mode 100644 tests/component_tests/i2s_audio/test_i2s_audio.py create mode 100644 tests/components/i2s_audio/common-full_duplex.yaml create mode 100644 tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml create mode 100644 tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 6e4e35590c1..3d5ec97a633 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -19,8 +19,16 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S3, ) from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_CHANNEL, + CONF_ID, + CONF_PLATFORM, + CONF_SAMPLE_RATE, + CONF_SPEAKER, +) from esphome.core import CORE from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv @@ -42,6 +50,9 @@ CONF_I2S_BCLK_PIN = "i2s_bclk_pin" CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" CONF_I2S_AUDIO_ID = "i2s_audio_id" +CONF_FULL_DUPLEX = "full_duplex" +CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_SPDIF_MODE = "spdif_mode" CONF_I2S_MODE = "i2s_mode" CONF_PRIMARY = "primary" @@ -185,7 +196,8 @@ def i2s_audio_component_schema( async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: - await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) + parent = await cg.get_variable(config[CONF_I2S_AUDIO_ID]) + await cg.register_parented(var, parent) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] if slot_mode != CONF_STEREO: @@ -200,6 +212,12 @@ async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None cg.add(var.set_use_apll(config[CONF_USE_APLL])) cg.add(var.set_mclk_multiple(I2S_MCLK_MULTIPLE[config[CONF_MCLK_MULTIPLE]])) + if str(config[CONF_I2S_AUDIO_ID]) in _get_data().full_duplex_buses: + if config[CONF_ID].type.inherits_from(I2SAudioIn): + cg.add(parent.set_audio_in(var)) + else: + cg.add(parent.set_audio_out(var)) + CONFIG_SCHEMA = cv.Schema( { @@ -207,6 +225,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_FULL_DUPLEX, default=False): cv.boolean, }, ) @@ -216,6 +235,7 @@ class I2SAudioData: """I2S audio component state stored in CORE.data.""" port_map: dict[str, int] = field(default_factory=dict) + full_duplex_buses: set[str] = field(default_factory=set) def _get_data() -> I2SAudioData: @@ -262,8 +282,56 @@ def _assign_ports() -> None: next_port += 1 +def _bus_devices(full_config: ConfigType, domain: str, bus_id: str) -> list[ConfigType]: + return [ + device + for device in full_config.get(domain, []) + if device.get(CONF_PLATFORM) == DOMAIN + and str(device[CONF_I2S_AUDIO_ID]) == bus_id + ] + + +def _validate_full_duplex(full_config: ConfigType, bus_id: str) -> None: + """Check that a full duplex bus has one microphone and speakers that can share its clocks and TX channel.""" + microphones = _bus_devices(full_config, MICROPHONE_DOMAIN, bus_id) + speakers = _bus_devices(full_config, CONF_SPEAKER, bus_id) + if len(microphones) != 1 or not speakers: + raise cv.Invalid( + f"'{CONF_FULL_DUPLEX}' requires exactly one i2s_audio microphone and at least one " + f"i2s_audio speaker on bus '{bus_id}'" + ) + microphone = microphones[0] + if microphone.get(CONF_PDM): + raise cv.Invalid(f"A PDM microphone cannot use a '{CONF_FULL_DUPLEX}' bus") + for speaker in speakers: + if speaker.get(CONF_SPDIF_MODE): + raise cv.Invalid(f"An SPDIF speaker cannot use a '{CONF_FULL_DUPLEX}' bus") + # Both directions run from the same bit and word clocks + for key in ( + CONF_SAMPLE_RATE, + CONF_BITS_PER_SAMPLE, + CONF_I2S_MODE, + CONF_USE_APLL, + CONF_MCLK_MULTIPLE, + ): + if microphone[key] != speaker[key]: + raise cv.Invalid( + f"The microphone and speaker '{speaker[CONF_ID]}' on '{CONF_FULL_DUPLEX}' bus " + f"'{bus_id}' must use the same '{key}'" + ) + # The TX channel is set up once from one speaker's configuration, and the speakers take turns using it + first = speakers[0] + for speaker in speakers[1:]: + for key in (CONF_I2S_DOUT_PIN, CONF_CHANNEL, CONF_I2S_COMM_FMT): + if speaker.get(key) != first.get(key): + raise cv.Invalid( + f"The speakers on '{CONF_FULL_DUPLEX}' bus '{bus_id}' must use the same '{key}'" + ) + + def _final_validate(_: ConfigType) -> None: - i2s_audio_configs = fv.full_config.get()[DOMAIN] + full_config = fv.full_config.get() + i2s_audio_configs = full_config[DOMAIN] variant = get_esp32_variant() if variant not in I2S_PORTS: raise cv.Invalid(f"Unsupported variant {variant}") @@ -273,6 +341,13 @@ def _final_validate(_: ConfigType) -> None: ) _assign_ports() + data = _get_data() + for config in i2s_audio_configs: + if config[CONF_FULL_DUPLEX]: + bus_id = str(config[CONF_ID]) + _validate_full_duplex(full_config, bus_id) + data.full_duplex_buses.add(bus_id) + FINAL_VALIDATE_SCHEMA = _final_validate @@ -299,3 +374,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) if CONF_I2S_MCLK_PIN in config: cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) + if config[CONF_FULL_DUPLEX]: + cg.add_define("USE_I2S_AUDIO_FULL_DUPLEX") + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"i2s_audio.cpp": "USE_I2S_AUDIO_FULL_DUPLEX"} +) diff --git a/esphome/components/i2s_audio/i2s_audio.cpp b/esphome/components/i2s_audio/i2s_audio.cpp new file mode 100644 index 00000000000..d1ee8d0c73b --- /dev/null +++ b/esphome/components/i2s_audio/i2s_audio.cpp @@ -0,0 +1,94 @@ +#include "i2s_audio.h" + +#if defined(USE_ESP32) && defined(USE_I2S_AUDIO_FULL_DUPLEX) + +#include "esphome/core/log.h" + +namespace esphome::i2s_audio { + +ESPHOME_LOG_TAG(TAG, "i2s_audio"); + +void I2SAudioComponent::setup() { + if (!this->is_full_duplex()) + return; + + // ESP-IDF only shares the bit and word clocks between channels allocated in the same call, and deleting either + // channel ends the pairing. Allocate both once here and keep them for the lifetime of the device. + i2s_chan_config_t chan_cfg{}; + i2s_std_config_t tx_cfg{}; + i2s_std_config_t rx_cfg{}; + this->audio_out_->build_full_duplex_config(chan_cfg, tx_cfg); + this->audio_in_->build_full_duplex_config(rx_cfg); + + esp_err_t err = i2s_new_channel(&chan_cfg, &this->tx_handle_, &this->rx_handle_); + if (err == ESP_OK) { + // The first initialized channel drives the shared clocks. Use RX so the microphone runs on its own. + err = i2s_channel_init_std_mode(this->rx_handle_, &rx_cfg); + } + if (err == ESP_OK) { + err = i2s_channel_init_std_mode(this->tx_handle_, &tx_cfg); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "Full duplex channel setup failed: %s", esp_err_to_name(err)); + if (this->tx_handle_ != nullptr) { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + } + if (this->rx_handle_ != nullptr) { + i2s_del_channel(this->rx_handle_); + this->rx_handle_ = nullptr; + } + this->mark_failed(); + } +} + +i2s_chan_handle_t I2SAudioComponent::acquire_(bool &in_use, i2s_chan_handle_t handle) { + if (handle == nullptr) + return nullptr; + in_use = true; + if (!this->update_rx_channel_()) { + in_use = false; + return nullptr; + } + return handle; +} + +i2s_chan_handle_t I2SAudioComponent::acquire_rx_channel() { return this->acquire_(this->rx_in_use_, this->rx_handle_); } + +void I2SAudioComponent::release_rx_channel() { + this->rx_in_use_ = false; + this->update_rx_channel_(); +} + +i2s_chan_handle_t I2SAudioComponent::acquire_tx_channel() { + // Speakers sharing the bus take turns; a second one must not get the channel while the first is playing + if (this->tx_in_use_) + return nullptr; + return this->acquire_(this->tx_in_use_, this->tx_handle_); +} + +void I2SAudioComponent::release_tx_channel() { + // The speaker task may have left the channel disabled already, so an invalid state error is expected here + i2s_channel_disable(this->tx_handle_); + this->tx_in_use_ = false; + this->update_rx_channel_(); +} + +bool I2SAudioComponent::update_rx_channel_() { + const bool needed = this->rx_in_use_ || this->tx_in_use_; + if (needed == this->rx_enabled_) + return true; + + esp_err_t err = needed ? i2s_channel_enable(this->rx_handle_) : i2s_channel_disable(this->rx_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to %s RX channel: %s", needed ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"), + esp_err_to_name(err)); + return false; + } + this->rx_enabled_ = needed; + return true; +} + +} // namespace esphome::i2s_audio + +#endif // USE_ESP32 && USE_I2S_AUDIO_FULL_DUPLEX diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 00a9705807e..b9adbe45e23 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -23,6 +23,15 @@ class I2SAudioBase : public Parented { void set_mclk_multiple(i2s_mclk_multiple_t mclk_multiple) { this->mclk_multiple_ = mclk_multiple; } protected: + i2s_clock_src_t get_clock_source_() const { +#ifdef I2S_CLK_SRC_APLL + if (this->use_apll_) { + return I2S_CLK_SRC_APLL; + } +#endif + return I2S_CLK_SRC_DEFAULT; + } + i2s_role_t i2s_role_{}; i2s_slot_mode_t slot_mode_; i2s_std_slot_mask_t std_slot_mask_; @@ -32,12 +41,51 @@ class I2SAudioBase : public Parented { i2s_mclk_multiple_t mclk_multiple_; }; -class I2SAudioIn : public I2SAudioBase {}; +class I2SAudioIn : public I2SAudioBase { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + public: + /// @brief Builds the RX configuration the parent uses to set up a full duplex channel pair. + virtual void build_full_duplex_config(i2s_std_config_t &std_cfg) = 0; +#endif +}; -class I2SAudioOut : public I2SAudioBase {}; +class I2SAudioOut : public I2SAudioBase { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + public: + /// @brief Builds the TX configuration the parent uses to set up a full duplex channel pair. The channel + /// configuration (DMA layout, role, interrupt priority) is shared by both channels. Validation only puts + /// standard speakers on a full duplex bus, so the SPDIF speaker never gets here. + virtual void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) {} +#endif +}; class I2SAudioComponent final : public Component { public: +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void setup() override; + + void set_audio_in(I2SAudioIn *audio_in) { this->audio_in_ = audio_in; } + void set_audio_out(I2SAudioOut *audio_out) { this->audio_out_ = audio_out; } + + /// @brief True when a microphone and a speaker share this bus at the same time. + bool is_full_duplex() const { return this->audio_in_ != nullptr && this->audio_out_ != nullptr; } + + /// @brief Enables the full duplex RX channel. Main loop only. + /// @return The enabled RX handle, or nullptr if the channel pair is unavailable + i2s_chan_handle_t acquire_rx_channel(); + /// @brief Releases the RX channel; it keeps running while the TX side needs its clocks. Main loop only. + void release_rx_channel(); + + /// @brief Starts the shared clocks and hands over the full duplex TX channel, still disabled, so the caller + /// can register callbacks and preload data before enabling it. Main loop only. + /// @return The TX handle, or nullptr if the channel pair is unavailable or another speaker holds it + i2s_chan_handle_t acquire_tx_channel(); + /// @brief Disables the TX channel and stops the shared clocks if the RX side is idle. Only the speaker that + /// acquired the channel may call this. Main loop only. + void release_tx_channel(); + /// @brief True while another speaker holds the full duplex TX channel + bool is_tx_in_use() const { return this->tx_in_use_; } +#endif i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, .bclk = (gpio_num_t) this->bclk_pin_, @@ -68,8 +116,20 @@ class I2SAudioComponent final : public Component { protected: Mutex lock_; +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief Marks one side active and brings the shared clocks up; undoes the mark if that fails. + i2s_chan_handle_t acquire_(bool &in_use, i2s_chan_handle_t handle); + /// @brief Enables the RX channel while either side is active, since it drives the shared clocks. + bool update_rx_channel_(); + I2SAudioIn *audio_in_{nullptr}; I2SAudioOut *audio_out_{nullptr}; + i2s_chan_handle_t rx_handle_{nullptr}; + i2s_chan_handle_t tx_handle_{nullptr}; + bool rx_in_use_{false}; + bool tx_in_use_{false}; + bool rx_enabled_{false}; +#endif int mclk_pin_{I2S_GPIO_UNUSED}; int bclk_pin_{I2S_GPIO_UNUSED}; int lrclk_pin_; diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 1345f0187b3..526ae304ee2 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -46,6 +46,13 @@ void I2SAudioMicrophone::setup() { return; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex() && this->parent_->is_failed()) { + this->mark_failed(LOG_STR("Full duplex bus setup failed")); + return; + } +#endif + this->configure_stream_settings_(); } @@ -94,7 +101,44 @@ void I2SAudioMicrophone::start() { xSemaphoreTake(this->active_listeners_semaphore_, 0); } +void I2SAudioMicrophone::build_std_config_(i2s_std_config_t &std_cfg) const { + i2s_std_slot_config_t std_slot_cfg = + I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); + std_slot_cfg.slot_bit_width = this->slot_bit_width_; + std_slot_cfg.slot_mask = this->std_slot_mask_; + + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); + pin_config.din = this->din_pin_; + + std_cfg = { + .clk_cfg = + { + .sample_rate_hz = this->sample_rate_, + .clk_src = this->get_clock_source_(), + .mclk_multiple = this->mclk_multiple_, + }, + .slot_cfg = std_slot_cfg, + .gpio_cfg = pin_config, + }; +} + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +void I2SAudioMicrophone::build_full_duplex_config(i2s_std_config_t &std_cfg) { this->build_std_config_(std_cfg); } +#endif + bool I2SAudioMicrophone::start_driver_() { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + this->rx_handle_ = this->parent_->acquire_rx_channel(); + if (this->rx_handle_ == nullptr) { + ESP_LOGE(TAG, "Full duplex channel unavailable"); + return false; + } + this->configure_stream_settings_(); + return true; + } +#endif + if (!this->parent_->try_lock()) { return false; // Waiting for another i2s to return lock } @@ -115,18 +159,12 @@ bool I2SAudioMicrophone::start_driver_() { return false; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; -#ifdef I2S_CLK_SRC_APLL - if (this->use_apll_) { - clk_src = I2S_CLK_SRC_APLL; - } -#endif - i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); #if SOC_I2S_SUPPORTS_PDM_RX if (this->pdm_) { + i2s_std_gpio_config_t pin_config = this->parent_->get_pin_config(); i2s_pdm_rx_clk_config_t clk_cfg = { .sample_rate_hz = this->sample_rate_, - .clk_src = clk_src, + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, .dn_sample_mode = this->pdm_dsr_, }; @@ -162,23 +200,8 @@ bool I2SAudioMicrophone::start_driver_() { } else #endif { - i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = this->sample_rate_, - .clk_src = clk_src, - .mclk_multiple = this->mclk_multiple_, - }; - i2s_std_slot_config_t std_slot_cfg = - I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) this->slot_bit_width_, this->slot_mode_); - std_slot_cfg.slot_bit_width = this->slot_bit_width_; - std_slot_cfg.slot_mask = this->std_slot_mask_; - - pin_config.din = this->din_pin_; - - i2s_std_config_t std_cfg = { - .clk_cfg = clk_cfg, - .slot_cfg = std_slot_cfg, - .gpio_cfg = pin_config, - }; + i2s_std_config_t std_cfg; + this->build_std_config_(std_cfg); /* Initialize the channel */ err = i2s_channel_init_std_mode(this->rx_handle_, &std_cfg); } @@ -210,6 +233,17 @@ void I2SAudioMicrophone::stop_driver_() { // There is no harm continuing to unload the driver if an error is ever returned by the various functions. This // ensures that we stop/unload the driver when it only partially starts. +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent owns the channel pair, so only hand the RX channel back + if (this->rx_handle_ != nullptr) { + this->parent_->release_rx_channel(); + this->rx_handle_ = nullptr; + } + return; + } +#endif + esp_err_t err; if (this->rx_handle_ != nullptr) { /* Have to stop the channel before deleting it */ diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 1b17380ed87..c5ed52f6514 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -33,7 +33,14 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } #endif +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void build_full_duplex_config(i2s_std_config_t &std_cfg) override; +#endif + protected: + /// @brief Builds the standard (non-PDM) mode configuration from the configured settings. + void build_std_config_(i2s_std_config_t &std_cfg) const; + /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. /// @return True if succesful, false otherwise bool start_driver_(); @@ -59,7 +66,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon TaskHandle_t task_handle_{nullptr}; gpio_num_t din_pin_{I2S_GPIO_UNUSED}; - i2s_chan_handle_t rx_handle_; + i2s_chan_handle_t rx_handle_{nullptr}; #if SOC_I2S_SUPPORTS_PDM_RX i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; #endif diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index e4ea186199d..ddbe08922fe 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType from .. import ( CONF_I2S_AUDIO_ID, + CONF_I2S_COMM_FMT, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -27,6 +28,7 @@ from .. import ( CONF_MONO, CONF_PRIMARY, CONF_RIGHT, + CONF_SPDIF_MODE, CONF_STEREO, CONF_USE_APLL, I2SAudioOut, @@ -46,9 +48,7 @@ I2SAudioSpeakerBase = i2s_audio_ns.class_( I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" -CONF_I2S_COMM_FMT = "i2s_comm_fmt" CONF_KEEP_ALIVE = "keep_alive" -CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( "I2SAudioSpeakerBase", cg.Component, speaker.Speaker, I2SAudioOut diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 3938d777717..46d6473983d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -467,14 +467,6 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s return ESP_ERR_INVALID_STATE; } - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; - -#if SOC_CLK_APLL_SUPPORTED - if (this->use_apll_) { - clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; - } -#endif // SOC_CLK_APLL_SUPPORTED - // SPDIF mode: fixed configuration for BMC encoding // For new driver, dma_frame_num is in I2S frames (8 bytes each for 32-bit stereo) uint32_t dma_buffer_length = SPDIF_BLOCK_I2S_FRAMES; // One SPDIF block = 384 I2S frames = 3072 bytes @@ -496,7 +488,7 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s // SPDIF: double sample rate for BMC, 32-bit stereo, only data pin needed i2s_std_clk_config_t clk_cfg = { .sample_rate_hz = this->sample_rate_ * 2, - .clk_src = clk_src, + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, }; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 85c0e6358a2..f14626dd47f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -41,6 +41,17 @@ void I2SAudioSpeakerBase::setup() { this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + // The parent set up before us and already routed dout to the shared TX channel, which is never re-initialized. + // Parking would detach the pin for good. SPDIF speakers cannot use a full duplex bus. + if (this->parent_->is_full_duplex()) { + if (this->parent_->is_failed()) { + this->mark_failed(LOG_STR("Full duplex bus setup failed")); + } + return; + } +#endif + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. this->park_dout_pin_(); @@ -130,7 +141,7 @@ void I2SAudioSpeakerBase::loop() { // Handle the speaker's state switch (this->state_) { - case speaker::STATE_STARTING: + case speaker::STATE_STARTING: { if (this->status_has_error()) { break; } @@ -140,7 +151,21 @@ void I2SAudioSpeakerBase::loop() { break; } - if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_tx_in_use()) { + // Another speaker on the bus is playing; wait for it to release the channel + break; + } +#endif + const esp_err_t err = this->start_i2s_driver(this->audio_stream_info_); + if (err == ESP_ERR_NOT_SUPPORTED) { + // Retrying cannot help; stay stopped so the audio source gives up instead of waiting forever + ESP_LOGE(TAG, "Stream format not supported; not starting"); + this->rejected_stream_info_ = this->audio_stream_info_; + this->state_ = speaker::STATE_STOPPED; + break; + } + if (err != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error(1000); break; @@ -159,6 +184,7 @@ void I2SAudioSpeakerBase::loop() { this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; + } case speaker::STATE_RUNNING: // Intentional fallthrough case speaker::STATE_STOPPING: // Intentional fallthrough case speaker::STATE_STOPPED: @@ -240,6 +266,9 @@ void I2SAudioSpeakerBase::start() { return; if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; + // play() calls start() on every write, so without this a rejected stream would be retried and logged nonstop + if (this->rejected_stream_info_.has_value() && this->rejected_stream_info_.value() == this->audio_stream_info_) + return; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -277,6 +306,15 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return err; } + err = this->prepare_event_queues_(event_queue_size); + if (err != ESP_OK) { + this->delete_channel_(); + this->parent_->unlock(); + } + return err; +} + +esp_err_t I2SAudioSpeakerBase::prepare_event_queues_(size_t event_queue_size) { // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); @@ -290,15 +328,41 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - this->delete_channel_(); - this->parent_->unlock(); return ESP_ERR_NO_MEM; } return ESP_OK; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +esp_err_t I2SAudioSpeakerBase::acquire_full_duplex_channel_(size_t event_queue_size) { + this->tx_handle_ = this->parent_->acquire_tx_channel(); + if (this->tx_handle_ == nullptr) { + ESP_LOGE(TAG, "Full duplex channel unavailable"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err = this->prepare_event_queues_(event_queue_size); + if (err != ESP_OK) { + this->parent_->release_tx_channel(); + this->tx_handle_ = nullptr; + } + return err; +} +#endif + void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent owns the channel pair and its pins, so only hand the TX channel back + if (this->tx_handle_ != nullptr) { + this->parent_->release_tx_channel(); + this->tx_handle_ = nullptr; + } + return; + } +#endif + if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and @@ -372,9 +436,9 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea this->current_stream_info_.bytes_to_samples(bytes_read)); } -bool I2SAudioSpeakerBase::esp32_mono_swap_needed_() const { +bool I2SAudioSpeakerBase::esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info) { #ifdef USE_ESP32_VARIANT_ESP32 - return this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16; + return stream_info.get_channels() == 1 && stream_info.get_bits_per_sample() == 16; #else return false; #endif // USE_ESP32_VARIANT_ESP32 diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 07b7e5d1905..03213347f0e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -127,6 +127,18 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); + /// @brief Creates the lockstep queues unless they exist already. + /// @param event_queue_size Size of each queue + /// @return ESP_OK if successful, or ESP_ERR_NO_MEM + esp_err_t prepare_event_queues_(size_t event_queue_size); + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief Takes the TX channel of the parent's full duplex pair and prepares the event queues. + /// @param event_queue_size Size of the event queue + /// @return ESP_OK if successful, or an error code. On failure, hands the channel back to the parent. + esp_err_t acquire_full_duplex_channel_(size_t event_queue_size); +#endif + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated void stop_i2s_driver_(bool keep_channel); @@ -160,8 +172,10 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); + /// @brief Whether 16-bit mono samples in this format need the ESP32 (non-variant) pair swap. + static bool esp32_mono_swap_needed(const audio::AudioStreamInfo &stream_info); /// @brief Whether the output is 16-bit mono on the original ESP32, which sends each pair of samples swapped. - bool esp32_mono_swap_needed_() const; + bool esp32_mono_swap_needed_() const { return esp32_mono_swap_needed(this->output_stream_info_); } /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. /// Only applies when esp32_mono_swap_needed_() is true. `data` must start on a DMA word and hold whole pairs. @@ -182,6 +196,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; + // A format start_i2s_driver() refused. Those checks compare against fixed settings, so it can never start. + optional rejected_stream_info_; + bool pause_state_{false}; bool keep_alive_{false}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 16abb2f22ef..73ad924fad7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -86,7 +86,7 @@ void I2SAudioSpeaker::run_speaker_task() { // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Hand over whole ESP32 mono sample pairs so writes stay word-aligned - const size_t source_alignment = bytes_per_frame * this->dma_frame_multiple_(); + const size_t source_alignment = bytes_per_frame * dma_frame_multiple(this->output_stream_info_); const bool pair_aligned = source_alignment > bytes_per_frame; // Round the ring buffer size down to a multiple of source_alignment so the wrap boundary avoids splices. const size_t ring_buffer_size = @@ -111,7 +111,7 @@ void I2SAudioSpeaker::run_speaker_task() { } else { // Should not happen for a READY channel; fall back to the requested size. dma_buffer_bytes = this->output_stream_info_.frames_to_bytes( - dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_())); + dma_buffer_frames(this->output_stream_info_, dma_frame_multiple(this->output_stream_info_))); } // dma_buffer_bytes counts output-format bytes; convert with the output stream info. const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); @@ -442,6 +442,25 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } #endif // USE_ESP32_VARIANT_ESP32 +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + if (this->parent_->is_full_duplex()) { + // The parent set up the channel once from the configured format, so the stream must produce exactly that + const audio::AudioStreamInfo required = this->full_duplex_stream_info_(); + if (this->output_stream_info_ != required) { + ESP_LOGE(TAG, "Full duplex requires %u-bit, %u channel audio at %" PRIu32 " Hz", + (unsigned) required.get_bits_per_sample(), (unsigned) required.get_channels(), + required.get_sample_rate()); + if (this->output_stream_info_.get_channels() != required.get_channels()) { + ESP_LOGE(TAG, "A resampler does not change the channel count; add a mixer speaker with num_channels: %u", + (unsigned) required.get_channels()); + } + return ESP_ERR_NOT_SUPPORTED; + } + // The speaker task will enable the channel after preloading. + return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT); + } +#endif // USE_I2S_AUDIO_FULL_DUPLEX + if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; @@ -457,25 +476,37 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream this->current_stream_info_ = audio_stream_info; + i2s_chan_config_t chan_cfg; + i2s_std_config_t std_cfg; + this->build_i2s_config_(this->output_stream_info_, chan_cfg, std_cfg); + + // The speaker task will enable the channel after preloading. + return this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); +} + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX +audio::AudioStreamInfo I2SAudioSpeaker::full_duplex_stream_info_() const { + return audio::AudioStreamInfo(static_cast(this->slot_bit_width_), + this->slot_mode_ == I2S_SLOT_MODE_STEREO ? 2 : 1, this->sample_rate_); +} + +void I2SAudioSpeaker::build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) { + this->build_i2s_config_(this->full_duplex_stream_info_(), chan_cfg, std_cfg); +} +#endif // USE_I2S_AUDIO_FULL_DUPLEX + +void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg, + i2s_std_config_t &std_cfg) const { // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. - uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_, this->dma_frame_multiple_()); - - i2s_role_t i2s_role = this->i2s_role_; - i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; - -#if SOC_CLK_APLL_SUPPORTED - if (this->use_apll_) { - clk_src = i2s_clock_src_t::I2S_CLK_SRC_APLL; - } -#endif // SOC_CLK_APLL_SUPPORTED + uint32_t dma_buffer_length = dma_buffer_frames(output_stream_info, dma_frame_multiple(output_stream_info)); // Log DMA configuration for debugging ESP_LOGV(TAG, "I2S DMA config: %zu buffers x %lu frames", (size_t) DMA_BUFFERS_COUNT, (unsigned long) dma_buffer_length); - i2s_chan_config_t chan_cfg = { + chan_cfg = { .id = this->parent_->get_port(), - .role = i2s_role, + .role = this->i2s_role_, .dma_desc_num = DMA_BUFFERS_COUNT, .dma_frame_num = dma_buffer_length, .auto_clear = true, @@ -484,22 +515,22 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream // Build standard I2S clock/slot/gpio configuration i2s_std_clk_config_t clk_cfg = { - .sample_rate_hz = audio_stream_info.get_sample_rate(), - .clk_src = clk_src, + .sample_rate_hz = output_stream_info.get_sample_rate(), + .clk_src = this->get_clock_source_(), .mclk_multiple = this->mclk_multiple_, }; i2s_slot_mode_t slot_mode = this->slot_mode_; i2s_std_slot_mask_t slot_mask = this->std_slot_mask_; - if (audio_stream_info.get_channels() == 1) { + if (output_stream_info.get_channels() == 1) { slot_mode = I2S_SLOT_MODE_MONO; - } else if (audio_stream_info.get_channels() == 2) { + } else if (output_stream_info.get_channels() == 2) { slot_mode = I2S_SLOT_MODE_STEREO; slot_mask = I2S_STD_SLOT_BOTH; } // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. - const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) output_stream_info.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: @@ -535,20 +566,11 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config(); gpio_cfg.dout = this->dout_pin_; - i2s_std_config_t std_cfg = { + std_cfg = { .clk_cfg = clk_cfg, .slot_cfg = slot_cfg, .gpio_cfg = gpio_cfg, }; - - esp_err_t err = this->init_i2s_channel_(chan_cfg, std_cfg, I2S_EVENT_QUEUE_COUNT); - if (err != ESP_OK) { - return err; - } - - // The speaker task will enable the channel after preloading. - - return ESP_OK; } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 01390dfdf70..fb1184bb71e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -20,12 +20,28 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase { void set_i2s_comm_fmt(I2SCommFmt fmt) { this->i2s_comm_fmt_ = fmt; } +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + void build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) override; +#endif + protected: void run_speaker_task() override; esp_err_t start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) override; + /// @brief Builds the channel and standard mode configuration for the given output format. + /// @param output_stream_info Format clocked out of the I2S peripheral + void build_i2s_config_(const audio::AudioStreamInfo &output_stream_info, i2s_chan_config_t &chan_cfg, + i2s_std_config_t &std_cfg) const; + +#ifdef USE_I2S_AUDIO_FULL_DUPLEX + /// @brief The output format of a full duplex bus, fixed by the configuration since the channel is set up once. + audio::AudioStreamInfo full_duplex_stream_info_() const; +#endif + /// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned. - uint32_t dma_frame_multiple_() const { return this->esp32_mono_swap_needed_() ? 2 : 1; } + static uint32_t dma_frame_multiple(const audio::AudioStreamInfo &stream_info) { + return esp32_mono_swap_needed(stream_info) ? 2 : 1; + } I2SCommFmt i2s_comm_fmt_{I2SCommFmt::STANDARD}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c901ff3e844..0655c4a399c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -95,6 +95,7 @@ #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT +#define USE_I2S_AUDIO_FULL_DUPLEX #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE #define USE_IMPROV_BLE_STATE_CALLBACK diff --git a/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml new file mode 100644 index 00000000000..5cc4fb38125 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml @@ -0,0 +1,42 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2s_audio: + - id: duplex_bus + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + full_duplex: true + +microphone: + - platform: i2s_audio + id: duplex_mic + i2s_audio_id: duplex_bus + i2s_din_pin: GPIO16 + adc_type: external + sample_rate: 48000 + bits_per_sample: 32bit + +speaker: + - platform: i2s_audio + id: duplex_speaker_a + i2s_audio_id: duplex_bus + i2s_dout_pin: + number: GPIO17 + allow_other_uses: true + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit + - platform: i2s_audio + id: duplex_speaker_b + i2s_audio_id: duplex_bus + i2s_dout_pin: + number: GPIO17 + allow_other_uses: true + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit diff --git a/tests/component_tests/i2s_audio/test_i2s_audio.py b/tests/component_tests/i2s_audio/test_i2s_audio.py new file mode 100644 index 00000000000..f92d0ca9414 --- /dev/null +++ b/tests/component_tests/i2s_audio/test_i2s_audio.py @@ -0,0 +1,141 @@ +"""Tests for the i2s_audio full duplex bus.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.i2s_audio import _validate_full_duplex +from esphome.core import CORE, ID + +BUS_ID = "duplex_bus" + + +def _device(**overrides: Any) -> dict[str, Any]: + return { + "platform": "i2s_audio", + "id": ID("device", is_declaration=True), + "i2s_audio_id": ID(BUS_ID, is_declaration=False), + "i2s_dout_pin": 17, + "channel": "stereo", + "i2s_comm_fmt": "stand_i2s", + "sample_rate": 48000, + "bits_per_sample": 32, + "i2s_mode": "primary", + "use_apll": False, + "mclk_multiple": 256, + } | overrides + + +def _full_config( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> dict[str, Any]: + return {"microphone": microphones, "speaker": speakers} + + +def test_full_duplex_generates_code( + generate_main: Callable[[str | Path], str], +) -> None: + """The microphone and every speaker are registered with the bus and the full duplex code is enabled.""" + main_cpp = generate_main( + "tests/component_tests/i2s_audio/test_full_duplex_multiple_speakers.yaml" + ) + + assert "duplex_bus->set_audio_in(duplex_mic);" in main_cpp + assert "duplex_bus->set_audio_out(duplex_speaker_a);" in main_cpp + assert "duplex_bus->set_audio_out(duplex_speaker_b);" in main_cpp + assert "USE_I2S_AUDIO_FULL_DUPLEX" in {define.name for define in CORE.defines} + + +_OTHER_BUS = _device(i2s_audio_id=ID("other_bus", is_declaration=False)) + + +@pytest.mark.parametrize( + ("microphones", "speakers"), + [ + pytest.param([_device(pdm=False)], [_device(spdif_mode=False)], id="matching"), + pytest.param( + [_device(), _OTHER_BUS], [_device(), _OTHER_BUS], id="other-bus-ignored" + ), + pytest.param( + [_device()], [_device(), _device(), _device()], id="several-speakers" + ), + ], +) +def test_full_duplex_accepts( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> None: + """Devices with the same clock and TX settings share the bus; other buses do not count.""" + _validate_full_duplex(_full_config(microphones, speakers), BUS_ID) + + +@pytest.mark.parametrize( + ("microphones", "speakers"), + [ + ([], [_device()]), + ([_device()], []), + ([_device(), _device()], [_device()]), + ], +) +def test_full_duplex_requires_one_microphone_and_a_speaker( + microphones: list[dict[str, Any]], speakers: list[dict[str, Any]] +) -> None: + """Exactly one microphone and at least one speaker must use the bus.""" + with pytest.raises(cv.Invalid, match="exactly one i2s_audio microphone"): + _validate_full_duplex(_full_config(microphones, speakers), BUS_ID) + + +@pytest.mark.parametrize( + ("microphone", "speaker", "error_match"), + [ + pytest.param(_device(pdm=True), _device(), "PDM microphone", id="pdm"), + pytest.param(_device(), _device(spdif_mode=True), "SPDIF speaker", id="spdif"), + ], +) +def test_full_duplex_rejects_unsupported_devices( + microphone: dict[str, Any], speaker: dict[str, Any], error_match: str +) -> None: + """PDM receive and SPDIF output cannot share the standard mode clocks.""" + with pytest.raises(cv.Invalid, match=error_match): + _validate_full_duplex(_full_config([microphone], [speaker]), BUS_ID) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("sample_rate", 16000), + ("bits_per_sample", 16), + ("i2s_mode", "secondary"), + ("use_apll", True), + ("mclk_multiple", 384), + ], +) +def test_full_duplex_rejects_mismatched_clock_settings(key: str, value: Any) -> None: + """Settings that shape the shared clocks must match.""" + with pytest.raises(cv.Invalid, match=f"must use the same '{key}'"): + _validate_full_duplex( + _full_config([_device()], [_device(**{key: value})]), BUS_ID + ) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("i2s_dout_pin", 18), + ("channel", "mono"), + ("i2s_comm_fmt", "stand_msb"), + ], +) +def test_full_duplex_rejects_mismatched_speakers(key: str, value: Any) -> None: + """Settings that shape the TX channel must match across speakers.""" + with pytest.raises( + cv.Invalid, + match=f"speakers on 'full_duplex' bus '{BUS_ID}' must use the same '{key}'", + ): + _validate_full_duplex( + _full_config([_device()], [_device(), _device(**{key: value})]), BUS_ID + ) diff --git a/tests/components/i2s_audio/common-full_duplex.yaml b/tests/components/i2s_audio/common-full_duplex.yaml new file mode 100644 index 00000000000..06ec6a87c96 --- /dev/null +++ b/tests/components/i2s_audio/common-full_duplex.yaml @@ -0,0 +1,26 @@ +i2s_audio: + - id: i2s_duplex_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} + full_duplex: true + +microphone: + - platform: i2s_audio + id: duplex_mic + i2s_audio_id: i2s_duplex_bus + i2s_din_pin: ${i2s_din_pin} + adc_type: external + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo + +speaker: + - platform: i2s_audio + id: duplex_speaker + i2s_audio_id: i2s_duplex_bus + i2s_dout_pin: ${i2s_dout_pin} + dac_type: external + sample_rate: 48000 + bits_per_sample: 32bit + channel: stereo diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + i2s_din_pin: GPIO16 + i2s_dout_pin: GPIO17 + +packages: + i2s_audio: !include common-full_duplex.yaml diff --git a/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml new file mode 100644 index 00000000000..4e8c9b4e4c8 --- /dev/null +++ b/tests/components/i2s_audio/test-full_duplex.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + i2s_din_pin: GPIO16 + i2s_dout_pin: GPIO17 + +packages: + i2s_audio: !include common-full_duplex.yaml From 802c2633edb9634b9d47bde46648288677d5a2de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Oct 2026 21:38:58 -1000 Subject: [PATCH 44/50] [image] Draw rows in the outer loop and hand opaque RGB565 and RGB blocks to the display (#20653) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/animation/animation.cpp | 4 +- esphome/components/animation/animation.h | 2 +- esphome/components/animation/image.py | 3 +- esphome/components/file/image.py | 8 +- esphome/components/image/image.cpp | 211 ++++++++++------- esphome/components/image/image.h | 11 +- .../runtime_image/image_decoder.cpp | 2 +- .../runtime_image/runtime_image.cpp | 7 +- .../components/runtime_image/runtime_image.h | 5 - esphome/components/sendspin/image/__init__.py | 1 + tests/component_tests/image/test_init.py | 2 +- .../config/image_byte_order_big_endian.yaml | 35 +++ .../image_byte_order_little_endian.yaml | 35 +++ tests/component_tests/sendspin/test_image.py | 23 ++ tests/components/image/test_image_draw.cpp | 213 ++++++++++++++++++ 15 files changed, 466 insertions(+), 96 deletions(-) create mode 100644 tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml create mode 100644 tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml create mode 100644 tests/components/image/test_image_draw.cpp diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index 2a293adf1d8..1a2307520d3 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -5,8 +5,8 @@ namespace esphome::animation { Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, - image::ImageType type, image::Transparency transparent) - : Image(data_start, width, height, type, transparent), + image::ImageType type, image::Transparency transparent, bool big_endian) + : Image(data_start, width, height, type, transparent, big_endian), animation_data_start_(data_start), current_frame_(0), animation_frame_count_(animation_frame_count), diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h index 4ee060c1213..0cee54c3607 100644 --- a/esphome/components/animation/animation.h +++ b/esphome/components/animation/animation.h @@ -6,7 +6,7 @@ namespace esphome::animation { class Animation final : public image::Image { public: Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type, - image::Transparency transparent); + image::Transparency transparent, bool big_endian = false); uint32_t get_animation_frame_count() const; int get_current_frame() const; diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 09e0e8753cf..b8280f47cd9 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components.const import CONF_LOOP +from esphome.components.const import CONF_BYTE_ORDER, CONF_LOOP from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings @@ -90,6 +90,7 @@ async def setup_animation(config: ConfigType) -> None: frame_count, image_type, trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) if loop_config := config.get(CONF_LOOP): start = loop_config[CONF_START_FRAME] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 20eda9a3278..5e4fd145a44 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -254,7 +254,13 @@ async def new_image(config: ConfigType) -> MockObj: """ prog_arr, width, height, image_type, trans_value, _ = await write_image(config) var = cg.new_Pvariable( - config[CONF_ID], prog_arr, width, height, image_type, trans_value + config[CONF_ID], + prog_arr, + width, + height, + image_type, + trans_value, + config.get(CONF_BYTE_ORDER) == "BIG_ENDIAN", ) add_metadata( config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index d27d110248c..60d81ad62f1 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -1,5 +1,7 @@ #include "image.h" +#include + #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -7,92 +9,137 @@ ESPHOME_LOG_TAG(TAG, "image"); namespace esphome::image { +// Walks the clipped range with rows (ROWS_OUTER) or columns outer and hands the +// body its coordinates and row major index. The body must be ESPHOME_ALWAYS_INLINE, +// at -Os GCC otherwise calls it once per pixel. +template +static void for_each_pixel(int x_start, int x_end, int y_start, int y_end, int width, F &&f) { + const int outer_start = ROWS_OUTER ? y_start : x_start; + const int outer_end = ROWS_OUTER ? y_end : x_end; + const int inner_start = ROWS_OUTER ? x_start : y_start; + const int inner_end = ROWS_OUTER ? x_end : y_end; + for (int outer = outer_start; outer < outer_end; outer++) { + const int outer_pos = ROWS_OUTER ? outer * width : outer; + for (int inner = inner_start; inner < inner_end; inner++) { + f(ROWS_OUTER ? inner : outer, ROWS_OUTER ? outer : inner, + ROWS_OUTER ? outer_pos + inner : outer_pos + inner * width); + } + } +} + void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { - int img_x0 = 0; - int img_y0 = 0; - int w = width_; - int h = height_; + int x_start = 0; + int y_start = 0; + int x_end = width_; + int y_end = height_; auto clipping = display->get_clipping(); if (clipping.is_set()) { if (clipping.x > x) - img_x0 += clipping.x - x; + x_start += clipping.x - x; if (clipping.y > y) - img_y0 += clipping.y - y; - if (w > clipping.x2() - x) - w = clipping.x2() - x; - if (h > clipping.y2() - y) - h = clipping.y2() - y; + y_start += clipping.y - y; + if (x_end > clipping.x2() - x) + x_end = clipping.x2() - x; + if (y_end > clipping.y2() - y) + y_end = clipping.y2() - y; } - switch (type_) { - case IMAGE_TYPE_BINARY: { - for (int img_x = img_x0; img_x < w; img_x++) { - for (int img_y = img_y0; img_y < h; img_y++) { - if (this->get_binary_pixel_(img_x, img_y)) { - display->draw_pixel_at(x + img_x, y + img_y, color_on); - } else if (!this->transparency_) { - display->draw_pixel_at(x + img_x, y + img_y, color_off); - } - } - } +#ifndef USE_ESP8266 + // Opaque RGB565 and RGB are already laid out for draw_pixels_at(), so the display + // gets the whole window. Not on the ESP8266, whose image bytes need progmem_read_byte(). + if (this->transparency_ == TRANSPARENCY_OPAQUE && + (this->type_ == IMAGE_TYPE_RGB565 || this->type_ == IMAGE_TYPE_RGB)) { + // Keep the block on screen + x_start = std::max(x_start, -x); + y_start = std::max(y_start, -y); + x_end = std::min(x_end, display->get_width() - x); + y_end = std::min(y_end, display->get_height() - y); + if (x_end <= x_start || y_end <= y_start) + return; + const bool rgb565 = this->type_ == IMAGE_TYPE_RGB565; + display->draw_pixels_at(x + x_start, y + y_start, x_end - x_start, y_end - y_start, this->data_start_, + display::COLOR_ORDER_RGB, rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_888, + this->big_endian_, x_start, y_start, this->width_ - x_end); + return; + } +#endif + + // Rows keep the read and the write sequential; when the display swaps the axes + // columns keep the write sequential instead, and a strided write costs more. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); + return; + } +#endif + // Without PSRAM a strided write is free, so one copy of the loops is enough + this->draw_(x, y, display, color_on, color_off, x_start, x_end, y_start, y_end); +} + +template +void Image::draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end) { + // Local copies, or the virtual draw_pixel_at() forces a reload every pixel + const Transparency transparency = this->transparency_; + const int width = this->width_; + switch (this->type_) { + case IMAGE_TYPE_BINARY: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + if (this->get_binary_pixel_(img_x, img_y)) { + display->draw_pixel_at(x + img_x, y + img_y, color_on); + } else if (transparency == TRANSPARENCY_OPAQUE) { + display->draw_pixel_at(x + img_x, y + img_y, color_off); + } + }); + break; + case IMAGE_TYPE_RGB111: + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + 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: { + const uint8_t *const data = this->data_start_; + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + const uint8_t gray = progmem_read_byte(data + pos); + Color color = Color(gray, gray, gray, 0xFF); + if (transparency == TRANSPARENCY_CHROMA_KEY) { + if (gray == 1) { + return; // transparent pixel, skip drawing + } + } else if (transparency == TRANSPARENCY_ALPHA_CHANNEL) { + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); + } + display->draw_pixel_at(x + img_x, y + img_y, 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++) { - const uint32_t pos = (img_x + img_y * this->width_); - const uint8_t gray = progmem_read_byte(this->data_start_ + pos); - Color color = Color(gray, gray, gray, 0xFF); - switch (this->transparency_) { - case TRANSPARENCY_CHROMA_KEY: - if (gray == 1) { - continue; // skip drawing - } - break; - case TRANSPARENCY_ALPHA_CHANNEL: - // gray is the alpha: blend from color_off to color_on, drawn opaque - color = Color(Color::blend_channel(color_off.r, color_on.r, gray), - Color::blend_channel(color_off.g, color_on.g, gray), - Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); - break; - default: - break; - } - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - break; case IMAGE_TYPE_RGB565: - 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_rgb565_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb565_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); break; case IMAGE_TYPE_RGB: - 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_rgb_pixel_(img_x, img_y); - if (color.w >= 0x80) { - display->draw_pixel_at(x + img_x, y + img_y, color); - } - } - } + for_each_pixel(x_start, x_end, y_start, y_end, width, + [&](int img_x, int img_y, int pos) ESPHOME_ALWAYS_INLINE { + auto color = this->get_rgb_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + }); break; } } @@ -185,7 +232,7 @@ Color Image::get_rgb111_pixel_(int x, int y) const { 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; + const uint32_t pos = (x + y * this->width_) * static_cast(this->bpp_) / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), progmem_read_byte(this->data_start_ + pos + 0), 0xFF); @@ -205,8 +252,10 @@ Color Image::get_rgb_pixel_(int x, int y) const { return color; } Color Image::get_rgb565_pixel_(int x, int y) const { - const uint8_t *pos = this->data_start_ + (x + y * this->width_) * this->bpp_ / 8; - uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + 1), progmem_read_byte(pos)); + const uint8_t *pos = this->data_start_ + (x + y * this->width_) * static_cast(this->bpp_) / 8; + // High byte first when big endian + const uint8_t high = this->big_endian_ ? 0 : 1; + const uint16_t rgb565 = encode_uint16(progmem_read_byte(pos + high), progmem_read_byte(pos + 1 - high)); auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; @@ -242,8 +291,14 @@ Color Image::get_grayscale_pixel_(int x, int y) const { int Image::get_width() const { return this->width_; } int Image::get_height() const { return this->height_; } ImageType Image::get_type() const { return this->type_; } -Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency) - : width_(width), height_(height), type_(type), data_start_(data_start), transparency_(transparency) { +Image::Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian) + : width_(width), + height_(height), + type_(type), + data_start_(data_start), + transparency_(transparency), + big_endian_(big_endian) { switch (this->type_) { case IMAGE_TYPE_BINARY: this->bpp_ = 1; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index a94f8137f41..b03befcddb4 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -24,7 +24,9 @@ enum Transparency { class Image : public display::BaseImage { public: - Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency); + /// big_endian is the RGB565 byte order; codegen writes little endian unless configured otherwise. + Image(const uint8_t *data_start, int width, int height, ImageType type, Transparency transparency, + bool big_endian = false); Color get_pixel(int x, int y, Color color_on = display::COLOR_ON, Color color_off = display::COLOR_OFF) const; int get_width() const override; int get_height() const override; @@ -44,6 +46,10 @@ class Image : public display::BaseImage { lv_image_dsc_t *get_lv_image_dsc(); #endif protected: + /// Per-pixel draw of the clipped range, rows (true) or columns (false) outer. + template + void draw_(int x, int y, display::Display *display, Color color_on, Color color_off, int x_start, int x_end, + int y_start, int y_end); 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; @@ -55,7 +61,8 @@ class Image : public display::BaseImage { ImageType type_; const uint8_t *data_start_; Transparency transparency_; - size_t bpp_{}; + bool big_endian_; + uint8_t bpp_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index 55e25d76b72..d57d5d4e73d 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -30,6 +30,6 @@ 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_; } +bool ImageDecoder::image_is_big_endian_() const { return this->image_->big_endian_; } } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 79d2627a11c..14e0d5665a6 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -41,12 +41,11 @@ inline bool is_color_on(const Color &color) { RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency, image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height) - : Image(nullptr, 0, 0, type, transparency), + : Image(nullptr, 0, 0, type, transparency, is_big_endian), format_(format), fixed_width_(fixed_width), fixed_height_(fixed_height), - placeholder_(placeholder), - is_big_endian_(is_big_endian) {} + placeholder_(placeholder) {} RuntimeImage::~RuntimeImage() { this->release(); } @@ -137,7 +136,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { Color mapped_color = color; this->map_chroma_key(mapped_color); uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color); - if (this->is_big_endian_) { + if (this->big_endian_) { this->buffer_[pos + 0] = static_cast((rgb565 >> 8) & 0xFF); this->buffer_[pos + 1] = static_cast(rgb565 & 0xFF); } else { diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 33bad52acf7..2a3ab40a5c1 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -227,11 +227,6 @@ class RuntimeImage : public image::Image { // Configuration bool progressive_display_{false}; - /** - * Whether the image is stored in big-endian format. - * This is used to determine how to store 16 bit colors in the buffer. - */ - bool is_big_endian_{false}; /** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */ bool external_buffer_{false}; diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index a5336d5e052..571f8aa47d8 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -173,6 +173,7 @@ async def to_code(config: ConfigType) -> None: settings.height, settings.image_type_enum, settings.transparent, + settings.byte_order_big_endian, ) add_metadata( view_id, diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 4d62bb52a19..602b923a1dd 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -745,7 +745,7 @@ def test_image_generation( in main_cpp ) assert ( - "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);" + "new(cat_img) image::Image(uint8_t_id, 32, 24, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, false);" in main_cpp ) diff --git a/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml new file mode 100644 index 00000000000..e58c546d2ee --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_big_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: BIG_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml new file mode 100644 index 00000000000..b9c4fd91d21 --- /dev/null +++ b/tests/component_tests/sendspin/config/image_byte_order_little_endian.yaml @@ -0,0 +1,35 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ap: + +spi: + clk_pin: 18 + mosi_pin: 23 + +display: + - platform: ili9xxx + model: ili9342 + cs_pin: 22 + dc_pin: 21 + invert_colors: false + +sendspin: + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + byte_order: LITTLE_ENDIAN + resize: 240x240 + current_image: + id: album_art + transition_image: + id: album_art_transition diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index dc9e4978c6b..7de2b9b4dba 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -4,6 +4,8 @@ These cover the rejection branches, which a compile test cannot reach: a `test*.yaml` can only assert that a configuration is accepted. """ +from collections.abc import Callable +from pathlib import Path from typing import Any import pytest @@ -120,3 +122,24 @@ def test_display_offset_accepted( config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) assert config["display_offset"].total_milliseconds == expected_ms + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [ + ("image_byte_order_big_endian.yaml", "true"), + ("image_byte_order_little_endian.yaml", "false"), + ], +) +def test_views_get_the_byte_order( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: str, +) -> None: + """Both views draw the frames the slot decodes, so they must read them in its byte order.""" + main_cpp = generate_main(component_config_path(config_file)) + + views = [line for line in main_cpp.splitlines() if "ArtworkImageView(" in line] + assert len(views) == 2 + assert all(line.endswith(f", {expected});") for line in views), views diff --git a/tests/components/image/test_image_draw.cpp b/tests/components/image/test_image_draw.cpp new file mode 100644 index 00000000000..80db8a4ad2a --- /dev/null +++ b/tests/components/image/test_image_draw.cpp @@ -0,0 +1,213 @@ +#include + +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::testing { + +class TestDisplay : public display::Display { + public: + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 48; } +}; + +struct Blit { + int x, y, w, h; + const uint8_t *ptr; + display::ColorBitness bitness; + bool big_endian; + int x_offset, y_offset, x_pad; +}; + +// Records the bulk calls and counts the per pixel calls Image::draw() makes. +class RecordingDisplay : public TestDisplay { + public: + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + this->blits.push_back({x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad}); + } + void draw_pixel_at(int x, int y, Color color) override { this->pixels++; } + + std::vector blits; + int pixels{0}; +}; + +// Lets the bulk call fall through to the base class and keeps the pixels it produces. +class FallbackDisplay : public TestDisplay { + public: + void draw_pixel_at(int x, int y, Color color) override { this->pixels.push_back({x, y, color.raw_32}); } + + struct Pixel { + int x, y; + uint32_t raw; + }; + std::vector pixels; +}; + +static constexpr int W = 8, H = 6; + +static std::vector make_data(int bytes_per_pixel) { + std::vector data(W * H * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : data) + byte = value++; + return data; +} + +// Draws an opaque RGB565 image at (x, y) and returns the single bulk call it made. +static Blit draw_one(int x, int y, const std::vector &data, RecordingDisplay &display, + bool big_endian = false) { + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + image.draw(x, y, &display, Color::WHITE, Color::BLACK); + EXPECT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.pixels, 0); + return display.blits.at(0); +} + +static void expect_window(const Blit &blit, int x, int y, int w, int h, int x_offset, int y_offset, int x_pad) { + EXPECT_EQ(blit.x, x); + EXPECT_EQ(blit.y, y); + EXPECT_EQ(blit.w, w); + EXPECT_EQ(blit.h, h); + EXPECT_EQ(blit.x_offset, x_offset); + EXPECT_EQ(blit.y_offset, y_offset); + EXPECT_EQ(blit.x_pad, x_pad); +} + +TEST(ImageDraw, OpaqueRgb565IsOneBulkCall) { + const auto data = make_data(2); + RecordingDisplay display; + const Blit blit = draw_one(10, 20, data, display); + expect_window(blit, 10, 20, W, H, 0, 0, 0); + EXPECT_EQ(blit.ptr, data.data()); + EXPECT_EQ(blit.bitness, display::COLOR_BITNESS_565); + EXPECT_FALSE(blit.big_endian); +} + +TEST(ImageDraw, BigEndianRgb565IsPassedThrough) { + const auto data = make_data(2); + RecordingDisplay display; + EXPECT_TRUE(draw_one(0, 0, data, display, true).big_endian); +} + +TEST(ImageDraw, OpaqueRgbIsOneBulkCall) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.blits.size(), 1u); + EXPECT_EQ(display.blits[0].bitness, display::COLOR_BITNESS_888); +} + +TEST(ImageDraw, ClippingShrinksTheWindow) { + const auto data = make_data(2); + RecordingDisplay display; + display.start_clipping(12, 22, 15, 24); // right and bottom are exclusive + expect_window(draw_one(10, 20, data, display), 12, 22, 3, 2, 2, 2, W - 5); +} + +TEST(ImageDraw, PartlyOffTheTopLeftIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(-3, -2, data, display), 0, 0, W - 3, H - 2, 3, 2, 0); +} + +TEST(ImageDraw, PartlyOffTheBottomRightIsClamped) { + const auto data = make_data(2); + RecordingDisplay display; + expect_window(draw_one(60, 44, data, display), 60, 44, 4, 4, 0, 0, W - 4); +} + +TEST(ImageDraw, FullyOffScreenDrawsNothing) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + image.draw(64, 0, &display, Color::WHITE, Color::BLACK); + image.draw(0, -H, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_EQ(display.pixels, 0); +} + +TEST(ImageDraw, TransparentAndOtherFormatsStayPerPixel) { + const auto rgb565 = make_data(2); + const auto gray = make_data(1); + Image keyed(rgb565.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); + Image grayscale(gray.data(), W, H, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); + RecordingDisplay display; + keyed.draw(0, 0, &display, Color::WHITE, Color::BLACK); + grayscale.draw(0, 0, &display, Color::WHITE, Color::BLACK); + EXPECT_TRUE(display.blits.empty()); + EXPECT_GT(display.pixels, 0); +} + +// The base class fallback reads pixels in the image's byte order. +static void expect_fallback_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), pixel.x + pixel.y * W, big_endian); + const Color expected = display::ColorUtil::to_color(rgb565, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565); + EXPECT_EQ(pixel.raw, expected.raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, FallbackDecodesLittleEndian) { expect_fallback_decodes(false); } +TEST(ImageDraw, FallbackDecodesBigEndian) { expect_fallback_decodes(true); } + +// The per pixel reader behind get_pixel() reads RGB565 in the image's byte order. +static void expect_reader_decodes(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, big_endian); + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + const uint32_t rgb565 = + display::ColorUtil::read_packed(data.data(), x + y * W, big_endian); + const Color expected(((rgb565 >> 11) << 3) | ((rgb565 >> 11) >> 2), + (((rgb565 >> 5) & 0x3F) << 2) | (((rgb565 >> 5) & 0x3F) >> 4), + ((rgb565 & 0x1F) << 3) | ((rgb565 & 0x1F) >> 2), 0xFF); + EXPECT_EQ(image.get_pixel(x, y).raw_32, expected.raw_32) << "at " << x << "," << y; + } + } +} + +TEST(ImageDraw, ReaderDecodesLittleEndian) { expect_reader_decodes(false); } +TEST(ImageDraw, ReaderDecodesBigEndian) { expect_reader_decodes(true); } + +// A chroma keyed RGB565 draw stays per pixel and must match get_pixel(). +static void expect_keyed_draw_matches_reader(bool big_endian) { + const auto data = make_data(2); + Image image(data.data(), W, H, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, big_endian); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_GT(display.pixels.size(), 0u); + for (const auto &pixel : display.pixels) { + EXPECT_EQ(pixel.raw, image.get_pixel(pixel.x, pixel.y).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +TEST(ImageDraw, KeyedDrawMatchesReaderLittleEndian) { expect_keyed_draw_matches_reader(false); } +TEST(ImageDraw, KeyedDrawMatchesReaderBigEndian) { expect_keyed_draw_matches_reader(true); } + +// The fallback keeps red and blue in the order the image stores them. +TEST(ImageDraw, FallbackKeepsRgbChannelOrder) { + const auto data = make_data(3); + Image image(data.data(), W, H, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); + FallbackDisplay display; + image.draw(0, 0, &display, Color::WHITE, Color::BLACK); + ASSERT_EQ(display.pixels.size(), static_cast(W * H)); + for (const auto &pixel : display.pixels) { + // get_pixel() reports an opaque pixel with alpha set; the fallback decodes colour only + const Color expected = image.get_pixel(pixel.x, pixel.y); + EXPECT_EQ(pixel.raw, Color(expected.r, expected.g, expected.b).raw_32) << "at " << pixel.x << "," << pixel.y; + } +} + +} // namespace esphome::image::testing From 3298988f5d166d68a557c469183b3b4a9d3edf30 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:11:26 -1000 Subject: [PATCH 45/50] Bump bundled esphome-device-builder to 1.24.0 (#20851) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c9aa3dba62c..658b8c06302 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 RUN \ platformio settings set enable_telemetry No \ From 126dc8b0b20a39d2992f3893eef0a79e0646e3d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:38:55 -1000 Subject: [PATCH 46/50] [components] Let core report communication failures in dump_config (1/5) (#20870) --- esphome/components/ads1115/ads1115.cpp | 7 +- esphome/components/aht10/aht10.cpp | 15 +--- esphome/components/aic3204/aic3204.cpp | 7 +- esphome/components/am2315c/am2315c.cpp | 28 ++----- esphome/components/am2320/am2320.cpp | 5 +- esphome/components/apds9306/apds9306.cpp | 25 +------ esphome/components/apds9306/apds9306.h | 6 -- esphome/components/apds9960/apds9960.cpp | 22 +----- esphome/components/apds9960/apds9960.h | 5 -- esphome/components/as5600/as5600.cpp | 8 +- esphome/components/as7341/as7341.cpp | 8 +- esphome/components/as734x/as734x.cpp | 3 - esphome/components/atm90e26/atm90e26.cpp | 9 +-- esphome/components/atm90e32/atm90e32.cpp | 5 +- esphome/components/bh1750/bh1750.cpp | 5 +- .../bluetooth_connection_bluedroid.cpp | 13 ++-- .../bluetooth_connection_rp2.cpp | 6 +- esphome/components/bm8563/bm8563.cpp | 5 +- .../components/bme280_base/bme280_base.cpp | 17 +---- esphome/components/bme280_base/bme280_base.h | 5 -- esphome/components/bme680/bme680.cpp | 57 ++++++-------- esphome/components/bme680/bme680.h | 2 + esphome/components/bmi160/bmi160.cpp | 30 ++++---- esphome/components/bmi160/bmi160.h | 2 + esphome/components/bmi270/bmi270.cpp | 26 +++---- esphome/components/bmp085/bmp085.cpp | 5 +- .../components/bmp280_base/bmp280_base.cpp | 18 +---- esphome/components/bmp280_base/bmp280_base.h | 5 -- .../components/bmp3xx_base/bmp3xx_base.cpp | 46 ++---------- esphome/components/bmp3xx_base/bmp3xx_base.h | 7 -- .../components/bmp581_base/bmp581_base.cpp | 74 +++---------------- esphome/components/bmp581_base/bmp581_base.h | 9 --- esphome/components/bq27220/bq27220.cpp | 5 +- esphome/components/cap1188/cap1188.cpp | 12 +-- esphome/components/cap1188/cap1188.h | 5 -- esphome/components/ccs811/ccs811.cpp | 38 ++-------- esphome/components/ccs811/ccs811.h | 9 --- esphome/components/ch422g/ch422g.cpp | 5 +- esphome/components/ch423/ch423.cpp | 10 +-- esphome/components/cm1106/cm1106.cpp | 6 +- esphome/components/cse7761/cse7761.cpp | 5 +- 41 files changed, 124 insertions(+), 456 deletions(-) diff --git a/esphome/components/ads1115/ads1115.cpp b/esphome/components/ads1115/ads1115.cpp index b75e1b73046..03714390fea 100644 --- a/esphome/components/ads1115/ads1115.cpp +++ b/esphome/components/ads1115/ads1115.cpp @@ -11,7 +11,7 @@ static const uint8_t ADS1115_REGISTER_CONFIG = 0x01; void ADS1115Component::setup() { uint16_t value; if (!this->read_byte_16(ADS1115_REGISTER_CONVERSION, &value)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -58,7 +58,7 @@ void ADS1115Component::setup() { config |= 0b0000000000000011; if (!this->write_byte_16(ADS1115_REGISTER_CONFIG, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->prev_config_ = config; @@ -66,9 +66,6 @@ void ADS1115Component::setup() { void ADS1115Component::dump_config() { ESP_LOGCONFIG(TAG, "ADS1115:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1115Gain gain, ADS1115Resolution resolution, ADS1115Samplerate samplerate) { diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 83ab55e6576..41167033701 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -52,8 +52,7 @@ void AHT10Component::setup() { break; } if (error_code != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t cal_attempts = 0; @@ -61,20 +60,17 @@ void AHT10Component::setup() { while (data & AHT10_STATUS_BUSY) { delay(AHT10_DEFAULT_DELAY); if (this->read(&data, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ++cal_attempts; if (cal_attempts > AHT10_INIT_ATTEMPTS) { - ESP_LOGE(TAG, "Initialization timed out"); - this->mark_failed(); + this->mark_failed(LOG_STR("Initialization timed out")); return; } } if ((data & 0x68) != 0x08) { // Bit[6:5] = 0b00, NORMAL mode and Bit[3] = 0b1, CALIBRATED - ESP_LOGE(TAG, "Initialization failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Initialization failed")); return; } } @@ -152,9 +148,6 @@ void AHT10Component::update() { void AHT10Component::dump_config() { ESP_LOGCONFIG(TAG, "AHT10:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp index 892a6c56ac0..23902035eb8 100644 --- a/esphome/components/aic3204/aic3204.cpp +++ b/esphome/components/aic3204/aic3204.cpp @@ -10,8 +10,7 @@ ESPHOME_LOG_TAG(TAG, "aic3204"); #define ERROR_CHECK(err, msg) \ if (!(err)) { \ - ESP_LOGE(TAG, msg); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(msg)); \ return; \ } @@ -108,10 +107,6 @@ void AIC3204::setup() { void AIC3204::dump_config() { ESP_LOGCONFIG(TAG, "AIC3204:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool AIC3204::set_mute_off() { diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index d34061889be..9b350560ee9 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -42,21 +42,18 @@ bool AM2315C::reset_register_(uint8_t reg) { data[2] = 0; ESP_LOGD(TAG, "Reset register: 0x%02x", reg); if (this->write(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Write failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(5); if (this->read(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Read failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(10); data[0] = 0xB0 | reg; if (this->write(data, 3) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Write failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } delay(5); @@ -76,8 +73,7 @@ void AM2315C::setup() { // get status uint8_t status = 0; if (this->read(&status, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Read failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -86,18 +82,9 @@ void AM2315C::setup() { // never required during testing if ((status & 0x18) != 0x18) { ESP_LOGD(TAG, "Resetting AM2315C registers"); - if (!this->reset_register_(0x1B)) { - this->mark_failed(); + // reset_register_() marks the component failed with its own reason + if (!this->reset_register_(0x1B) || !this->reset_register_(0x1C) || !this->reset_register_(0x1E)) return; - } - if (!this->reset_register_(0x1C)) { - this->mark_failed(); - return; - } - if (!this->reset_register_(0x1E)) { - this->mark_failed(); - return; - } } } @@ -168,9 +155,6 @@ void AM2315C::update() { void AM2315C::dump_config() { ESP_LOGCONFIG(TAG, "AM2315C:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 94053ab5dc8..1b3761fdac5 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -37,16 +37,13 @@ void AM2320Component::setup() { data[0] = 0; data[1] = 4; if (!this->read_data_(data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void AM2320Component::dump_config() { ESP_LOGD(TAG, "AM2320:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index daf6bb6b338..af123d8c4f3 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -34,8 +34,7 @@ enum { // APDS9306 registers #define APDS9306_ERROR_CHECK(func, error) \ if (!(func)) { \ - ESP_LOGE(TAG, error); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(error)); \ return; \ } #define APDS9306_WARNING_CHECK(func, warning) \ @@ -48,21 +47,19 @@ enum { // APDS9306 registers ESP_LOGV(TAG, "Writing 0x%02x to 0x%02x", value, reg); \ if (!this->write_byte(reg, value)) { \ ESP_LOGE(TAG, "Failed writing 0x%02x to 0x%02x", value, reg); \ - this->mark_failed(); \ + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); \ return; \ } void APDS9306::setup() { uint8_t id; if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("APDS9306 has invalid id")); return; } @@ -89,20 +86,6 @@ void APDS9306::dump_config() { LOG_SENSOR("", "APDS9306", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "APDS9306 has invalid id!"); - break; - default: - ESP_LOGE(TAG, "Setting up APDS9306 registers failed!"); - break; - } - } - ESP_LOGCONFIG(TAG, " Gain: %u\n" " Measurement rate: %u\n" diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h index f971290cdd5..1f0a695c4ff 100644 --- a/esphome/components/apds9306/apds9306.h +++ b/esphome/components/apds9306/apds9306.h @@ -50,12 +50,6 @@ class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2 void set_ambient_light_gain(AmbientLightGain gain) { this->gain_ = gain; } protected: - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; - MeasurementBitWidth bit_width_; MeasurementRate measurement_rate_; AmbientLightGain gain_; diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index ef5d43e2cba..c003e7a8bc9 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "apds9960"); #define APDS9960_ERROR_CHECK(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Setting up APDS9960 registers failed")); \ return; \ } #define APDS9960_WRITE_BYTE(reg, value) APDS9960_ERROR_CHECK(this->write_byte(reg, value)); @@ -16,14 +16,12 @@ ESPHOME_LOG_TAG(TAG, "apds9960"); void APDS9960::setup() { uint8_t id; if (!this->read_byte(0x92, &id)) { // ID register - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0xAB && id != 0x9C && id != 0xA8 && id != 0x9E) { // APDS9960 all should have one of these IDs - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("APDS9960 has invalid id")); return; } @@ -135,20 +133,6 @@ void APDS9960::dump_config() { LOG_SENSOR(" ", "Clear channel", this->clear_sensor_); LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); #endif - - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "APDS9960 has invalid id!"); - break; - default: - ESP_LOGE(TAG, "Setting up APDS9960 registers failed!"); - break; - } - } } #define APDS9960_WARNING_CHECK(func, warning) \ diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h index bfa64bcc745..5e84a6d3d9d 100644 --- a/esphome/components/apds9960/apds9960.h +++ b/esphome/components/apds9960/apds9960.h @@ -58,11 +58,6 @@ class APDS9960 final : public PollingComponent, public i2c::I2CDevice { uint8_t gesture_gain_; uint8_t gesture_wait_time_; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; bool gesture_up_started_{false}; bool gesture_down_started_{false}; bool gesture_left_started_{false}; diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp index fc768b383b0..279e4051660 100644 --- a/esphome/components/as5600/as5600.cpp +++ b/esphome/components/as5600/as5600.cpp @@ -23,7 +23,7 @@ static const uint8_t REGISTER_MAGNITUDE = 0x1B; // 16 bytes / R void AS5600Component::setup() { if (!this->read_byte(REGISTER_STATUS).has_value()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -54,7 +54,7 @@ void AS5600Component::setup() { // write config to config register if (!this->write_byte_16(REGISTER_CONF, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -87,10 +87,8 @@ void AS5600Component::dump_config() { ESP_LOGCONFIG(TAG, "AS5600:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Watchdog: %d\n" diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 359636a4992..60f576f7bb9 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -14,14 +14,13 @@ void AS7341Component::setup() { this->read_byte(AS7341_ID, &id); ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } // Power on (enter IDLE state) if (!this->enable_power(true)) { - ESP_LOGE(TAG, " Power on failed!"); - this->mark_failed(); + this->mark_failed(LOG_STR("Power on failed")); return; } @@ -35,9 +34,6 @@ void AS7341Component::setup() { void AS7341Component::dump_config() { ESP_LOGCONFIG(TAG, "AS7341:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Gain: %u\n" diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 5337b3918eb..30412be70f5 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -355,9 +355,6 @@ void AS734XComponent::dump_config() { #ifdef USE_AS734X_RGB LOG_TEXT_SENSOR(" ", "RGB hex", this->rgb_hex_sensor_); #endif - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void AS734XComponent::enable_led(bool enable) { diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index 8ec611357c8..d0b734ddcb5 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -52,7 +52,7 @@ void ATM90E26Component::setup() { uint16_t read = this->read16_(ATM90E26_REGISTER_LASTDATA); if (read != 0x0030) { ESP_LOGW(TAG, "Could not initialize ATM90E26 IC, check SPI settings: %d", read); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // TODO: 100 * * sqrt(2) * / (4 * gain_voltage/32768) @@ -120,21 +120,18 @@ void ATM90E26Component::setup() { ESP_LOGW(TAG, "Could not initialize ATM90E26 IC: CS1 was incorrect, expected: 0x%04X", this->read16_(ATM90E26_REGISTER_CS1)); - this->mark_failed(); + this->mark_failed(LOG_STR("Checksum mismatch")); } if (sys_status & 0x3000) { // Checksum 2 Error ESP_LOGW(TAG, "Could not initialize ATM90E26 IC: CS2 was incorrect, expected: 0x%04X", this->read16_(ATM90E26_REGISTER_CS2)); - this->mark_failed(); + this->mark_failed(LOG_STR("Checksum mismatch")); } } void ATM90E26Component::dump_config() { ESP_LOGCONFIG("", "ATM90E26:"); LOG_PIN(" CS Pin: ", this->cs_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage A", this->voltage_sensor_); LOG_SENSOR(" ", "Current A", this->current_sensor_); diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 0c4f095ab57..0cdf9918db9 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -168,7 +168,7 @@ void ATM90E32Component::setup() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); // enable register config access if (!this->validate_spi_read_(0x55AA, "setup()")) { ESP_LOGW(TAG, "Could not initialize ATM90E32 IC, check SPI settings"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -411,9 +411,6 @@ void ATM90E32Component::log_calibration_status_() { void ATM90E32Component::dump_config() { ESP_LOGCONFIG("", "ATM90E32:"); LOG_PIN(" CS Pin: ", this->cs_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage A", this->phase_[PHASEA].voltage_sensor_); LOG_SENSOR(" ", "Current A", this->phase_[PHASEA].current_sensor_); diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 2d9709f4228..94ee60ea8d3 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -65,7 +65,7 @@ MTreg: void BH1750Sensor::setup() { uint8_t turn_on = BH1750_COMMAND_POWER_ON; if (this->write(&turn_on, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->state_ = IDLE; @@ -74,9 +74,6 @@ void BH1750Sensor::setup() { void BH1750Sensor::dump_config() { LOG_SENSOR("", "BH1750", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->get_name().c_str()); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 5fe1460c661..b642c544c56 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -62,7 +62,8 @@ void BluedroidGattClient::loop() { auto ret = esp_ble_gattc_app_register(this->app_id); if (ret) { ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); - this->mark_failed(); + this->mark_failed( + LOG_STR("Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots")); } // Do not wait for REG_EVT; connect() rejects until it lands. this->set_idle_(); @@ -108,12 +109,7 @@ void BluedroidGattClient::ble_before_disabled_event_handler() { this->enable_loop(); } -void BluedroidGattClient::dump_config() { - ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); - if (this->is_failed()) { - ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); - } -} +void BluedroidGattClient::dump_config() { ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); } // ---- contract ops ---- @@ -680,7 +676,8 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->gattc_if_ = esp_gattc_if; } else { ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); - this->mark_failed(); + this->mark_failed( + LOG_STR("Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots")); } break; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index c6e2703c48b..6af2fba62bb 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -76,16 +76,14 @@ void RP2GattClient::setup() { // Pre-create every pool entry so the packet handlers' allocate() calls are // always a free-list pop -- the IRQ path must never reach malloc(). if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { - ESP_LOGE(TAG, "GATT event pool warm-up failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("GATT event pool warm-up failed")); return; } // Register this engine for IRQ-context event routing. if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { // Cannot happen with codegen-sized storage; refuse loudly if it ever does. - ESP_LOGE(TAG, "GATT client registry full"); - this->mark_failed(); + this->mark_failed(LOG_STR("GATT client registry full")); return; } { diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index de6b51ab1f5..f83b98af115 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -21,7 +21,7 @@ static constexpr uint32_t MAX_TIMER_DURATION_S = 255 * 60; void BM8563::setup() { if (!this->write_byte_16(CONTROL_STATUS_1_REG, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -31,9 +31,6 @@ void BM8563::update() { this->read_time(); } void BM8563::dump_config() { ESP_LOGCONFIG(TAG, "BM8563:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void BM8563::start_timer(uint32_t duration_s) { diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 96bb56f345e..4b74a15c0a6 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -7,8 +7,6 @@ #include #include -#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" - namespace esphome::bme280_base { ESPHOME_LOG_TAG(TAG, "bme280.sensor"); @@ -100,13 +98,11 @@ void BME280Component::setup() { } if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x60) { - this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(LOG_STR(BME280_ERROR_WRONG_CHIP_ID)); + this->mark_failed(LOG_STR("Wrong chip ID or no response")); return; } @@ -182,17 +178,6 @@ void BME280Component::setup() { } void BME280Component::dump_config() { ESP_LOGCONFIG(TAG, "BME280:"); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_CHIP_ID: - ESP_LOGE(TAG, BME280_ERROR_WRONG_CHIP_ID); - break; - case NONE: - default: - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 8b4906b7b77..d1a441ccebb 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -101,11 +101,6 @@ class BME280Component : public PollingComponent { sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_CHIP_ID, - } error_code_{NONE}; }; } // namespace esphome::bme280_base diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 6bacb71ff20..3bc1f3ec1e2 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -72,22 +72,24 @@ constexpr float BME680_GAS_LOOKUP_TABLE_2[16] PROGMEM = {0.0, 0.0, 0.0, 0.0, 0. } void BME680Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool BME680Component::setup_registers_() { uint8_t chip_id; if (!this->read_byte(BME680_REGISTER_CHIPID, &chip_id) || chip_id != 0x61) { - this->mark_failed(); - return; + return false; } // Read calibration uint8_t coeff1[25]; if (!this->read_bytes(BME680_REGISTER_COEFF1, coeff1, 25)) { - this->mark_failed(); - return; + return false; } uint8_t coeff2[16]; if (!this->read_bytes(BME680_REGISTER_COEFF2, coeff2, 16)) { - this->mark_failed(); - return; + return false; } this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; @@ -119,20 +121,17 @@ void BME680Component::setup() { uint8_t temp_var = 0; if (!this->read_byte(0x02, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.res_heat_range = ((temp_var & 0x30) / 16); if (!this->read_byte(0x00, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.res_heat_val = (int8_t) temp_var; if (!this->read_byte(0x04, &temp_var)) { - this->mark_failed(); - return; + return false; } this->calibration_.range_sw_err = ((int8_t) temp_var & (int8_t) 0xf0) / 16; @@ -141,41 +140,35 @@ void BME680Component::setup() { // Config register uint8_t config_register; if (!this->read_byte(BME680_REGISTER_CONFIG, &config_register)) { - this->mark_failed(); - return; + return false; } config_register &= ~0b00011100; config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BME680_REGISTER_CONFIG, config_register)) { - this->mark_failed(); - return; + return false; } // Humidity control register uint8_t hum_control; if (!this->read_byte(BME680_REGISTER_CONTROL_HUMIDITY, &hum_control)) { - this->mark_failed(); - return; + return false; } hum_control &= ~0b00000111; hum_control |= this->humidity_oversampling_ & 0b111; if (!this->write_byte(BME680_REGISTER_CONTROL_HUMIDITY, hum_control)) { - this->mark_failed(); - return; + return false; } // Gas 1 control register uint8_t gas1_control; if (!this->read_byte(BME680_REGISTER_CONTROL_GAS1, &gas1_control)) { - this->mark_failed(); - return; + return false; } gas1_control &= ~0b00011111; gas1_control |= 1 << 4; gas1_control |= 0; // profile 0 if (!this->write_byte(BME680_REGISTER_CONTROL_GAS1, gas1_control)) { - this->mark_failed(); - return; + return false; } const bool heat_off = this->heater_temperature_ == 0 || this->heater_duration_ == 0; @@ -183,40 +176,34 @@ void BME680Component::setup() { // Gas 0 control register uint8_t gas0_control; if (!this->read_byte(BME680_REGISTER_CONTROL_GAS0, &gas0_control)) { - this->mark_failed(); - return; + return false; } gas0_control &= ~0b00001000; gas0_control |= heat_off << 3; if (!this->write_byte(BME680_REGISTER_CONTROL_GAS0, gas0_control)) { - this->mark_failed(); - return; + return false; } if (!heat_off) { // Gas Heater Temperature uint8_t temperature = this->calc_heater_resistance_(this->heater_temperature_); if (!this->write_byte(BME680_REGISTER_HEATER_HEAT0, temperature)) { - this->mark_failed(); - return; + return false; } // Gas Heater Duration uint8_t duration = this->calc_heater_duration_(this->heater_duration_); if (!this->write_byte(BME680_REGISTER_HEATER_WAIT0, duration)) { - this->mark_failed(); - return; + return false; } } + return true; } void BME680Component::dump_config() { ESP_LOGCONFIG(TAG, "BME680:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e401d036595..64fa20f2fb1 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -101,6 +101,8 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { void update() override; protected: + /// Configure the sensor registers; false on an I2C failure. + bool setup_registers_(); /// Calculate the heater resistance value to send to the BME680 register. uint8_t calc_heater_resistance_(uint16_t temperature); /// Calculate the heater duration value to send to the BME680 register. diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index a90d490ae18..b97baae06ae 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -117,18 +117,21 @@ const uint8_t BMI160_REGISTER_DATA_TEMP_MSB = 0x21; const float GRAVITY_EARTH = 9.80665f; void BMI160Component::internal_setup_(int stage) { + if (!this->setup_stage_(stage)) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool BMI160Component::setup_stage_(int stage) { switch (stage) { case 0: uint8_t chipid; if (!this->read_byte(BMI160_REGISTER_CHIPID, &chipid) || (chipid != 0b11010001)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Bringing accelerometer out of sleep"); if (!this->write_byte(BMI160_REGISTER_CMD, (uint8_t) Cmd::ACCL_SET_PMU_MODE | (uint8_t) AcclPmuMode::NORMAL)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Waiting for accelerometer to wake up"); // need to wait (max delay in datasheet) because we can't send commands while another is in progress @@ -139,8 +142,7 @@ void BMI160Component::internal_setup_(int stage) { case 1: ESP_LOGV(TAG, " Bringing gyroscope out of sleep"); if (!this->write_byte(BMI160_REGISTER_CMD, (uint8_t) Cmd::GYRO_SET_PMU_MODE | (uint8_t) GyroPmuMode::NORMAL)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Waiting for gyroscope to wake up"); // wait between 51 & 81ms, doing 100 to be safe @@ -152,15 +154,13 @@ void BMI160Component::internal_setup_(int stage) { uint8_t gyro_config = (uint8_t) GyroBandwidth::OSR4 | (uint8_t) GyroOuputDataRate::HZ_25; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(BMI160_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyro Range"); uint8_t gyro_range = (uint8_t) GyroRange::RANGE_2000_DPS; ESP_LOGV(TAG, " Output gyro_range: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_range)); if (!this->write_byte(BMI160_REGISTER_GYRO_RANGE, gyro_range)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Config"); @@ -168,28 +168,24 @@ void BMI160Component::internal_setup_(int stage) { (uint8_t) AcclFilterMode::PERF | (uint8_t) AcclBandwidth::RES_AVG16 | (uint8_t) AccelOutputDataRate::HZ_25; ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); if (!this->write_byte(BMI160_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Range"); uint8_t accel_range = (uint8_t) AccelRange::RANGE_16G; ESP_LOGV(TAG, " Output accel_range: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_range)); if (!this->write_byte(BMI160_REGISTER_ACCEL_RANGE, accel_range)) { - this->mark_failed(); - return; + return false; } this->setup_complete_ = true; } + return true; } void BMI160Component::setup() { this->internal_setup_(0); } void BMI160Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI160:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 8af25a09add..53704965cd9 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -31,6 +31,8 @@ class BMI160Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *gyro_z_sensor_{nullptr}; void internal_setup_(int stage); + /// Run one setup stage; false on an I2C failure. + bool setup_stage_(int stage); bool setup_complete_{false}; /** reads `len` 16-bit little-endian integers from the given i2c register */ diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 6f14598a5f4..e3c6d83a722 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -80,28 +80,26 @@ void BMI270Component::setup() { // 1. Verify chip ID uint8_t chip_id = 0; if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != BMI270_CHIP_ID_VALUE) { ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(20); // 4. Upload the configuration blob if (!load_config_file_()) { - ESP_LOGE(TAG, "Config file upload failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Config file upload failed")); return; } ESP_LOGD(TAG, "Config blob uploaded ✓"); @@ -111,11 +109,11 @@ void BMI270Component::setup() { 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -124,18 +122,18 @@ void BMI270Component::setup() { 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // 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, BMI270_PWR_CTRL_IMU)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(5); @@ -143,7 +141,7 @@ 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, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -272,10 +270,8 @@ bool BMI270Component::setup_magnetometer_() { void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Communication failed!"); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Accel range : %s", LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_, AccelRangeStrings::LAST_INDEX))); diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index b989ed877b6..f9ec710a9b6 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -24,7 +24,7 @@ void BMP085Component::update() { void BMP085Component::setup() { uint8_t data[22]; if (!this->read_bytes(BMP085_REGISTER_AC1_H, data, 22)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -44,9 +44,6 @@ void BMP085Component::setup() { void BMP085Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP085:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with BMP085 failed!"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 9cb023c6884..d82f80ccd43 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,8 +2,6 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" - namespace esphome::bmp280_base { ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); @@ -65,18 +63,15 @@ void BMP280Component::setup() { // Read the chip id twice, to work around a bug where the first read is 0. // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 if (!this->bmp_read_byte(0xD0, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->bmp_read_byte(0xD0, &chip_id)) { - this->error_code_ = COMMUNICATION_FAILED; this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x58) { - this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(LOG_STR(BMP280_ERROR_WRONG_CHIP_ID)); + this->mark_failed(LOG_STR("Wrong chip ID or no response")); return; } @@ -130,17 +125,6 @@ void BMP280Component::setup() { } void BMP280Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP280:"); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_CHIP_ID: - ESP_LOGE(TAG, BMP280_ERROR_WRONG_CHIP_ID); - break; - case NONE: - default: - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", iir_filter_to_str(this->iir_filter_)); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 860fff6b4b1..1c3fc9cf6cf 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -85,11 +85,6 @@ class BMP280Component : public PollingComponent { BMP280IIRFilter iir_filter_{BMP280_IIR_FILTER_OFF}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_CHIP_ID, - } error_code_{NONE}; }; } // namespace esphome::bmp280_base diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index b10a7a7a951..f59abc61ec3 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -43,35 +43,27 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { } void BMP3XXComponent::setup() { - this->error_code_ = NONE; // Call the Device base class "initialise" function if (!reset()) { - ESP_LOGE(TAG, "Failed to reset"); - this->error_code_ = ERROR_SENSOR_RESET; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset")); + return; } if (!read_byte(BMP388_CHIP_ID, &this->chip_id_.reg)) { - ESP_LOGE(TAG, "Can't read chip id"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGCONFIG(TAG, "Chip %s Id 0x%X", LOG_STR_ARG(chip_type_to_str(this->chip_id_.reg)), this->chip_id_.reg); if (chip_id_.reg != BMP388_ID && chip_id_.reg != BMP390_ID) { - ESP_LOGE(TAG, "Unknown chip id - is this really a BMP388 or BMP390?"); - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID - please check if you are really using a BMP 388 or BMP 390")); return; } // set sensor in sleep mode stop_conversion(); // Read the calibration parameters into the params structure if (!read_bytes(BMP388_TRIM_PARAMS, (uint8_t *) &compensation_params_, sizeof(compensation_params_))) { - ESP_LOGE(TAG, "Can't read calibration data"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Can't read calibration data")); return; } compensation_float_params_.param_T1 = @@ -92,9 +84,7 @@ void BMP3XXComponent::setup() { // Initialise the BMP388 IIR filter register if (!set_iir_filter(this->iir_filter_)) { - ESP_LOGE(TAG, "Failed to set IIR filter"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set IIR filter")); return; } @@ -113,9 +103,7 @@ void BMP3XXComponent::setup() { } // Initialise the BMP388 oversampling register if (!set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_)) { - ESP_LOGE(TAG, "Failed to set oversampling register"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set oversampling register")); return; } } @@ -125,23 +113,6 @@ void BMP3XXComponent::dump_config() { "BMP3XX:\n" " Type: %s (0x%X)", LOG_STR_ARG(chip_type_to_str(this->chip_id_.reg)), this->chip_id_.reg); - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Wrong chip ID (reported id: 0x%X) - please check if you are really using a BMP 388 or BMP 390", - this->chip_id_.reg); - break; - case ERROR_SENSOR_RESET: - ESP_LOGE(TAG, "Failed to reset"); - break; - default: - ESP_LOGE(TAG, "Error code %d", (int) this->error_code_); - break; - } ESP_LOGCONFIG(TAG, " IIR Filter: %s", LOG_STR_ARG(iir_filter_to_str(this->iir_filter_))); LOG_UPDATE_INTERVAL(this); if (this->temperature_sensor_) { @@ -165,8 +136,7 @@ void BMP3XXComponent::update() { meas_time += 2.02f * oversampling_to_time(this->pressure_oversampling_) + 0.392f; meas_time += 0.234f; if (!set_mode(FORCED_MODE)) { - ESP_LOGE(TAG, "Failed start forced mode"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed start forced mode")); return; } diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.h b/esphome/components/bmp3xx_base/bmp3xx_base.h index cd70b2fb163..c3e30890411 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.h +++ b/esphome/components/bmp3xx_base/bmp3xx_base.h @@ -124,13 +124,6 @@ class BMP3XXComponent : public PollingComponent { OperationMode operation_mode_{FORCED_MODE}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - ERROR_SENSOR_STATUS, - ERROR_SENSOR_RESET, - } error_code_{NONE}; struct { // The BMP388 compensation trim parameters (coefficients) uint16_t param_T1; diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 29f67fadc51..7bc743977b2 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -38,29 +38,6 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { void BMP581Component::dump_config() { ESP_LOGCONFIG(TAG, "BMP581:"); - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Unknown chip ID"); - break; - case ERROR_SENSOR_RESET: - ESP_LOGE(TAG, "Reset failed"); - break; - case ERROR_SENSOR_STATUS: - ESP_LOGE(TAG, "Get status failed"); - break; - case ERROR_PRIME_IIR_FAILED: - ESP_LOGE(TAG, "IIR Filter failed to prime with initial measurement"); - break; - default: - ESP_LOGE(TAG, "Error %d", (int) this->error_code_); - break; - } - LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Measurement conversion time: %ums", this->conversion_time_); @@ -96,17 +73,13 @@ void BMP581Component::setup() { * 6) Configure and prime IIR Filter(s), if enabled */ - this->error_code_ = NONE; //////////////////// // 1) Soft reboot // //////////////////// // Power-On-Reboot bit is asserted if sensor successfully reset if (!this->reset_()) { - ESP_LOGE(TAG, "Reset failed"); - - this->error_code_ = ERROR_SENSOR_RESET; - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed")); return; } @@ -119,20 +92,14 @@ void BMP581Component::setup() { // read chip id from sensor if (!this->bmp_read_byte(BMP581_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Read chip ID failed"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Read chip ID failed")); return; } // verify id if (chip_id != BMP581_ASIC_ID && chip_id != BMP585_ASIC_ID) { - ESP_LOGE(TAG, "Unknown chip ID"); - - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown chip ID")); return; } @@ -142,30 +109,21 @@ void BMP581Component::setup() { //////////////////////////////////////////////////// if (!this->bmp_read_byte(BMP581_STATUS, &this->status_.reg)) { - ESP_LOGE(TAG, "Failed to read status register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read status register")); return; } // verify status_nvm_rdy bit (it is asserted if boot was successful) if (!(this->status_.bit.status_nvm_rdy)) { - ESP_LOGE(TAG, "NVM not ready"); - - this->error_code_ = ERROR_SENSOR_STATUS; - this->mark_failed(); + this->mark_failed(LOG_STR("NVM not ready")); return; } // verify status_nvm_err bit (it is asserted if an error is detected) if (this->status_.bit.status_nvm_err) { - ESP_LOGE(TAG, "NVM error detected"); - - this->error_code_ = ERROR_SENSOR_STATUS; - this->mark_failed(); + this->mark_failed(LOG_STR("NVM error detected")); return; } @@ -176,10 +134,7 @@ void BMP581Component::setup() { // enable the data ready interrupt source if (!this->write_interrupt_source_settings_(true)) { - ESP_LOGE(TAG, "Failed to write interrupt source register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write interrupt source register")); return; } @@ -198,10 +153,7 @@ void BMP581Component::setup() { // write oversampling settings if (!this->write_oversampling_settings_(this->temperature_oversampling_, this->pressure_oversampling_)) { - ESP_LOGE(TAG, "Failed to write oversampling register"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write oversampling register")); return; } @@ -218,19 +170,13 @@ void BMP581Component::setup() { if ((this->iir_temperature_level_ != IIR_FILTER_OFF) || (this->iir_pressure_level_ != IIR_FILTER_OFF)) { if (!this->write_iir_settings_(this->iir_temperature_level_, this->iir_pressure_level_)) { - ESP_LOGE(TAG, "Failed to write IIR configuration registers"); - - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write IIR configuration registers")); return; } if (!this->prime_iir_filter_()) { - ESP_LOGE(TAG, "Failed to prime the IIR filter with an initial measurement"); - - this->error_code_ = ERROR_PRIME_IIR_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to prime the IIR filter with an initial measurement")); return; } diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index 1a73a91558c..abd7f64b4d2 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -133,15 +133,6 @@ class BMP581Component : public PollingComponent { // Sets the power mode on the BMP581 by writing to the ODR register bool write_power_mode_(OperationMode mode); - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - ERROR_SENSOR_STATUS, - ERROR_SENSOR_RESET, - ERROR_PRIME_IIR_FAILED - } error_code_{NONE}; - // BMP581's interrupt source register (address 0x15) to configure which interrupts are enabled (page 54 of datasheet) union { struct { diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index d2f7961e020..b7912b930e6 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -48,7 +48,7 @@ void BQ27220Component::setup() { } if (device_number != DEVICE_NUMBER) { ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected device number")); } } @@ -76,9 +76,6 @@ void BQ27220Component::update() { 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_); diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index d6d97329db4..394b3a909bd 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -37,8 +37,7 @@ void CAP1188Component::finish_setup_() { this->read_byte(CAP1188_REVISION, &this->cap1188_revision_); if ((this->cap1188_product_id_ != 0x50) || (this->cap1188_manufacture_id_ != 0x5D)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Product ID or Manufacture ID of the connected device does not match a known CAP1188")); return; } @@ -70,15 +69,6 @@ void CAP1188Component::dump_config() { this->cap1188_product_id_, this->cap1188_manufacture_id_, this->cap1188_revision_); LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Product ID or Manufacture ID of the connected device does not match a known CAP1188."); - break; - case NONE: - default: - break; - } } void CAP1188Component::loop() { diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index a4abb270e70..daa2c4015f6 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -59,11 +59,6 @@ class CAP1188Component final : public Component, public i2c::I2CDevice { uint8_t cap1188_product_id_{0}; uint8_t cap1188_manufacture_id_{0}; uint8_t cap1188_revision_{0}; - - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::cap1188 diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index 126ed0049d1..1cae581194f 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -10,29 +10,28 @@ ESPHOME_LOG_TAG(TAG, "ccs811"); // based on // - https://cdn.sparkfun.com/datasheets/BreakoutBoards/CCS811_Programming_Guide.pdf -#define CHECK_TRUE(f, error_code) \ +#define CHECK_TRUE(f, message) \ if (!(f)) { \ - this->mark_failed(); \ - this->error_code_ = (error_code); \ + this->mark_failed(LOG_STR(message)); \ return; \ } -#define CHECKED_IO(f) CHECK_TRUE(f, COMMUNICATION_FAILED) +#define CHECKED_IO(f) CHECK_TRUE(f, ESP_LOG_MSG_COMM_FAIL) void CCS811Component::setup() { // page 9 programming guide - hwid is always 0x81 uint8_t hw_id; CHECKED_IO(this->read_byte(0x20, &hw_id)) - CHECK_TRUE(hw_id == 0x81, INVALID_ID) + CHECK_TRUE(hw_id == 0x81, "Sensor reported an invalid ID. Is this a CCS811?") // software reset, page 3 - allowed to fail this->write_bytes(0xFF, {0x11, 0xE5, 0x72, 0x8A}); delay(5); // page 10, APP_START - CHECK_TRUE(!this->status_has_error_(), SENSOR_REPORTED_ERROR) - CHECK_TRUE(this->status_app_is_valid_(), APP_INVALID) - CHECK_TRUE(this->write_bytes(0xF4, {}), APP_START_FAILED) + CHECK_TRUE(!this->status_has_error_(), "Sensor reported internal error") + CHECK_TRUE(this->status_app_is_valid_(), "Sensor reported invalid APP installed") + CHECK_TRUE(this->write_bytes(0xF4, {}), "Sensor reported APP start failed") // App setup, wait for it to load delay(1); @@ -160,29 +159,6 @@ void CCS811Component::dump_config() { } else { ESP_LOGCONFIG(TAG, " Baseline: NOT SET"); } - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case INVALID_ID: - ESP_LOGW(TAG, "Sensor reported an invalid ID. Is this a CCS811?"); - break; - case SENSOR_REPORTED_ERROR: - ESP_LOGW(TAG, "Sensor reported internal error"); - break; - case APP_INVALID: - ESP_LOGW(TAG, "Sensor reported invalid APP installed."); - break; - case APP_START_FAILED: - ESP_LOGW(TAG, "Sensor reported APP start failed."); - break; - case UNKNOWN: - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } } } // namespace esphome::ccs811 diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fb83f842fd9..d7a8f22f116 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -31,15 +31,6 @@ class CCS811Component final : public PollingComponent, public i2c::I2CDevice { bool status_has_data_() { return this->read_status_().value_or(0) & (1 << 3); } void send_env_data_(); - enum ErrorCode { - UNKNOWN, - COMMUNICATION_FAILED, - INVALID_ID, - SENSOR_REPORTED_ERROR, - APP_INVALID, - APP_START_FAILED, - } error_code_{UNKNOWN}; - sensor::Sensor *co2_{nullptr}; sensor::Sensor *tvoc_{nullptr}; text_sensor::TextSensor *version_{nullptr}; diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 28b355a4f1d..e76801ff77a 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -18,7 +18,7 @@ void CH422GComponent::setup() { // Set mode and check for errors if (!this->set_mode_(this->mode_value_) || !this->read_inputs_()) { ESP_LOGE(TAG, "CH422G not detected at 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -34,9 +34,6 @@ void CH422GComponent::loop() { void CH422GComponent::dump_config() { ESP_LOGCONFIG(TAG, "CH422G:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void CH422GComponent::pin_mode(uint8_t pin, gpio::Flags flags) { diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index b7e9d224faf..3a57f646d4e 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -24,8 +24,7 @@ void CH423Component::setup() { success = this->read_inputs_(); } if (!success) { - ESP_LOGE(TAG, "CH423 not detected"); - this->mark_failed(); + this->mark_failed(LOG_STR("CH423 not detected")); return; } @@ -38,12 +37,7 @@ void CH423Component::loop() { this->pin_read_flags_ = 0x00; } -void CH423Component::dump_config() { - ESP_LOGCONFIG(TAG, "CH423:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } -} +void CH423Component::dump_config() { ESP_LOGCONFIG(TAG, "CH423:"); } void CH423Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (pin < 8) { diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index edfcd0f7309..55b7556cda5 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -27,8 +27,7 @@ uint8_t cm1106_checksum(const uint8_t *response, size_t len) { void CM1106Component::setup() { uint8_t response[8] = {0}; if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -139,9 +138,6 @@ void CM1106Component::dump_config() { " ABC baseline: %uppm", ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_); } - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } } // namespace esphome::cm1106 diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 4ba93af01f1..0c56c741fe6 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -67,15 +67,12 @@ void CSE7761Component::setup() { ESP_LOGD(TAG, "CSE7761 found"); this->data_.ready = true; } else { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } void CSE7761Component::dump_config() { ESP_LOGCONFIG(TAG, "CSE7761:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Current input: %s", LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt"))); LOG_UPDATE_INTERVAL(this); From e95e56f1002d7cc26b18aa6481933ab65435df99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:39:20 -1000 Subject: [PATCH 47/50] [components] Let core report communication failures in dump_config (2/5) (#20871) --- esphome/components/dac7678/dac7678_output.cpp | 10 +-- esphome/components/dht12/dht12.cpp | 5 +- esphome/components/dps310/dps310.cpp | 36 +++++------ esphome/components/dps310/dps310.h | 2 + esphome/components/ds1307/ds1307.cpp | 5 +- esphome/components/ds3231/ds3231.cpp | 4 -- esphome/components/emc2101/emc2101.cpp | 5 +- .../components/ens160_base/ens160_base.cpp | 64 +++++-------------- esphome/components/ens160_base/ens160_base.h | 10 --- esphome/components/ens210/ens210.cpp | 20 ++---- esphome/components/ens210/ens210.h | 3 +- esphome/components/es7210/es7210.cpp | 7 +- esphome/components/es7243e/es7243e.cpp | 11 +--- esphome/components/es8156/es8156.cpp | 11 +--- esphome/components/es8311/es8311.cpp | 7 +- esphome/components/es8388/es8388.cpp | 7 +- .../camera_web_server.cpp | 8 +-- esphome/components/ezo/ezo.cpp | 3 - esphome/components/ezo_pmp/ezo_pmp.cpp | 3 - esphome/components/gdk101/gdk101.cpp | 3 - .../grove_gas_mc_v2/grove_gas_mc_v2.cpp | 22 +------ .../grove_gas_mc_v2/grove_gas_mc_v2.h | 7 -- esphome/components/hdc1080/hdc1080.cpp | 3 - esphome/components/hdc2010/hdc2010.cpp | 3 - esphome/components/hdc2080/hdc2080.cpp | 3 - esphome/components/hmc5883l/hmc5883l.cpp | 20 ++---- esphome/components/hmc5883l/hmc5883l.h | 5 -- .../honeywell_hih_i2c/honeywell_hih.cpp | 15 ++--- esphome/components/htu21d/htu21d.cpp | 5 +- esphome/components/htu31d/htu31d.cpp | 7 +- esphome/components/husb238/husb238.cpp | 3 - .../components/hydreon_rgxx/hydreon_rgxx.cpp | 5 +- esphome/components/iaqcore/iaqcore.cpp | 6 +- esphome/components/icm20689/icm20689.cpp | 9 +-- .../components/ili9xxx/ili9xxx_display.cpp | 5 +- 35 files changed, 74 insertions(+), 268 deletions(-) diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index d1a076733aa..de61964a8ff 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -23,8 +23,7 @@ void DAC7678Output::setup() { // Reset device if (!this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000)) { - ESP_LOGE(TAG, "Reset failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed")); return; } else { ESP_LOGV(TAG, "Reset succeeded"); @@ -35,8 +34,7 @@ void DAC7678Output::setup() { // Set internal reference if (this->internal_reference_) { if (!this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4)) { - ESP_LOGE(TAG, "Set internal reference failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set internal reference failed")); return; } else { ESP_LOGV(TAG, "Internal reference enabled"); @@ -45,9 +43,7 @@ void DAC7678Output::setup() { } void DAC7678Output::dump_config() { - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up DAC7678 failed!"); - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, "DAC7678 initialised"); } } diff --git a/esphome/components/dht12/dht12.cpp b/esphome/components/dht12/dht12.cpp index e099b65d5b7..353546dba3a 100644 --- a/esphome/components/dht12/dht12.cpp +++ b/esphome/components/dht12/dht12.cpp @@ -35,16 +35,13 @@ void DHT12Component::update() { void DHT12Component::setup() { uint8_t data[5]; if (!this->read_data_(data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void DHT12Component::dump_config() { ESP_LOGD(TAG, "DHT12:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); } diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 080f101747f..6ab786e7c71 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -9,13 +9,17 @@ ESPHOME_LOG_TAG(TAG, "dps310"); static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 0; void DPS310Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool DPS310Component::setup_registers_() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; auto timer = DPS310_INIT_TIMEOUT; uint8_t reg = 0; // first, reset the sensor if (!this->write_byte(DPS310_REG_RESET, DPS310_CMD_RESET)) { - this->mark_failed(); - return; + return false; } delay(10); // wait for the sensor and its coefficients to be ready @@ -25,40 +29,32 @@ void DPS310Component::setup() { } if (!(reg & DPS310_BIT_SENSOR_RDY) || !(reg & DPS310_BIT_COEF_RDY)) { // the flags were not set in time - this->mark_failed(); - return; + return false; } // read device ID if (!this->read_byte(DPS310_REG_PROD_REV_ID, &this->prod_rev_id_)) { - this->mark_failed(); - return; + return false; } // read in coefficients used to calculate the compensated pressure and temperature values if (!this->read_bytes(DPS310_REG_COEF, coef_data_raw, DPS310_NUM_COEF_REGS)) { - this->mark_failed(); - return; + return false; } // read in coefficients source register, too -- we need this a few lines down if (!this->read_byte(DPS310_REG_TMP_COEF_SRC, ®)) { - this->mark_failed(); - return; + return false; } // set up operational stuff if (!this->write_byte(DPS310_REG_PRS_CFG, DPS310_VAL_PRS_CFG)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_TMP_CFG, DPS310_VAL_TMP_CFG | (reg & DPS310_BIT_TMP_COEF_SRC))) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_CFG, DPS310_VAL_REG_CFG)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(DPS310_REG_MEAS_CFG, 0x07)) { // enable background mode - this->mark_failed(); - return; + return false; } this->c0_ = // we only ever use c0/2, so just divide by 2 here to save time later @@ -82,6 +78,7 @@ void DPS310Component::setup() { this->c20_ = int16_t(((uint16_t) coef_data_raw[12] << 8) | (uint16_t) coef_data_raw[13]); this->c21_ = int16_t(((uint16_t) coef_data_raw[14] << 8) | (uint16_t) coef_data_raw[15]); this->c30_ = int16_t(((uint16_t) coef_data_raw[16] << 8) | (uint16_t) coef_data_raw[17]); + return true; } void DPS310Component::dump_config() { @@ -91,9 +88,6 @@ void DPS310Component::dump_config() { " Revision ID: %u", this->prod_rev_id_ & 0x0F, (this->prod_rev_id_ >> 4) & 0x0F); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 4dd23985d75..d761940ca99 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -45,6 +45,8 @@ class DPS310Component final : public PollingComponent, public i2c::I2CDevice { void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } protected: + /// Configure the sensor; false on a failure. + bool setup_registers_(); void read_(); void read_pressure_(); void read_temperature_(); diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 4ddef04f413..485e0f4b5c1 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "ds1307"); void DS1307Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void DS1307Component::update() { this->read_time(); } void DS1307Component::dump_config() { ESP_LOGCONFIG(TAG, "DS1307:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 4f7396fcbdd..72cae0e3832 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -29,10 +29,6 @@ void DS3231Component::setup() { void DS3231Component::dump_config() { ESP_LOGCONFIG(TAG, "DS3231:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - return; - } } bool DS3231Component::read_temperature(float &out) { diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 6a21b4e7b8e..c6ae5169e4c 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -60,7 +60,7 @@ void Emc2101Component::setup() { uint8_t chip_id = reg(EMC2101_REGISTER_WHOAMI).get(); if ((chip_id != EMC2101_CHIP_ID) && (chip_id != EMC2101_ALT_CHIP_ID)) { ESP_LOGE(TAG, "Wrong chip ID %02X", chip_id); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -90,9 +90,6 @@ void Emc2101Component::setup() { void Emc2101Component::dump_config() { ESP_LOGCONFIG(TAG, "Emc2101 component:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Mode: %s", this->dac_mode_ ? LOG_STR_LITERAL("DAC") : LOG_STR_LITERAL("PWM")); if (this->dac_mode_) { ESP_LOGCONFIG(TAG, " DAC Conversion Rate: %X", this->dac_conversion_rate_); diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index b06182e59c0..b18a44f7523 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -62,20 +62,17 @@ void ENS160Component::setup() { // check part_id uint16_t part_id; if (!this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (part_id != ENS160_PART_ID) { - this->error_code_ = INVALID_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor reported an invalid ID. Is this a ENS160?")); return; } // set mode to reset if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); @@ -83,51 +80,44 @@ void ENS160Component::setup() { // check status uint8_t status_value; if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); if (this->validity_flag_ == INVALID_OUTPUT) { - this->error_code_ = VALIDITY_INVALID; - this->mark_failed(); + this->mark_failed(LOG_STR("Invalid Device Status - No valid output")); return; } // set mode to idle if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); // clear command if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_CLRGPR)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); // read firmware version if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(ENS160_BOOTING); uint8_t version_data[3]; if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->firmware_ver_major_ = version_data[0]; @@ -136,22 +126,19 @@ void ENS160Component::setup() { // set mode to standard if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { - this->error_code_ = WRITE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // read opmode and check standard mode is achieved before finishing Setup uint8_t op_mode; if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { - this->error_code_ = READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (op_mode != ENS160_OPMODE_STD) { - this->error_code_ = STD_OPMODE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Device failed to achieve Standard Operating Mode")); return; } } @@ -291,29 +278,8 @@ void ENS160Component::send_env_data_() { void ENS160Component::dump_config() { ESP_LOGCONFIG(TAG, "ENS160:"); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case READ_FAILED: - ESP_LOGE(TAG, "Error reading from register"); - break; - case WRITE_FAILED: - ESP_LOGE(TAG, "Error writing to register"); - break; - case INVALID_ID: - ESP_LOGE(TAG, "Sensor reported an invalid ID. Is this a ENS160?"); - break; - case VALIDITY_INVALID: - ESP_LOGE(TAG, "Invalid Device Status - No valid output"); - break; - case STD_OPMODE_FAILED: - ESP_LOGE(TAG, "Device failed to achieve Standard Operating Mode"); - break; - case NONE: - ESP_LOGD(TAG, "Setup successful"); - break; + if (!this->is_failed()) { + ESP_LOGD(TAG, "Setup successful"); } ESP_LOGI(TAG, "Firmware Version: %d.%d.%d", this->firmware_ver_major_, this->firmware_ver_minor_, this->firmware_ver_build_); diff --git a/esphome/components/ens160_base/ens160_base.h b/esphome/components/ens160_base/ens160_base.h index f42272684ed..d844dccb02c 100644 --- a/esphome/components/ens160_base/ens160_base.h +++ b/esphome/components/ens160_base/ens160_base.h @@ -21,16 +21,6 @@ class ENS160Component : public PollingComponent, public sensor::Sensor { protected: void send_env_data_(); - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - INVALID_ID, - VALIDITY_INVALID, - READ_FAILED, - WRITE_FAILED, - STD_OPMODE_FAILED, - } error_code_{NONE}; - enum ValidityFlag { NORMAL_OPERATION = 0, WARMING_UP, diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 5815fc90382..2251e0a574b 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -55,8 +55,6 @@ static const LogString *ens210_status_to_human(int status) { return LOG_STR("Invalid data"); case ENS210Component::ENS210_STATUS_OK: return LOG_STR("Status OK"); - case ENS210Component::ENS210_WRONG_CHIP_ID: - return LOG_STR("ENS210 has wrong chip ID! Is it a ENS210?"); default: return LOG_STR("Unknown"); } @@ -93,8 +91,7 @@ void ENS210Component::setup() { // Reset if (!this->write_byte(ENS210_REGISTER_SYS_CTRL, 0x80)) { this->write_byte(ENS210_REGISTER_SYS_CTRL, 0x80); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Wait to boot after reset @@ -103,24 +100,24 @@ void ENS210Component::setup() { if (!set_low_power_(false)) { // Try to go back to default mode (low power enabled) set_low_power_(true); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Read the PART_ID if (!this->read_bytes(ENS210_REGISTER_PART_ID, data, 2)) { // Try to go back to default mode (low power enabled) set_low_power_(true); - this->error_code_ = ENS210_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // Pack bytes into partid part_id = data[1] * 256U + data[0] * 1U; // Check expected part id of the ENS210 if (part_id != ENS210_PART_ID) { - this->error_code_ = ENS210_WRONG_CHIP_ID; - this->mark_failed(); + // Try to go back to default mode (low power enabled) + set_low_power_(true); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; } // Set default power mode (low power enabled) set_low_power_(true); @@ -129,9 +126,6 @@ void ENS210Component::setup() { void ENS210Component::dump_config() { ESP_LOGCONFIG(TAG, "ENS210:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "%s", LOG_STR_ARG(ens210_status_to_human(this->error_code_))); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index fca20133b82..803d3b9cedf 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -22,8 +22,7 @@ class ENS210Component final : public PollingComponent, public i2c::I2CDevice { // not yet finished) ENS210_STATUS_CRC_ERROR, // The value was read, but the CRC over the payload (valid and data) does not match ENS210_STATUS_I2C_ERROR, // There was an I2C communication error - ENS210_WRONG_CHIP_ID // The read PART_ID is not the expected part id of the ENS210 - } error_code_{ENS210_STATUS_OK}; + }; protected: bool set_low_power_(bool enable); diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index fbabf7460f2..9eb24653dfc 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -13,7 +13,7 @@ static const size_t MCLK_DIV_FRE = 256; // Mark the component as failed; use only in setup #define ES7210_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -31,11 +31,6 @@ void ES7210::dump_config() { " Channel Gains: %.1f dB, %.1f dB, %.1f dB, %.1f dB", this->bits_per_sample_, this->sample_rate_, this->channel_gains_[0], this->channel_gains_[1], this->channel_gains_[2], this->channel_gains_[3]); - - if (this->is_failed()) { - ESP_LOGE(TAG, " Failed to initialize"); - return; - } } void ES7210::setup() { diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index 1bfa6933ddc..c43ed38cd57 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -13,7 +13,7 @@ ESPHOME_LOG_TAG(TAG, "es7243e"); // Mark the component as failed; use only in setup #define ES7243E_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -23,14 +23,7 @@ ESPHOME_LOG_TAG(TAG, "es7243e"); return false; \ } -void ES7243E::dump_config() { - ESP_LOGCONFIG(TAG, "ES7243E audio ADC:"); - - if (this->is_failed()) { - ESP_LOGE(TAG, " Failed to initialize"); - return; - } -} +void ES7243E::dump_config() { ESP_LOGCONFIG(TAG, "ES7243E audio ADC:"); } void ES7243E::setup() { ES7243E_ERROR_FAILED(this->write_byte(ES7243E_CLOCK_MGR_REG01, 0x3A)); diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index 467402e704c..4b854a1bc35 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8156"); // Mark the component as failed; use only in setup #define ES8156_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -74,14 +74,7 @@ void ES8156::setup() { ES8156_ERROR_FAILED(this->write_byte(ES8156_REG25_ANALOG_SYS5, 0x20)); } -void ES8156::dump_config() { - ESP_LOGCONFIG(TAG, "ES8156 Audio Codec:"); - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize"); - return; - } -} +void ES8156::dump_config() { ESP_LOGCONFIG(TAG, "ES8156 Audio Codec:"); } bool ES8156::set_volume(float volume) { volume = clamp(volume, 0.0f, 1.0f); diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 96b644ee780..67229d908ec 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8311"); // Mark the component as failed; use only in setup #define ES8311_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } // Return false; use outside of setup @@ -56,11 +56,6 @@ void ES8311::dump_config() { " DAC Bits per Sample: %" PRIu8 "\n" " Sample Rate: %" PRIu32, YESNO(this->use_mclk_), YESNO(this->use_mic_), this->resolution_out_, this->sample_frequency_); - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize!"); - return; - } } bool ES8311::set_volume(float volume) { diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 56ee1ac4a59..d7aedd9d9e5 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -11,7 +11,7 @@ ESPHOME_LOG_TAG(TAG, "es8388"); // Mark the component as failed; use only in setup #define ES8388_ERROR_FAILED(func) \ if (!(func)) { \ - this->mark_failed(); \ + this->mark_failed(LOG_STR("Failed to initialize")); \ return; \ } @@ -154,11 +154,6 @@ void ES8388::dump_config() { LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif - - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " Failed to initialize"); - return; - } } bool ES8388::set_volume(float volume) { diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 36c1cdfa45b..a248b901204 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -42,7 +42,7 @@ CameraWebServer::~CameraWebServer() {} void CameraWebServer::setup() { if (!camera::Camera::instance() || camera::Camera::instance()->is_failed()) { - this->mark_failed(); + this->mark_failed(LOG_STR("Camera not available")); return; } @@ -56,7 +56,7 @@ void CameraWebServer::setup() { config.lru_purge_enable = true; if (httpd_start(&this->httpd_, &config) != ESP_OK) { - mark_failed(); + this->mark_failed(LOG_STR("Failed to start the web server")); return; } @@ -97,10 +97,6 @@ void CameraWebServer::dump_config() { } else { ESP_LOGCONFIG(TAG, " Mode: snapshot"); } - - if (this->is_failed()) { - ESP_LOGE(TAG, " Setup Failed"); - } } float CameraWebServer::get_setup_priority() const { return setup_priority::LATE; } diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index d18e6e67cb1..c9a5087bf5c 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -13,9 +13,6 @@ static const char *const EZO_CALIBRATION_TYPE_STRINGS[] = {"LOW", "MID", "HIGH"} void EZOSensor::dump_config() { LOG_SENSOR("", "EZO", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 8db57670aea..393ba66fdf0 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -39,9 +39,6 @@ static const std::string DOSING_MODE_CONTINUOUS = "Continuous"; void EzoPMP::dump_config() { LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 3c7e8d21613..a84d5ba06fb 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -82,9 +82,6 @@ bool GDK101Component::try_reset_() { void GDK101Component::dump_config() { ESP_LOGCONFIG(TAG, "GDK101:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } #ifdef USE_SENSOR LOG_SENSOR(" ", "Average Radaition Dose per 1 minute", this->rad_1m_sensor_); LOG_SENSOR(" ", "Average Radaition Dose per 10 minutes", this->rad_10m_sensor_); diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index b01dd119e63..1acf8076af4 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -23,7 +23,6 @@ bool GroveGasMultichannelV2Component::read_sensor_(uint8_t address, sensor::Sens uint32_t value = 0; if (!this->read_bytes(address, (uint8_t *) &value, 4)) { ESP_LOGW(TAG, "Reading Grove Gas Sensor data failed!"); - this->error_code_ = COMMUNICATION_FAILED; this->status_set_warning(); return false; } @@ -34,8 +33,7 @@ bool GroveGasMultichannelV2Component::read_sensor_(uint8_t address, sensor::Sens void GroveGasMultichannelV2Component::setup() { // Before reading sensor values, must preheat sensor if (!(this->write_bytes(GROVE_GAS_MC_V2_HEAT_ON, {}))) { - this->mark_failed(); - this->error_code_ = APP_START_FAILED; + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -61,24 +59,6 @@ void GroveGasMultichannelV2Component::dump_config() { LOG_SENSOR(" ", "Ethanol", this->ethanol_sensor_); LOG_SENSOR(" ", "Carbon Monoxide", this->carbon_monoxide_sensor_); LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); - - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case APP_INVALID: - ESP_LOGW(TAG, "Sensor reported invalid APP installed"); - break; - case APP_START_FAILED: - ESP_LOGW(TAG, "Sensor reported APP start failed"); - break; - case UNKNOWN: - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } } } // namespace esphome::grove_gas_mc_v2 diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 545b6df97a9..8aafdea8e7d 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -22,13 +22,6 @@ class GroveGasMultichannelV2Component final : public PollingComponent, public i2 void dump_config() override; protected: - enum ErrorCode { - UNKNOWN, - COMMUNICATION_FAILED, - APP_INVALID, - APP_START_FAILED, - } error_code_{UNKNOWN}; - bool read_sensor_(uint8_t address, sensor::Sensor *sensor); }; diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index be0de1bae6e..c61feb52d57 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -24,9 +24,6 @@ void HDC1080Component::setup() { void HDC1080Component::dump_config() { ESP_LOGCONFIG(TAG, "HDC1080:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 825d7d3da96..471afe515a1 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -42,9 +42,6 @@ void HDC2010Component::setup() { void HDC2010Component::dump_config() { ESP_LOGCONFIG(TAG, "HDC2010:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 8210733b4cb..beb84f30746 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -33,9 +33,6 @@ void HDC2080Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void HDC2080Component::update() { diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index c747e7968ed..d1abe8c1fdb 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -27,8 +27,7 @@ void HMC5883LComponent::setup() { if (!this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_A, &id[0]) || !this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_B, &id[1]) || !this->read_byte(HMC5883L_REGISTER_IDENTIFICATION_C, &id[2])) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -37,8 +36,7 @@ void HMC5883LComponent::setup() { } if (id[0] != 0x48 || id[1] != 0x34 || id[2] != 0x33) { - this->error_code_ = ID_REGISTERS; - this->mark_failed(); + this->mark_failed(LOG_STR("The ID registers don't match - Is this really an HMC5883L?")); return; } @@ -47,16 +45,14 @@ void HMC5883LComponent::setup() { config_a |= this->datarate_ << 2; config_a |= 0b0 << 0; // Measurement Mode: Normal(high impedance on load) if (!this->write_byte(HMC5883L_REGISTER_CONFIG_A, config_a)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t config_b = 0; config_b |= this->range_ << 5; if (!this->write_byte(HMC5883L_REGISTER_CONFIG_B, config_b)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -64,19 +60,13 @@ void HMC5883LComponent::setup() { // Continuous Measurement Mode mode |= 0b00; if (!this->write_byte(HMC5883L_REGISTER_MODE, mode)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void HMC5883LComponent::dump_config() { ESP_LOGCONFIG(TAG, "HMC5883L:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } else if (this->error_code_ == ID_REGISTERS) { - ESP_LOGE(TAG, "The ID registers don't match - Is this really an HMC5883L?"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index d23eb1a0f4a..6e5303d8988 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -56,11 +56,6 @@ class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *y_sensor_{nullptr}; sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - ID_REGISTERS, - } error_code_{NONE}; HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index 352d3f7f01f..91f3089c729 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -18,8 +18,7 @@ void HoneywellHIComponent::read_sensor_data_() { uint8_t data[4]; if (this->read(data, sizeof(data)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -38,8 +37,7 @@ void HoneywellHIComponent::read_sensor_data_() { void HoneywellHIComponent::start_measurement_() { if (this->write(REQUEST_CMD, sizeof(REQUEST_CMD)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -50,8 +48,7 @@ bool HoneywellHIComponent::is_measurement_ready_() { uint8_t data[1]; if (this->read(data, sizeof(data)) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } @@ -60,9 +57,8 @@ bool HoneywellHIComponent::is_measurement_ready_() { } void HoneywellHIComponent::measurement_timeout_() { - ESP_LOGE(TAG, "Honeywell HIH Timeout!"); this->measurement_running_ = false; - this->mark_failed(); + this->mark_failed(LOG_STR("Measurement timeout")); } void HoneywellHIComponent::update() { @@ -84,9 +80,6 @@ void HoneywellHIComponent::loop() { void HoneywellHIComponent::dump_config() { ESP_LOGD(TAG, "Honeywell HIH:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index 468a7f8e6a2..4fe502f2a5c 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -18,7 +18,7 @@ static const uint8_t HTU21D_REG_HTRE_BIT = 0x02; /**< Control Register He void HTU21DComponent::setup() { if (!this->write_bytes(HTU21D_REGISTER_RESET, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -28,9 +28,6 @@ void HTU21DComponent::setup() { void HTU21DComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTU21D:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 86c9876b2b6..5426ca50845 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -49,12 +49,12 @@ static const uint8_t HTU31D_DIAGNOSTICS = 0x08; */ void HTU31DComponent::setup() { if (!this->reset_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->read_serial_num_() == 0) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -131,9 +131,6 @@ void HTU31DComponent::update() { void HTU31DComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTU31D:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Humidity", this->humidity_); diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp index 8ca841a7e43..d0f9973b2a4 100644 --- a/esphome/components/husb238/husb238.cpp +++ b/esphome/components/husb238/husb238.cpp @@ -39,9 +39,6 @@ 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_); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 36a7b1b3511..a2f8fc50862 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -15,9 +15,6 @@ static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_ void HydreonRGxxComponent::dump_config() { ESP_LOGCONFIG(TAG, "hydreon_rgxx:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!"); - } if (model_ == RG9) { ESP_LOGCONFIG(TAG, " Model: RG9\n" @@ -154,7 +151,7 @@ void HydreonRGxxComponent::schedule_reboot_() { sensor->publish_state(NAN); } } - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor did not boot")); } }); } diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 48935c24681..73b6252cece 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -27,8 +27,7 @@ struct SensorData { void IAQCore::setup() { if (this->write(nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -89,9 +88,6 @@ void IAQCore::dump_config() { ESP_LOGCONFIG(TAG, "AMS iAQ Core:"); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "CO2", this->co2_); LOG_SENSOR(" ", "TVOC", this->tvoc_); } diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp index 7331a3fe4f6..3bca35dc254 100644 --- a/esphome/components/icm20689/icm20689.cpp +++ b/esphome/components/icm20689/icm20689.cpp @@ -38,8 +38,7 @@ void ICM20689Component::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)); + this->mark_failed(LOG_STR("SPI device is not ready")); return; } @@ -61,7 +60,7 @@ void ICM20689Component::setup() { 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)); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -77,10 +76,8 @@ 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); + if (this->is_failed()) 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_)); diff --git a/esphome/components/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index 870d4e7098d..d2e1bc127e5 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -70,7 +70,7 @@ void ILI9XXXDisplay::alloc_buffer_() { this->init_internal_(this->get_buffer_length_()); } if (this->buffer_ == nullptr) { - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to allocate the display buffer")); } } @@ -122,9 +122,6 @@ void ILI9XXXDisplay::dump_config() { this->color_order_ == display::COLOR_ORDER_BGR ? LOG_STR_LITERAL("BGR") : LOG_STR_LITERAL("RGB"), YESNO(this->swap_xy_), YESNO(this->mirror_x_), YESNO(this->mirror_y_), YESNO(this->pre_invertcolors_)); - if (this->is_failed()) { - ESP_LOGCONFIG(TAG, " => Failed to init Memory: YES!"); - } LOG_UPDATE_INTERVAL(this); } From e1a6c615b1493a0832bff233e3f69375d240d26f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:40:06 -1000 Subject: [PATCH 48/50] [core] Document passing the failure reason to mark_failed in AGENTS.md (#20879) --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e00888b5f77..5581a255dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,15 @@ file does, and it is the authority when they disagree. The most useful starting 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()`. + * **`dump_config()` already prints the failure reason:** for a failed component, core prints + ` is marked FAILED: ` right after the component's `dump_config()`, using the reason + passed to `mark_failed()`. So `dump_config()` never checks `is_failed()` to log a failure, and no + `error_code_` member exists only to pick that message. Pass the reason where setup fails instead: + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))` for a failed bus transfer, a short specific reason + otherwise (`LOG_STR("Wrong chip ID")`). Core also logs it at that moment, so no `ESP_LOGE` repeats it + before `mark_failed()`; keep a separate line only when it prints a value read at run time, such as the + chip ID that did not match. Only the first reason is kept, so do not call `status_set_error()` before + `mark_failed()` and do not mark the component failed again after a helper already did. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a runtime check of something the build already knows. * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they From 49caeae7807aef32292ab1e509200293d8c295be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:40:54 -1000 Subject: [PATCH 49/50] [components] Let core report communication failures in dump_config (3/5) (#20872) --- esphome/components/ina219/ina219.cpp | 10 +++--- esphome/components/ina226/ina226.cpp | 10 +++--- esphome/components/ina260/ina260.cpp | 24 ++------------ esphome/components/ina260/ina260.h | 7 ----- .../components/ina2xx_base/ina2xx_base.cpp | 9 ++---- esphome/components/ina3221/ina3221.cpp | 11 +++---- .../components/kamstrup_kmp/kamstrup_kmp.cpp | 3 -- esphome/components/lcd_menu/lcd_menu.cpp | 5 +-- .../lcd_pcf8574/pcf8574_display.cpp | 5 +-- esphome/components/lis2dw12/lis2dw12.cpp | 11 ++++--- esphome/components/lis3dh/lis3dh.cpp | 12 +++---- esphome/components/lm75b/lm75b.cpp | 3 -- esphome/components/lp5562/lp5562_output.cpp | 7 ++--- esphome/components/ltr501/ltr501.cpp | 6 +--- esphome/components/ltr_als_ps/ltr_als_ps.cpp | 6 +--- esphome/components/max17043/max17043.cpp | 10 ++---- esphome/components/max17048/max17048.cpp | 3 -- esphome/components/max44009/max44009.cpp | 5 +-- esphome/components/mcp4461/mcp4461.cpp | 9 ++---- esphome/components/mcp4725/mcp4725.cpp | 11 ++----- esphome/components/mcp4725/mcp4725.h | 1 - esphome/components/mcp4728/mcp4728.cpp | 5 +-- esphome/components/mcp9600/mcp9600.cpp | 18 ++--------- esphome/components/mcp9600/mcp9600.h | 6 ---- esphome/components/mcp9808/mcp9808.cpp | 19 +++++++----- .../components/mlx90393/sensor_mlx90393.cpp | 14 +++------ esphome/components/mmc5603/mmc5603.cpp | 26 ++++------------ esphome/components/mmc5603/mmc5603.h | 5 --- esphome/components/mpl3115a2/mpl3115a2.cpp | 19 ++---------- esphome/components/mpl3115a2/mpl3115a2.h | 5 --- esphome/components/mpr121/mpr121.cpp | 11 +------ esphome/components/mpr121/mpr121.h | 4 --- esphome/components/mpu6050/mpu6050.cpp | 31 +++++++------------ esphome/components/mpu6050/mpu6050.h | 2 ++ esphome/components/mpu6886/mpu6886.cpp | 31 +++++++------------ esphome/components/mpu6886/mpu6886.h | 2 ++ esphome/components/ms5611/ms5611.cpp | 7 ++--- esphome/components/msa3xx/msa3xx.cpp | 16 +++++----- 38 files changed, 107 insertions(+), 282 deletions(-) diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index a675099b4dc..48c9a06f504 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -36,7 +36,7 @@ void INA219Component::setup() { // Config Register // 0bx000000000000000 << 15 RESET Bit (1 -> trigger reset) if (!this->write_byte_16(INA219_REGISTER_CONFIG, 0x8000)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -101,7 +101,7 @@ void INA219Component::setup() { config |= shunt_gain << 11; ESP_LOGCONFIG(TAG, " Using %dV-Range Shunt Gain=%dmV", bus_32v_range ? 32 : 16, 40 << shunt_gain); if (!this->write_byte_16(INA219_REGISTER_CONFIG, config)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -122,7 +122,7 @@ void INA219Component::setup() { auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -138,10 +138,8 @@ void INA219Component::dump_config() { ESP_LOGCONFIG(TAG, "INA219:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Bus Voltage", this->bus_voltage_sensor_); diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 97b3caf949b..987ce25d60b 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -40,7 +40,7 @@ void INA226Component::setup() { config.reset = 1; if (!this->write_byte_16(INA226_REGISTER_CONFIG, config.raw)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); @@ -61,7 +61,7 @@ void INA226Component::setup() { config.mode = 0b111; if (!this->write_byte_16(INA226_REGISTER_CONFIG, config.raw)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -75,7 +75,7 @@ void INA226Component::setup() { ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA226_REGISTER_CALIBRATION, calibration)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -84,10 +84,8 @@ void INA226Component::dump_config() { ESP_LOGCONFIG(TAG, "INA226:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/ina260/ina260.cpp b/esphome/components/ina260/ina260.cpp index 82a4e4fba14..0600e088390 100644 --- a/esphome/components/ina260/ina260.cpp +++ b/esphome/components/ina260/ina260.cpp @@ -36,8 +36,7 @@ static const uint8_t INA260_REGISTER_DEVICE_ID = 0xFF; void INA260Component::setup() { // Reset device on setup if (!this->write_byte_16(INA260_REGISTER_CONFIG, 0x8000)) { - this->error_code_ = DEVICE_RESET_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Device reset failed - Is the device connected?")); return; } @@ -47,14 +46,12 @@ void INA260Component::setup() { this->read_byte_16(INA260_REGISTER_DEVICE_ID, &this->device_id_); if (this->manufacture_id_ != (uint16_t) 0x5449 || this->device_id_ != (uint16_t) 0x2270) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Connected device does not match a known INA260 sensor")); return; } if (!this->write_byte_16(INA260_REGISTER_CONFIG, (uint16_t) 0b0000001100000111)) { - this->error_code_ = FAILED_TO_UPDATE_CONFIGURATION; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to update device configuration")); return; } } @@ -70,21 +67,6 @@ void INA260Component::dump_config() { LOG_SENSOR(" ", "Bus Voltage", this->bus_voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); LOG_SENSOR(" ", "Power", this->power_sensor_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Connected device does not match a known INA260 sensor"); - break; - case DEVICE_RESET_FAILED: - ESP_LOGE(TAG, "Device reset failed - Is the device connected?"); - break; - case FAILED_TO_UPDATE_CONFIGURATION: - ESP_LOGE(TAG, "Failed to update device configuration"); - break; - case NONE: - default: - break; - } } void INA260Component::update() { diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index bbcb7a7acb2..162a4447e0f 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -23,13 +23,6 @@ class INA260Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *bus_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; - - enum ErrorCode { - NONE, - COMMUNICATION_FAILED, - DEVICE_RESET_FAILED, - FAILED_TO_UPDATE_CONFIGURATION, - } error_code_{NONE}; }; } // namespace esphome::ina260 diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 58e0598b06c..4c36870b680 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -50,15 +50,13 @@ static bool check_model_and_device_match(INAModel model, uint16_t dev_id) { void INA2XX::setup() { if (!this->reset_config_()) { - ESP_LOGE(TAG, "Reset failed, check connection"); - this->mark_failed(); + this->mark_failed(LOG_STR("Reset failed, check connection")); return; } delay(2); if (!this->check_device_model_()) { - ESP_LOGE(TAG, "Device not supported or model selected improperly in yaml file"); - this->mark_failed(); + this->mark_failed(LOG_STR("Device not supported or model selected improperly in yaml file")); return; } delay(1); @@ -197,9 +195,6 @@ void INA2XX::dump_config() { ESP_LOGE(TAG, " Device model mismatch. Found device with ID = %x. Please check your configuration.", this->dev_id_); } - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Shunt resistance = %f Ohm\n" diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index e66e2456767..455b4c0c7d2 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -40,13 +40,13 @@ static constexpr uint32_t READ_TIMEOUT_ID = 0; void INA3221Component::setup() { if (!this->write_byte_16(INA3221_REGISTER_CONFIG, INA3221_CONFIG_RESET)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); if (!this->write_byte_16(INA3221_REGISTER_CONFIG, this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -57,7 +57,7 @@ void INA3221Component::setup() { !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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -67,7 +67,7 @@ void INA3221Component::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #endif @@ -76,9 +76,6 @@ void INA3221Component::setup() { void INA3221Component::dump_config() { ESP_LOGCONFIG(TAG, "INA3221:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - 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) { diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 9713c72b613..fcf648c31f3 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -9,9 +9,6 @@ ESPHOME_LOG_TAG(TAG, "kamstrup_kmp"); void KamstrupKMPComponent::dump_config() { ESP_LOGCONFIG(TAG, "kamstrup_kmp:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Heat Energy", this->heat_energy_sensor_); diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp index 7ced6e67cec..b500461809b 100644 --- a/esphome/components/lcd_menu/lcd_menu.cpp +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "lcd_menu"); void LCDCharacterMenuComponent::setup() { if (this->display_->is_failed()) { - this->mark_failed(); + this->mark_failed(LOG_STR("The connected display failed, the menu is disabled")); return; } @@ -24,9 +24,6 @@ void LCDCharacterMenuComponent::dump_config() { " Mark characters: %02x, %02x, %02x, %02x", this->columns_, this->rows_, this->mark_selected_, this->mark_editing_, this->mark_submenu_, this->mark_back_); - if (this->is_failed()) { - ESP_LOGE(TAG, "The connected display failed, the menu is disabled!"); - } } void LCDCharacterMenuComponent::draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.cpp b/esphome/components/lcd_pcf8574/pcf8574_display.cpp index fc7f5ff2f50..2e2196be4ad 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.cpp +++ b/esphome/components/lcd_pcf8574/pcf8574_display.cpp @@ -12,7 +12,7 @@ static const uint8_t LCD_DISPLAY_BACKLIGHT_OFF = 0x00; void PCF8574LCDDisplay::setup() { this->backlight_value_ = LCD_DISPLAY_BACKLIGHT_ON; if (!this->write_bytes(this->backlight_value_, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -25,9 +25,6 @@ void PCF8574LCDDisplay::dump_config() { this->columns_, this->rows_); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void PCF8574LCDDisplay::write_n_bits(uint8_t value, uint8_t n) { if (n == 4) { diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 38065aa7859..21e1a07676f 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -83,9 +83,13 @@ static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { void LIS2DW12Component::setup() { uint8_t who_am_i{0}; - if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i) || who_am_i != LIS2DW12_WHO_AM_I_VALUE) { + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (who_am_i != LIS2DW12_WHO_AM_I_VALUE) { ESP_LOGE(TAG, "WHO_AM_I check failed. Got 0x%02X, expected 0x%02X", who_am_i, LIS2DW12_WHO_AM_I_VALUE); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -171,9 +175,6 @@ bool LIS2DW12Component::configure_registers_() { void LIS2DW12Component::dump_config() { ESP_LOGCONFIG(TAG, "LIS2DW12:"); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Power Mode: %s\n" " Data Rate: %s\n" diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index 22ca7ae56b2..f12c00852cd 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -29,19 +29,17 @@ void LIS3DHComponent::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(); + this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); 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(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { - ESP_LOGE(TAG, "Failed to configure sensor"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure sensor")); return; } } @@ -119,10 +117,8 @@ bool LIS3DHComponent::setup_interrupt_() { void LIS3DHComponent::dump_config() { ESP_LOGCONFIG(TAG, "LIS3DH:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Range: ±%ug\n" diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 56e69faa62f..388f5dfd93a 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -9,9 +9,6 @@ ESPHOME_LOG_TAG(TAG, "lm75b"); void LM75BComponent::dump_config() { ESP_LOGCONFIG(TAG, "LM75B:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up LM75B failed!"); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index 356ef2c0669..b55e8b297d2 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -33,7 +33,7 @@ void LP5562Output::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(1); @@ -41,16 +41,13 @@ void LP5562Output::setup() { // 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } 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) { diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 91fca7fa9ba..a3f082051cc 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -115,10 +115,6 @@ void LTRAlsPs501Component::dump_config() { LOG_SENSOR(" ", "CH1 Infrared counts", this->infrared_counts_sensor_); LOG_SENSOR(" ", "CH0 Visible+IR counts", this->full_spectrum_counts_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void LTRAlsPs501Component::update() { @@ -151,7 +147,7 @@ void LTRAlsPs501Component::loop() { err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { ESP_LOGW(TAG, "i2c connection failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->configure_reset_(); if (this->is_als_()) { diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index 2d3b3755ad5..743bd68529d 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -109,10 +109,6 @@ void LTRAlsPsComponent::dump_config() { LOG_SENSOR(" ", "Proximity counts", this->proximity_counts_sensor_); } LOG_UPDATE_INTERVAL(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void LTRAlsPsComponent::update() { @@ -142,7 +138,7 @@ void LTRAlsPsComponent::loop() { err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { ESP_LOGV(TAG, "i2c connection failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } this->configure_reset_(); if (this->is_als_()) { diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index a15658512b2..c64875e67c9 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -55,16 +55,13 @@ void MAX17043Component::setup() { ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg); if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) { - ESP_LOGE(TAG, "Device does not appear to be a MAX17043"); - this->status_set_error(LOG_STR("unrecognised")); - this->mark_failed(); + this->mark_failed(LOG_STR("Device does not appear to be a MAX17043")); return; } // need to write back to config register to reset the sleep bit if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) { - this->status_set_error(LOG_STR("sleep reset failed")); - this->mark_failed(); + this->mark_failed(LOG_STR("sleep reset failed")); return; } } @@ -72,9 +69,6 @@ void MAX17043Component::setup() { void MAX17043Component::dump_config() { ESP_LOGCONFIG(TAG, "MAX17043:"); 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->voltage_sensor_); LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_); diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp index 67f9dbaac3d..1caf8ab308b 100644 --- a/esphome/components/max17048/max17048.cpp +++ b/esphome/components/max17048/max17048.cpp @@ -65,9 +65,6 @@ void MAX17048Component::update() { 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_); diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index b616b9032ee..10df08bbeb4 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -39,15 +39,12 @@ void MAX44009Sensor::setup() { } } if (!state_ok) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } void MAX44009Sensor::dump_config() { ESP_LOGCONFIG(TAG, "MAX44009:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void MAX44009Sensor::update() { diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index b6e8c247021..800ec631e75 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -12,7 +12,7 @@ void Mcp4461Component::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return; } // save WP/WL status @@ -96,9 +96,6 @@ void Mcp4461Component::update_write_protection_status_() { void Mcp4461Component::dump_config() { ESP_LOGCONFIG(TAG, "mcp4461:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_))); - } // log wiper status for (uint8_t i = 0; i < 8; ++i) { // terminals only valid for volatile wipers 0-3 - enable/disable is terminal hw @@ -218,7 +215,7 @@ uint8_t Mcp4461Component::get_status_register_() { uint16_t buf; if (!this->read_16_(reg, &buf)) { this->error_code_ = MCP4461_STATUS_REGISTER_ERROR; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return 0; } uint8_t msb = buf >> 8; @@ -227,7 +224,7 @@ uint8_t Mcp4461Component::get_status_register_() { // D8, D7 and R1 bits are hardlocked to 1 -> a status msb bit 0 (bit 9 of status register) of 0 or lsb bit 1/7 = 0 // indicate device/communication issues, therefore mark component failed this->error_code_ = MCP4461_STATUS_REGISTER_INVALID; - this->mark_failed(); + this->mark_failed(Mcp4461Component::get_message_string(this->error_code_)); return 0; } this->status_clear_warning(); diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index faa377b8de9..f25765f4a3a 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -8,19 +8,12 @@ ESPHOME_LOG_TAG(TAG, "mcp4725"); void MCP4725::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } -void MCP4725::dump_config() { - LOG_I2C_DEVICE(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } -} +void MCP4725::dump_config() { LOG_I2C_DEVICE(this); } // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index a0838dc33e6..9c4b95db3f0 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -16,7 +16,6 @@ class MCP4725 final : public Component, public output::FloatOutput, public i2c:: void write_state(float state) override; protected: - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::mcp4725 diff --git a/esphome/components/mcp4728/mcp4728.cpp b/esphome/components/mcp4728/mcp4728.cpp index 7259a0a89ec..48336af339d 100644 --- a/esphome/components/mcp4728/mcp4728.cpp +++ b/esphome/components/mcp4728/mcp4728.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "mcp4728"); void MCP4728Component::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -18,9 +18,6 @@ void MCP4728Component::setup() { void MCP4728Component::dump_config() { ESP_LOGCONFIG(TAG, "MCP4728:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void MCP4728Component::loop() { diff --git a/esphome/components/mcp9600/mcp9600.cpp b/esphome/components/mcp9600/mcp9600.cpp index 70e7cb176d4..d99cf63b471 100644 --- a/esphome/components/mcp9600/mcp9600.cpp +++ b/esphome/components/mcp9600/mcp9600.cpp @@ -33,8 +33,7 @@ void MCP9600Component::setup() { // Allows both MCP9600's and MCP9601's to be connected. if (this->device_id_ != (uint8_t) 0x40 && this->device_id_ != (uint8_t) 0x41) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Connected device does not match a known MCP9600 or MCP9601 sensor")); return; } @@ -55,8 +54,7 @@ void MCP9600Component::setup() { success &= this->write_byte_16(MCP9600_REGISTER_ALERT4_LIMIT, 0x0000); if (!success) { - this->error_code_ = FAILED_TO_UPDATE_CONFIGURATION; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to update device configuration")); return; } } @@ -70,18 +68,6 @@ void MCP9600Component::dump_config() { LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Hot Junction Temperature", this->hot_junction_sensor_); LOG_SENSOR(" ", "Cold Junction Temperature", this->cold_junction_sensor_); - - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, "Connected device does not match a known MCP9600 or MCP901 sensor"); - break; - case FAILED_TO_UPDATE_CONFIGURATION: - ESP_LOGE(TAG, "Failed to update device configuration"); - break; - case NONE: - default: - break; - } } void MCP9600Component::update() { diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index 523c9ace2f7..88dfd046ceb 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -36,12 +36,6 @@ class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *cold_junction_sensor_{nullptr}; MCP9600ThermocoupleType thermocouple_type_{MCP9600_THERMOCOUPLE_TYPE_K}; - - enum ErrorCode { - NONE, - COMMUNICATION_FAILED, - FAILED_TO_UPDATE_CONFIGURATION, - } error_code_{NONE}; }; } // namespace esphome::mcp9600 diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 47df37f4563..309651970ca 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -18,24 +18,29 @@ ESPHOME_LOG_TAG(TAG, "mcp9808"); void MCP9808Sensor::setup() { uint16_t manu = 0; - if (!this->read_byte_16(MCP9808_REG_MANUF_ID, &manu) || manu != MCP9808_MANUF_ID) { - this->mark_failed(); + if (!this->read_byte_16(MCP9808_REG_MANUF_ID, &manu)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manu != MCP9808_MANUF_ID) { ESP_LOGE(TAG, "Incorrect manufacturer ID (%X) for '%s'", manu, this->name_.c_str()); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } uint16_t dev_id = 0; - if (!this->read_byte_16(MCP9808_REG_DEVICE_ID, &dev_id) || dev_id != MCP9808_DEV_ID) { - this->mark_failed(); + if (!this->read_byte_16(MCP9808_REG_DEVICE_ID, &dev_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (dev_id != MCP9808_DEV_ID) { ESP_LOGE(TAG, "Incorrect device ID (%X) for '%s'", dev_id, this->name_.c_str()); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } } void MCP9808Sensor::dump_config() { ESP_LOGCONFIG(TAG, "%s:", this->name_.c_str()); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->name_.c_str()); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index e47b7d8c30b..7aa01a976d9 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -112,7 +112,7 @@ void MLX90393Cls::setup() { this->mlx_.begin_with_hal(this, 0, 0); if (!this->apply_all_settings_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } // start verify settings process @@ -123,10 +123,8 @@ void MLX90393Cls::dump_config() { ESP_LOGCONFIG(TAG, "MLX90393:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); @@ -250,16 +248,12 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { if (!is_setting_ok) { if (this->mlx_.checkStatus(this->mlx_.reset()) != MLX90393::STATUS_OK) { - ESP_LOGE(TAG, "failed to reset device"); - this->status_set_error(); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset device")); return; } if (!this->apply_all_settings_()) { - ESP_LOGE(TAG, "failed to re-apply settings"); - this->status_set_error(); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to re-apply settings")); } else { ESP_LOGI(TAG, "reset and re-apply settings completed"); } diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index b7d16b30edd..fa5ba5985d7 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -34,35 +34,27 @@ static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { uint8_t id = 0; if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - ESP_LOGCONFIG(TAG, "Chip Wrong"); - this->error_code_ = ID_REGISTERS; - this->mark_failed(); + this->mark_failed(LOG_STR("The ID registers don't match - Is this really an MMC5603?")); return; } if (!this->write_byte(MMC56X3_CTRL1_REG, 0x80)) { // turn on set bit - ESP_LOGCONFIG(TAG, "Control 1 Failed for set bit"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Control 1 failed for set bit")); return; } if (!this->write_byte(MMC56X3_CTRL0_REG, 0x08)) { // turn on set bit - ESP_LOGCONFIG(TAG, "Control 0 Failed for set bit"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Control 0 failed for set bit")); return; } if (!this->write_byte(MMC56X3_CTRL0_REG, 0x10)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -70,19 +62,13 @@ void MMC5603Component::setup() { ctrl_2 &= ~0x10; // turn off cmm_en bit if (!this->write_byte(MMC56X3_CTRL2_REG, ctrl_2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void MMC5603Component::dump_config() { ESP_LOGCONFIG(TAG, "MMC5603:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } else if (this->error_code_ == ID_REGISTERS) { - ESP_LOGE(TAG, "The ID registers don't match - Is this really an MMC5603?"); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto set/reset: %s", ONOFF(this->auto_set_reset_)); diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index d291e6d2728..9187e019fb3 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -32,11 +32,6 @@ class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *z_sensor_{nullptr}; sensor::Sensor *heading_sensor_{nullptr}; bool auto_set_reset_{true}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - ID_REGISTERS, - } error_code_{NONE}; }; } // namespace esphome::mmc5603 diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index db892716258..4a206b30dfe 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -10,13 +10,11 @@ ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (whoami != 0xC4) { - this->error_code_ = WRONG_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); return; } @@ -32,19 +30,6 @@ void MPL3115A2Component::setup() { void MPL3115A2Component::dump_config() { ESP_LOGCONFIG(TAG, "MPL3115A2:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case WRONG_ID: - ESP_LOGE(TAG, "MPL3115A2 has invalid id"); - break; - default: - ESP_LOGE(TAG, "Setting up MPL3115A2 registers failed!"); - break; - } - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_); LOG_SENSOR(" ", "Pressure", this->pressure_); diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index a6163673cbf..a8af1ff0fea 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -94,11 +94,6 @@ class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice sensor::Sensor *temperature_{nullptr}; sensor::Sensor *altitude_{nullptr}; sensor::Sensor *pressure_{nullptr}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - WRONG_ID, - } error_code_{NONE}; }; } // namespace esphome::mpl3115a2 diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 26cdcae6138..3d9cded59ff 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -15,8 +15,7 @@ void MPR121Component::setup() { this->write_byte(MPR121_SOFTRESET, 0x63); this->set_timeout(100, [this]() { if (!this->write_byte(MPR121_ECR, 0x0)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // set touch sensitivity for all 12 channels @@ -70,14 +69,6 @@ void MPR121Component::set_release_debounce(uint8_t debounce) { void MPR121Component::dump_config() { ESP_LOGCONFIG(TAG, "MPR121:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case NONE: - default: - break; - } } void MPR121Component::loop() { uint16_t val = 0; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 64c4b291b3d..4d945ded872 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -84,10 +84,6 @@ class MPR121Component final : public Component, public i2c::I2CDevice { uint8_t touch_threshold_{}; uint8_t release_threshold_{}; uint8_t max_touch_channel_{3}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; bool flush_gpio_(); diff --git a/esphome/components/mpu6050/mpu6050.cpp b/esphome/components/mpu6050/mpu6050.cpp index cf393fd4d10..364843ebdce 100644 --- a/esphome/components/mpu6050/mpu6050.cpp +++ b/esphome/components/mpu6050/mpu6050.cpp @@ -20,19 +20,22 @@ const uint8_t MPU6050_BIT_TEMPERATURE_DISABLED = 3; const float GRAVITY_EARTH = 9.80665f; void MPU6050Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool MPU6050Component::setup_registers_() { uint8_t who_am_i; if (!this->read_byte(MPU6050_REGISTER_WHO_AM_I, &who_am_i) || (who_am_i != 0x68 && who_am_i != 0x70 && who_am_i != 0x98)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Power Management"); // Setup power management uint8_t power_management; if (!this->read_byte(MPU6050_REGISTER_POWER_MANAGEMENT_1, &power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); // Set clock source - X-Gyro @@ -44,48 +47,38 @@ void MPU6050Component::setup() { power_management &= ~(1 << MPU6050_BIT_TEMPERATURE_DISABLED); ESP_LOGV(TAG, " Output power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); if (!this->write_byte(MPU6050_REGISTER_POWER_MANAGEMENT_1, power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyro Config"); // Set scale - 2000DPS uint8_t gyro_config; if (!this->read_byte(MPU6050_REGISTER_GYRO_CONFIG, &gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); gyro_config &= 0b11100111; gyro_config |= MPU6050_SCALE_2000_DPS << 3; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(MPU6050_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accel Config"); // Set range - 2G uint8_t accel_config; if (!this->read_byte(MPU6050_REGISTER_ACCEL_CONFIG, &accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); accel_config &= 0b11100111; accel_config |= (MPU6050_RANGE_2G << 3); ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); - if (!this->write_byte(MPU6050_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; - } + return this->write_byte(MPU6050_REGISTER_ACCEL_CONFIG, accel_config); } void MPU6050Component::dump_config() { ESP_LOGCONFIG(TAG, "MPU6050:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index 4410bf01645..2a346c1f4d9 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -22,6 +22,8 @@ class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { void set_gyro_z_sensor(sensor::Sensor *gyro_z_sensor) { gyro_z_sensor_ = gyro_z_sensor; } protected: + /// Configure the sensor; false on an I2C failure. + bool setup_registers_(); sensor::Sensor *accel_x_sensor_{nullptr}; sensor::Sensor *accel_y_sensor_{nullptr}; sensor::Sensor *accel_z_sensor_{nullptr}; diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index 514ff0dd0e7..15fbd3c9c16 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -25,18 +25,21 @@ const float TEMPERATURE_SENSITIVITY = 326.8; const float TEMPERATURE_OFFSET = 25.0; void MPU6886Component::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool MPU6886Component::setup_registers_() { uint8_t who_am_i; if (!this->read_byte(MPU6886_REGISTER_WHO_AM_I, &who_am_i) || who_am_i != MPU6886_WHO_AM_I_IDENTIFIER) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Power Management"); // Setup power management uint8_t power_management; if (!this->read_byte(MPU6886_REGISTER_POWER_MANAGEMENT_1, &power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); // Set clock source - X-Gyro @@ -48,49 +51,39 @@ void MPU6886Component::setup() { power_management &= ~(1 << MPU6886_BIT_TEMPERATURE_DISABLED); ESP_LOGV(TAG, " Output power_management: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(power_management)); if (!this->write_byte(MPU6886_REGISTER_POWER_MANAGEMENT_1, power_management)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Gyroscope Config"); // Set scale - 2000DPS uint8_t gyro_config; if (!this->read_byte(MPU6886_REGISTER_GYRO_CONFIG, &gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input gyroscope_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); gyro_config &= 0b11100111; gyro_config |= MPU6886_SCALE_2000_DPS << 3; ESP_LOGV(TAG, " Output gyro_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(gyro_config)); if (!this->write_byte(MPU6886_REGISTER_GYRO_CONFIG, gyro_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Setting up Accelerometer Config"); // Set range - 2G uint8_t accel_config; if (!this->read_byte(MPU6886_REGISTER_ACCEL_CONFIG, &accel_config)) { - this->mark_failed(); - return; + return false; } ESP_LOGV(TAG, " Input accelerometer_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); accel_config &= 0b11100111; accel_config |= (MPU6886_RANGE_2G << 3); ESP_LOGV(TAG, " Output accel_config: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(accel_config)); - if (!this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config)) { - this->mark_failed(); - return; - } + return this->write_byte(MPU6886_REGISTER_ACCEL_CONFIG, accel_config); } void MPU6886Component::dump_config() { ESP_LOGCONFIG(TAG, "MPU6886:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Acceleration X", this->accel_x_sensor_); LOG_SENSOR(" ", "Acceleration Y", this->accel_y_sensor_); diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index b795d5f690f..a7c81e17593 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -22,6 +22,8 @@ class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { void set_gyro_z_sensor(sensor::Sensor *gyro_z_sensor) { gyro_z_sensor_ = gyro_z_sensor; } protected: + /// Configure the sensor; false on an I2C failure. + bool setup_registers_(); sensor::Sensor *accel_x_sensor_{nullptr}; sensor::Sensor *accel_y_sensor_{nullptr}; sensor::Sensor *accel_z_sensor_{nullptr}; diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 8f66d704fe6..e3e5194c415 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -18,13 +18,13 @@ static const uint8_t MS5611_CMD_READ_PROM = 0xA2; void MS5611Component::setup() { if (!this->write_bytes(MS5611_CMD_RESET, nullptr, 0)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->set_timeout(100, [this]() { for (uint8_t offset = 0; offset < 6; offset++) { if (!this->read_byte_16(MS5611_CMD_READ_PROM + (offset * 2), &this->prom_[offset])) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -33,9 +33,6 @@ void MS5611Component::setup() { void MS5611Component::dump_config() { ESP_LOGCONFIG(TAG, "MS5611:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 78f2b75c9ce..19a4b067bc7 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -118,9 +118,13 @@ const char *orientation_z_to_string(bool orientation) { return orientation ? "Do void MSA3xxComponent::setup() { uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id) || (part_id != MSA_3XX_PART_ID)) { - ESP_LOGE(TAG, "Part ID is wrong or missing. Got 0x%02X", part_id); - this->mark_failed(); + if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (part_id != MSA_3XX_PART_ID) { + ESP_LOGE(TAG, "Part ID is wrong. Got 0x%02X", part_id); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } @@ -137,8 +141,7 @@ void MSA3xxComponent::setup() { this->device_params_.accel_data_width = 12; this->device_params_.scale_factor_exp = static_cast(this->range_) - 10; } else { - ESP_LOGE(TAG, "Unknown model"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown model")); return; } @@ -155,9 +158,6 @@ void MSA3xxComponent::setup() { void MSA3xxComponent::dump_config() { ESP_LOGCONFIG(TAG, "MSA3xx:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Model: %s\n" " Power Mode: %s\n" From 1ff6b29b0d9fbc55e3d094ff86db079a8de4661a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 02:41:52 -1000 Subject: [PATCH 50/50] [components] Let core report communication failures in dump_config (4/5) (#20873) --- esphome/components/nau7802/nau7802.cpp | 10 ++-- esphome/components/opt3001/opt3001.cpp | 3 -- esphome/components/pca6416a/pca6416a.cpp | 5 +- esphome/components/pca9505/pca9505.cpp | 5 +- esphome/components/pca9554/pca9554.cpp | 5 +- esphome/components/pca9685/pca9685_output.cpp | 33 ++++++------ esphome/components/pca9685/pca9685_output.h | 2 + esphome/components/pcf85063/pcf85063.cpp | 5 +- esphome/components/pcf8563/pcf8563.cpp | 5 +- esphome/components/pcf8574/pcf8574.cpp | 5 +- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 7 +-- esphome/components/pm2005/pm2005.cpp | 6 +-- esphome/components/pmwcs3/pmwcs3.cpp | 5 +- esphome/components/pn532/pn532.cpp | 28 +++-------- esphome/components/pn532/pn532.h | 6 --- esphome/components/pylontech/pylontech.cpp | 3 -- esphome/components/qmc5883l/qmc5883l.cpp | 15 ++---- esphome/components/qmc5883l/qmc5883l.h | 4 -- esphome/components/qmi8658/qmi8658.cpp | 12 ++--- esphome/components/qwiic_pir/qwiic_pir.cpp | 30 ++--------- esphome/components/qwiic_pir/qwiic_pir.h | 6 --- esphome/components/rc522/rc522.cpp | 11 +--- esphome/components/rc522/rc522.h | 5 -- esphome/components/rx8025t/rx8025t.cpp | 5 +- esphome/components/scd30/scd30.cpp | 40 +++------------ esphome/components/scd30/scd30.h | 7 --- esphome/components/scd4x/scd4x.cpp | 41 +++------------ esphome/components/scd4x/scd4x.h | 9 ---- esphome/components/sdp3x/sdp3x.cpp | 15 ++---- esphome/components/sen0321/sen0321.cpp | 5 +- esphome/components/sen21231/sen21231.cpp | 4 -- esphome/components/sen5x/sen5x.cpp | 50 +++---------------- esphome/components/sen5x/sen5x.h | 10 ---- esphome/components/sfa30/sfa30.cpp | 21 +------- esphome/components/sfa30/sfa30.h | 4 -- esphome/components/sfa40/sfa40.cpp | 21 +------- esphome/components/sfa40/sfa40.h | 6 --- esphome/components/sgp30/sgp30.cpp | 34 +++---------- esphome/components/sgp30/sgp30.h | 8 --- esphome/components/sgp4x/sgp4x.cpp | 32 +++--------- esphome/components/sgp4x/sgp4x.h | 10 ---- esphome/components/sht4x/sht4x.cpp | 5 +- 42 files changed, 91 insertions(+), 452 deletions(-) diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index ad1a569e0fa..cc24353782e 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -56,8 +56,7 @@ void NAU7802Sensor::setup() { uint8_t rev; if (this->read_register(DEVICE_REV | READ_BIT, &rev, 1)) { - ESP_LOGE(TAG, "Failed I2C read during setup()"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed I2C read during setup()")); return; } ESP_LOGI(TAG, "Setting up NAU7802 Rev %d", rev); @@ -72,8 +71,7 @@ void NAU7802Sensor::setup() { delay(1); if (!(pu_ctrl.get() & PU_CTRL_POWERUP_READY)) { - ESP_LOGE(TAG, "Failed to reset sensor during setup()"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to reset sensor during setup()")); return; } @@ -124,10 +122,8 @@ void NAU7802Sensor::dump_config() { LOG_SENSOR("", "NAU7802", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL_FOR, this->get_name().c_str()); + if (this->is_failed()) return; - } // Note these may differ from the values on the device if calbration has been run ESP_LOGCONFIG(TAG, " Offset Calibration: %" PRId32 "\n" diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 0101368cebd..374fd707561 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -87,9 +87,6 @@ void OPT3001Sensor::read_lx_(const std::function &f) { void OPT3001Sensor::dump_config() { LOG_SENSOR("", "OPT3001", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index f805a8da545..0825d6eac98 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -27,7 +27,7 @@ void PCA6416AComponent::setup() { uint8_t value; if (!this->read_register_(PCA6416A_INPUT0, &value)) { ESP_LOGE(TAG, "PCA6416A not available under 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -77,9 +77,6 @@ void PCA6416AComponent::dump_config() { } LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCA6416AComponent::digital_read_hw(uint8_t pin) { diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 20f62c14721..6ac50d3b1bc 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -18,7 +18,7 @@ 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(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -65,9 +65,6 @@ 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); } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index f367d140678..a12d291f6b2 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -16,7 +16,7 @@ void PCA9554Component::setup() { // Test to see if device exists if (!this->read_inputs_()) { ESP_LOGE(TAG, "PCA95xx not detected at 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -64,9 +64,6 @@ void PCA9554Component::dump_config() { this->pin_count_); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCA9554Component::digital_read_hw(uint8_t pin) { diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index fe417ed65de..cd4688a02a4 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -21,38 +21,37 @@ static const uint8_t PCA9685_MODE1_AUTOINC = 0b00100000; static const uint8_t PCA9685_MODE1_SLEEP = 0b00010000; void PCA9685Output::setup() { + if (!this->setup_registers_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +bool PCA9685Output::setup_registers_() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_bytes(PCA9685_REGISTER_SOFTWARE_RESET, nullptr, 0)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(PCA9685_REGISTER_MODE1, PCA9685_MODE1_RESTART | PCA9685_MODE1_AUTOINC)) { - this->mark_failed(); - return; + return false; } if (!this->write_byte(PCA9685_REGISTER_MODE2, this->mode_)) { - this->mark_failed(); - return; + return false; } uint8_t mode1; if (!this->read_byte(PCA9685_REGISTER_MODE1, &mode1)) { - this->mark_failed(); - return; + return false; } mode1 = (mode1 & ~PCA9685_MODE1_RESTART) | PCA9685_MODE1_SLEEP; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } int pre_scaler = 3; if (this->extclk_) { mode1 = mode1 | PCA9685_MODE1_EXTCLK; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } } else { pre_scaler = static_cast((25000000 / (4096 * this->frequency_)) - 1); @@ -61,18 +60,17 @@ void PCA9685Output::setup() { ESP_LOGV(TAG, " -> Prescaler: %d", pre_scaler); } if (!this->write_byte(PCA9685_REGISTER_PRE_SCALE, pre_scaler)) { - this->mark_failed(); - return; + return false; } mode1 = (mode1 & ~PCA9685_MODE1_SLEEP) | PCA9685_MODE1_RESTART; if (!this->write_byte(PCA9685_REGISTER_MODE1, mode1)) { - this->mark_failed(); - return; + return false; } delayMicroseconds(500); this->loop(); + return true; } void PCA9685Output::dump_config() { @@ -88,9 +86,6 @@ void PCA9685Output::dump_config() { " Frequency: %.0f Hz", this->frequency_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up PCA9685 failed!"); - } } void PCA9685Output::loop() { diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index dad722888f1..38a66309bb0 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -54,6 +54,8 @@ class PCA9685Output final : public Component, public i2c::I2CDevice { void set_phase_balancer(PhaseBalancer balancer) { this->balancer_ = balancer; } protected: + /// Configure the chip; false on an I2C failure. + bool setup_registers_(); friend PCA9685Channel; void set_channel_value_(uint8_t channel, uint16_t value) { diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index bafe19ed2a4..f4123eddffa 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "pcf85063"); void PCF85063Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void PCF85063Component::update() { this->read_time(); } void PCF85063Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF85063:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 90ea4b3fd10..f20437366d6 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -10,7 +10,7 @@ ESPHOME_LOG_TAG(TAG, "PCF8563"); void PCF8563Component::setup() { if (!this->read_rtc_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } @@ -19,9 +19,6 @@ void PCF8563Component::update() { this->read_time(); } void PCF8563Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8563:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } RealTimeClock::dump_config(); } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index ea1e894a530..15ed2f8650e 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -8,7 +8,7 @@ ESPHOME_LOG_TAG(TAG, "pcf8574"); void PCF8574Component::setup() { if (!this->read_gpio_()) { ESP_LOGE(TAG, "PCF8574 not available under 0x%02X", this->address_); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -50,9 +50,6 @@ void PCF8574Component::dump_config() { YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool PCF8574Component::digital_read_hw(uint8_t pin) { // Read all pins from hardware into input_mask_ diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 9bc10872d28..0b5af94e696 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -22,12 +22,12 @@ void PI4IOE5V6408Component::setup() { this->reg(PI4IOE5V6408_REGISTER_OUT_HIGH_IMPEDENCE) = 0b00000000; } else { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGE(TAG, "Failed to read GPIO modes"); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGE(TAG, "Failed to read GPIO outputs"); return; } @@ -51,9 +51,6 @@ void PI4IOE5V6408Component::dump_config() { ESP_LOGCONFIG(TAG, "PI4IOE5V6408:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void PI4IOE5V6408Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags & gpio::FLAG_OUTPUT) { diff --git a/esphome/components/pm2005/pm2005.cpp b/esphome/components/pm2005/pm2005.cpp index 784a555e024..52dfea5fd5f 100644 --- a/esphome/components/pm2005/pm2005.cpp +++ b/esphome/components/pm2005/pm2005.cpp @@ -53,8 +53,7 @@ void PM2005Component::setup() { } if (this->read(this->data_buffer_, 12) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -107,9 +106,6 @@ void PM2005Component::dump_config() { this->sensor_type_ == PM2105); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 7493f5d97fb..60cc4da0e4c 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -55,9 +55,6 @@ void PMWCS3Component::update() { this->read_data_(); } void PMWCS3Component::dump_config() { ESP_LOGCONFIG(TAG, "PMWCS3"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "e25", this->e25_sensor_); LOG_SENSOR(" ", "ec", this->ec_sensor_); @@ -79,7 +76,7 @@ void PMWCS3Component::read_data_() { float e25, ec, temperature, vwc; if (!this->read_bytes(PMWCS3_REG_GET_DATA, (uint8_t *) &data, 8)) { ESP_LOGVV(TAG, "Reading PMWCS3_REG_GET_DATA failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->e25_sensor_ != nullptr) { diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 8fd92c9031f..fd38edf136a 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -21,8 +21,7 @@ void PN532::setup() { if (!this->write_command_({PN532_COMMAND_VERSION_DATA})) { ESP_LOGW(TAG, "Error sending version command, trying again"); if (!this->write_command_({PN532_COMMAND_VERSION_DATA})) { - ESP_LOGE(TAG, "Error sending version command"); - this->mark_failed(); + this->mark_failed(LOG_STR("Error sending version command")); return; } } @@ -30,8 +29,7 @@ void PN532::setup() { PN532Frame version_data; // GetFirmwareVersion returns IC, Ver, Rev and Support if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { - ESP_LOGE(TAG, "Error getting version"); - this->mark_failed(); + this->mark_failed(LOG_STR("Error getting version")); return; } ESP_LOGD(TAG, "Found chip PN5%02X, Firmware v%d.%d", version_data[0], version_data[1], version_data[2]); @@ -42,15 +40,13 @@ void PN532::setup() { 0x14, // timeout: 20 x 50 ms (only used in virtual card mode) 0x01, // use IRQ })) { - ESP_LOGE(TAG, "No wakeup ack"); - this->mark_failed(); + this->mark_failed(LOG_STR("No wakeup ack")); return; } PN532Frame wakeup_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, wakeup_result)) { - this->error_code_ = WAKEUP_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Wake Up command failed")); return; } @@ -62,8 +58,7 @@ void PN532::setup() { sam_timeout, // timeout as multiple of 50ms (actually only for virtual card mode, but shouldn't matter) 0x01, // Enable IRQ })) { - this->error_code_ = SAM_COMMAND_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("SAM command failed")); return; } @@ -73,8 +68,7 @@ void PN532::setup() { for (uint8_t dat : sam_result) { ESP_LOGV(TAG, " 0x%02X", dat); } - this->error_code_ = SAM_COMMAND_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("SAM command failed")); return; } @@ -482,16 +476,6 @@ bool PN532::mifare_read_(uint8_t address, MifareReadData &data) { void PN532::dump_config() { ESP_LOGCONFIG(TAG, "PN532:"); - switch (this->error_code_) { - case NONE: - break; - case WAKEUP_FAILED: - ESP_LOGE(TAG, "Wake Up command failed!"); - break; - case SAM_COMMAND_FAILED: - ESP_LOGE(TAG, "SAM command failed!"); - break; - } LOG_UPDATE_INTERVAL(this); diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 3a9099f3210..902da35c2fa 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -152,11 +152,6 @@ class PN532 : public PollingComponent { FORMAT, WRITE, }; - enum PN532Error : uint8_t { - NONE = 0, - WAKEUP_FAILED, - SAM_COMMAND_FAILED, - }; // members are ordered by alignment, widest first, to minimize padding LazyCallbackManager on_finished_write_callback_; @@ -174,7 +169,6 @@ class PN532 : public PollingComponent { uint32_t rd_start_time_{0}; // valid only while rd_started_ is set PN532ReadReady rd_ready_{WOULDBLOCK}; NfcTask next_task_{READ}; - PN532Error error_code_{NONE}; bool rd_started_{false}; bool updates_enabled_{true}; bool requested_read_{false}; diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 0898b924869..50d8c814fa4 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -34,9 +34,6 @@ PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with pylontech failed!"); - } for (PylontechListener *listener : this->listeners_) { listener->dump_config(); diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index dcfa46abc5b..2c859822e93 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -29,8 +29,7 @@ void IRAM_ATTR QMC5883LComponent::gpio_intr(QMC5883LComponent *arg) { arg->enabl void QMC5883LComponent::setup() { // Soft Reset if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, 1 << 7)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(10); @@ -51,8 +50,7 @@ void QMC5883LComponent::setup() { control_1 |= this->range_ << 4; control_1 |= this->oversampling_ << 6; if (!this->write_byte(QMC5883L_REGISTER_CONTROL_1, control_1)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -61,15 +59,13 @@ void QMC5883LComponent::setup() { control_2 |= 0b0 << 6; // ROL_PNT (Pointer Roll Over) -> 0b00=disabled, 0b01=enabled control_2 |= 0b0 << 0; // INT_ENB (Interrupt) -> 0b00=disabled, 0b01=enabled if (!this->write_byte(QMC5883L_REGISTER_CONTROL_2, control_2)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t period = 0x01; // recommended value if (!this->write_byte(QMC5883L_REGISTER_PERIOD, period)) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -81,9 +77,6 @@ void QMC5883LComponent::setup() { void QMC5883LComponent::dump_config() { ESP_LOGCONFIG(TAG, "QMC5883L:"); LOG_I2C_DEVICE(this); - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "X Axis", this->x_sensor_); diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index faef423f8cc..d941c5d2e1e 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -57,10 +57,6 @@ class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *temperature_sensor_{nullptr}; GPIOPin *drdy_pin_{nullptr}; bool drdy_use_isr_{false}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_; i2c::ErrorCode read_bytes_16_le_(uint8_t a_register, uint16_t *data, uint8_t len = 1); HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 5c9402c2018..da1f735fb9e 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -32,19 +32,18 @@ void QMI8658Component::setup() { // 1. Verify chip ID uint8_t who_am_i = 0; if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); return; } if (who_am_i != QMI8658_WHO_AM_I_VALUE) { ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); - this->mark_failed(); + this->mark_failed(LOG_STR("Wrong chip ID")); return; } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(15); // spec: wait for reset to complete @@ -70,7 +69,6 @@ void QMI8658Component::setup() { // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); - this->mark_failed(); return; } @@ -86,10 +84,8 @@ void QMI8658Component::setup() { void QMI8658Component::dump_config() { ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Communication failed!"); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Accel range : %s", LOG_STR_ARG(AccelRangeStrings::get_log_str(this->accel_range_ >> 4, AccelRangeStrings::LAST_INDEX))); diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index fffc3514d03..de9be3c8f0c 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -9,23 +9,17 @@ void QwiicPIRComponent::setup() { // Verify I2C communcation by reading and verifying the chip ID uint8_t chip_id; if (!this->read_byte(QWIIC_PIR_CHIP_ID, &chip_id)) { - ESP_LOGE(TAG, "Failed to read chip ID"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read chip ID")); return; } if (chip_id != QWIIC_PIR_DEVICE_ID) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->error_code_ = ERROR_WRONG_CHIP_ID; - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown chip ID")); return; } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { - ESP_LOGE(TAG, "Failed to configure debounce time"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure debounce time")); return; } @@ -33,9 +27,7 @@ void QwiicPIRComponent::setup() { // Publish the starting raw state of the PIR sensor // If NATIVE mode, the binary_sensor state would be unknown until a motion event if (!this->read_byte(QWIIC_PIR_EVENT_STATUS, &this->event_register_.reg)) { - ESP_LOGE(TAG, "Failed to read initial state"); - this->error_code_ = ERROR_COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read initial state")); return; } @@ -104,20 +96,6 @@ void QwiicPIRComponent::dump_config() { ESP_LOGCONFIG(TAG, " Debounce Time: %ums", this->debounce_time_); } - switch (this->error_code_) { - case NONE: - break; - case ERROR_COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case ERROR_WRONG_CHIP_ID: - ESP_LOGE(TAG, "Unknown chip ID"); - break; - default: - ESP_LOGE(TAG, "Error %d", (int) this->error_code_); - break; - } - LOG_I2C_DEVICE(this); LOG_BINARY_SENSOR(" ", "Binary Sensor", this); } diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 8d3b8fb321b..39ed5b32d5a 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -44,12 +44,6 @@ class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public DebounceMode debounce_mode_{}; - enum ErrorCode { - NONE = 0, - ERROR_COMMUNICATION_FAILED, - ERROR_WRONG_CHIP_ID, - } error_code_{NONE}; - union { struct { bool raw_reading : 1; // raw state of PIR sensor diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 6549ba9846c..9796974a411 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -76,13 +76,6 @@ void RC522::initialize_() { void RC522::dump_config() { ESP_LOGCONFIG(TAG, "RC522:"); - switch (this->error_code_) { - case NONE: - break; - case RESET_FAILED: - ESP_LOGE(TAG, "Reset command failed"); - break; - } LOG_PIN(" RESET Pin: ", this->reset_pin_); @@ -293,9 +286,7 @@ void RC522::pcd_reset_() { } if (--reset_count_ == 0) { - ESP_LOGE(TAG, "Unable to reset"); - this->error_code_ = RESET_FAILED; - mark_failed(); + this->mark_failed(LOG_STR("Reset command failed")); } } diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index fd3c8196969..00d530c81db 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -244,11 +244,6 @@ class RC522 : public PollingComponent { std::vector triggers_ontag_; std::vector triggers_ontagremoved_; std::vector current_uid_; - - enum RC522Error { - NONE = 0, - RESET_FAILED, - } error_code_{NONE}; }; class RC522BinarySensor final : public binary_sensor::BinarySensor { diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index 502f6f265d6..c94c64f8611 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -19,7 +19,7 @@ constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); void RX8025TComponent::setup() { uint8_t flags; if (!this->read_flags_(&flags)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -33,9 +33,6 @@ void RX8025TComponent::update() { this->read_time(); } void RX8025TComponent::dump_config() { ESP_LOGCONFIG(TAG, "RX8025T:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } time::RealTimeClock::dump_config(); } diff --git a/esphome/components/scd30/scd30.cpp b/esphome/components/scd30/scd30.cpp index 5802503627a..07760ebd191 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -34,8 +34,7 @@ void SCD30Component::setup() { /// Firmware version identification uint16_t raw_firmware_version[3]; if (!this->get_register(SCD30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version, 3)) { - this->error_code_ = FIRMWARE_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read sensor firmware version")); return; } ESP_LOGD(TAG, "SCD30 Firmware v%0d.%02d", (uint16_t(raw_firmware_version[0]) >> 8), @@ -49,9 +48,7 @@ void SCD30Component::setup() { } if (!this->write_command(SCD30_CMD_TEMPERATURE_OFFSET, temp_offset)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting temperature offset."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting temperature offset")); return; } #ifdef USE_ESP32 @@ -68,9 +65,7 @@ void SCD30Component::setup() { #endif if (!this->write_command(SCD30_CMD_MEASUREMENT_INTERVAL, update_interval_)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting update interval."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting update interval")); return; } #ifdef USE_ESP32 @@ -80,9 +75,7 @@ void SCD30Component::setup() { // The start measurement command disables the altitude compensation, if any, so we only set it if it's turned on if (this->altitude_compensation_ != 0xFFFF) { if (!this->write_command(SCD30_CMD_ALTITUDE_COMPENSATION, altitude_compensation_)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting altitude compensation."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting altitude compensation")); return; } } @@ -91,9 +84,7 @@ void SCD30Component::setup() { #endif if (!this->write_command(SCD30_CMD_AUTOMATIC_SELF_CALIBRATION, enable_asc_ ? 1 : 0)) { - ESP_LOGE(TAG, "Sensor SCD30 error setting automatic self calibration."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting automatic self calibration")); return; } #ifdef USE_ESP32 @@ -102,9 +93,7 @@ void SCD30Component::setup() { /// Sensor initialization if (!this->write_command(SCD30_CMD_START_CONTINUOUS_MEASUREMENTS, this->ambient_pressure_compensation_)) { - ESP_LOGE(TAG, "Sensor SCD30 error starting continuous measurements."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting continuous measurements")); return; } @@ -118,22 +107,6 @@ void SCD30Component::setup() { void SCD30Component::dump_config() { ESP_LOGCONFIG(TAG, "scd30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case FIRMWARE_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read sensor firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } if (this->altitude_compensation_ == 0xFFFF) { ESP_LOGCONFIG(TAG, " Altitude compensation: OFF"); } else { @@ -216,7 +189,6 @@ bool SCD30Component::force_recalibration_with_reference(uint16_t co2_reference) return true; } else { ESP_LOGE(TAG, "Failed to force recalibration with reference."); - this->error_code_ = FORCE_RECALIBRATION_FAILED; this->status_set_warning(); return false; } diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index 0605ab4175a..d3bb980d80c 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -29,13 +29,6 @@ class SCD30Component final : public Component, public sensirion_common::Sensirio protected: bool is_data_ready_(); - enum ErrorCode { - COMMUNICATION_FAILED, - FIRMWARE_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - FORCE_RECALIBRATION_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; bool enable_asc_{true}; uint16_t altitude_compensation_{0xFFFF}; uint16_t ambient_pressure_compensation_{0x0000}; diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index 8249a6feb1e..bd30f3a42cb 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -30,8 +30,7 @@ void SCD4XComponent::setup() { this->set_timeout(1000, [this]() { this->status_clear_error(); if (!this->write_command(SCD4X_CMD_STOP_MEASUREMENTS)) { - ESP_LOGE(TAG, "Failed to stop measurements"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to stop measurements")); return; } // According to the SCD4x datasheet the sensor will only respond to other commands after waiting 500 ms after @@ -39,9 +38,7 @@ void SCD4XComponent::setup() { this->set_timeout(500, [this]() { uint16_t raw_serial_number[3]; if (!this->get_register(SCD4X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 3, 1)) { - ESP_LOGE(TAG, "Failed to read serial number"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } ESP_LOGD(TAG, "Serial number %02d.%02d.%02d", (uint16_t(raw_serial_number[0]) >> 8), @@ -49,33 +46,25 @@ void SCD4XComponent::setup() { if (!this->write_command(SCD4X_CMD_TEMPERATURE_OFFSET, (uint16_t) (temperature_offset_ * SCD4X_TEMPERATURE_OFFSET_MULTIPLIER))) { - ESP_LOGE(TAG, "Error setting temperature offset"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting temperature offset")); return; } // If pressure compensation available use it, else use altitude if (this->ambient_pressure_) { if (!this->update_ambient_pressure_compensation_(this->ambient_pressure_)) { - ESP_LOGE(TAG, "Error setting ambient pressure compensation"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting ambient pressure compensation")); return; } } else { if (!this->write_command(SCD4X_CMD_ALTITUDE_COMPENSATION, this->altitude_compensation_)) { - ESP_LOGE(TAG, "Error setting altitude compensation"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting altitude compensation")); return; } } if (!this->write_command(SCD4X_CMD_AUTOMATIC_SELF_CALIBRATION, this->enable_asc_ ? 1 : 0)) { - ESP_LOGE(TAG, "Error setting automatic self calibration"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error setting automatic self calibration")); return; } @@ -110,22 +99,6 @@ void SCD4XComponent::dump_config() { ESP_LOGCONFIG(TAG, "SCD4X:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } ESP_LOGCONFIG(TAG, " Automatic self calibration: %s\n" " Measurement mode: %s\n" @@ -237,7 +210,6 @@ bool SCD4XComponent::perform_forced_calibration(uint16_t current_co2_concentrati return true; } else { ESP_LOGE(TAG, "Force calibration failed"); - this->error_code_ = FRC_FAILED; this->status_set_warning(); return false; } @@ -310,7 +282,6 @@ bool SCD4XComponent::start_measurement_() { while (remaining_retries) { if (!this->write_command(measurement_command)) { ESP_LOGE(TAG, "Error starting measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; this->status_set_warning(); if (--remaining_retries == 0) return false; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 4d5dedb5e94..0407af2b5ab 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -7,14 +7,6 @@ namespace esphome::scd4x { -enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - FRC_FAILED, - UNKNOWN, -}; - enum MeasurementMode : uint8_t { PERIODIC, LOW_POWER_PERIODIC, @@ -54,7 +46,6 @@ class SCD4XComponent final : public PollingComponent, public sensirion_common::S uint16_t ambient_pressure_{0}; // Per datasheet, valid values are 700 to 1200 hPa; 0 is a valid sentinel value bool initialized_{false}; bool enable_asc_{false}; - ErrorCode error_code_; MeasurementMode measurement_mode_{PERIODIC}; }; diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 98b5d9940a3..2f090108a69 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -26,20 +26,17 @@ void SDP3XComponent::setup() { this->set_timeout(20, [this] { if (!this->write_command(SDP3X_READ_ID1)) { - ESP_LOGE(TAG, "Read ID1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID1 failed")); return; } if (!this->write_command(SDP3X_READ_ID2)) { - ESP_LOGE(TAG, "Read ID2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID2 failed")); return; } uint16_t data[6]; if (!this->read_data(data, 6)) { - ESP_LOGE(TAG, "Read ID failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Read ID failed")); return; } @@ -76,8 +73,7 @@ void SDP3XComponent::setup() { } if (!this->write_command(measurement_mode_ == DP_AVG ? SDP3X_START_DP_AVG : SDP3X_START_MASS_FLOW_AVG)) { - ESP_LOGE(TAG, "Start Measurements failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Start Measurements failed")); return; } ESP_LOGCONFIG(TAG, "started"); @@ -86,9 +82,6 @@ void SDP3XComponent::setup() { void SDP3XComponent::dump_config() { LOG_SENSOR(" ", "SDP3X", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection with failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen0321/sen0321.cpp b/esphome/components/sen0321/sen0321.cpp index 564e05e0394..420caa01b4f 100644 --- a/esphome/components/sen0321/sen0321.cpp +++ b/esphome/components/sen0321/sen0321.cpp @@ -9,7 +9,7 @@ ESPHOME_LOG_TAG(TAG, "sen0321_sensor.sensor"); void Sen0321Sensor::setup() { if (!this->write_byte(SENSOR_MODE_REGISTER, SENSOR_MODE_AUTO)) { ESP_LOGW(TAG, "Error setting measurement mode."); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); }; } @@ -18,9 +18,6 @@ void Sen0321Sensor::update() { this->read_data_(); } void Sen0321Sensor::dump_config() { ESP_LOGCONFIG(TAG, "DF Robot Ozone Sensor sen0321:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index 29175d845cc..d45b247a437 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -10,10 +10,6 @@ void Sen21231Sensor::update() { this->read_data_(); } void Sen21231Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SEN21231:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - ESP_LOGI(TAG, "SEN21231: %s", this->is_failed() ? LOG_STR_LITERAL("FAILED") : LOG_STR_LITERAL("OK")); LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index f192a402887..2d7babddf81 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -60,16 +60,14 @@ void SEN5XComponent::setup() { this->set_timeout(1000, [this]() { // Check if measurement is ready before reading the value if (!this->write_command(SEN5X_CMD_GET_DATA_READY_STATUS)) { - ESP_LOGE(TAG, "Failed to write data ready status command"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write data ready status command")); return; } delay(20); // per datasheet uint16_t raw_read_status; if (!this->read_data(raw_read_status)) { - ESP_LOGE(TAG, "Failed to read data ready status"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read data ready status")); return; } @@ -78,8 +76,7 @@ void SEN5XComponent::setup() { if (raw_read_status) { ESP_LOGD(TAG, "Data is available; stopping periodic measurement"); if (!this->write_command(SEN5X_CMD_STOP_MEASUREMENTS)) { - ESP_LOGE(TAG, "Failed to stop measurements"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to stop measurements")); return; } // According to the SEN5x datasheet the sensor will only respond to other commands after waiting 200 ms after @@ -91,9 +88,7 @@ void SEN5XComponent::setup() { // this appears to be all that Sensirion uses for serial numbers, this could change uint16_t raw_serial_number[8]; if (!this->get_register(SEN5X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 20)) { - ESP_LOGE(TAG, "Failed to read serial number"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial ID")); return; } const char *serial_number = sensirion_convert_to_string_in_place(raw_serial_number, 8); @@ -121,8 +116,7 @@ void SEN5XComponent::setup() { this->type_ = this->model_override_.value(); } else if (detected_type == Sen5xType::UNKNOWN) { this->type_ = Sen5xType::UNKNOWN; - this->error_code_ = PRODUCT_NAME_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read product name")); return; } else { this->type_ = detected_type; @@ -147,9 +141,7 @@ void SEN5XComponent::setup() { } if (!this->get_register(SEN5X_CMD_GET_FIRMWARE_VERSION, this->firmware_version_, 20)) { - ESP_LOGE(TAG, "Failed to read firmware version"); - this->error_code_ = FIRMWARE_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } this->firmware_version_ >>= 8; @@ -189,9 +181,7 @@ void SEN5XComponent::setup() { result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE); } if (!result) { - ESP_LOGE(TAG, "Failed to set rh/t acceleration mode"); - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to set rh/t acceleration mode")); return; } delay(20); @@ -225,9 +215,7 @@ void SEN5XComponent::setup() { } if (!this->write_command(cmd)) { - ESP_LOGE(TAG, "Error starting continuous measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting continuous measurements")); return; } this->initialized_ = true; @@ -238,28 +226,6 @@ void SEN5XComponent::setup() { void SEN5XComponent::dump_config() { ESP_LOGCONFIG(TAG, "SEN5X:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed"); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Unable to read serial ID"); - break; - case PRODUCT_NAME_FAILED: - ESP_LOGW(TAG, "Unable to read product name"); - break; - case FIRMWARE_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } ESP_LOGCONFIG(TAG, " Type: %s%s\n" " Firmware version: %d\n" diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ed7689af523..07b72a1d69d 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -8,15 +8,6 @@ namespace esphome::sen5x { -enum ERRORCODE : uint8_t { - COMMUNICATION_FAILED, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - MEASUREMENT_INIT_FAILED, - PRODUCT_NAME_FAILED, - FIRMWARE_FAILED, - UNKNOWN -}; - enum RhtAccelerationMode : uint16_t { LOW_ACCELERATION = 0, MEDIUM_ACCELERATION = 1, @@ -107,7 +98,6 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S uint32_t voc_baseline_time_{0}; uint16_t firmware_version_{0}; Sen5xType type_{Sen5xType::UNKNOWN}; - ERRORCODE error_code_{ERRORCODE::UNKNOWN}; bool initialized_{false}; bool store_baseline_{false}; diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index efa3d8dc61f..b2db91bb587 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -13,9 +13,7 @@ void SFA30Component::setup() { // Serial Number identification uint16_t raw_device_marking[16]; if (!this->get_register(SFA30_CMD_GET_DEVICE_MARKING, raw_device_marking, 16, 5)) { - ESP_LOGE(TAG, "Failed to read device marking"); - this->error_code_ = DEVICE_MARKING_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read device marking")); return; } @@ -26,9 +24,7 @@ void SFA30Component::setup() { ESP_LOGD(TAG, "Device Marking: '%s'", this->device_marking_); if (!this->write_command(SFA30_CMD_START_CONTINUOUS_MEASUREMENTS)) { - ESP_LOGE(TAG, "Error starting measurements."); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Error starting measurements")); return; } } @@ -36,19 +32,6 @@ void SFA30Component::setup() { void SFA30Component::dump_config() { ESP_LOGCONFIG(TAG, "sfa30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case DEVICE_MARKING_READ_FAILED: - ESP_LOGW(TAG, "Unable to read device marking!"); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed!"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Device Marking: '%s'", this->device_marking_); LOG_SENSOR(" ", "Formaldehyde", this->formaldehyde_sensor_); diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index 13985b1a294..a9d85f60115 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -7,8 +7,6 @@ namespace esphome::sfa30 { class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { - enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; - public: void setup() override; void dump_config() override; @@ -21,8 +19,6 @@ class SFA30Component final : public PollingComponent, public sensirion_common::S protected: char device_marking_[32] = {0}; - ErrorCode error_code_{UNKNOWN}; - sensor::Sensor *formaldehyde_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp index 31a25a4f384..d0d3496afd0 100644 --- a/esphome/components/sfa40/sfa40.cpp +++ b/esphome/components/sfa40/sfa40.cpp @@ -41,15 +41,11 @@ void SFA40Component::setup() { this->write_command(SFA40_CMD_STOP_MEASUREMENT); this->set_timeout(25, [this]() { if (!this->detect_protocol_()) { - ESP_LOGE(TAG, "Failed to detect SFA40 protocol"); - this->error_code_ = PROTOCOL_DETECTION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Protocol detection failed")); return; } if (!this->write_command(SFA40_CMD_START_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start measurements"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to start measurements")); return; } this->initialized_ = true; @@ -77,19 +73,6 @@ bool SFA40Component::detect_protocol_() { void SFA40Component::dump_config() { ESP_LOGCONFIG(TAG, "sfa40:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case PROTOCOL_DETECTION_FAILED: - ESP_LOGW(TAG, "Protocol detection failed!"); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement initialization failed!"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error!"); - break; - } - } LOG_UPDATE_INTERVAL(this); switch (this->protocol_version_) { case ProtocolVersion::PRODUCTION: diff --git a/esphome/components/sfa40/sfa40.h b/esphome/components/sfa40/sfa40.h index 6f67607753c..67995fae2ee 100644 --- a/esphome/components/sfa40/sfa40.h +++ b/esphome/components/sfa40/sfa40.h @@ -25,14 +25,8 @@ class SFA40Component final : public PollingComponent, public sensirion_common::S PRODUCTION = 1, PROTOTYPE = 2, }; - enum ErrorCode : uint8_t { - UNKNOWN_ERROR = 0, - PROTOCOL_DETECTION_FAILED, - MEASUREMENT_INIT_FAILED, - }; bool detect_protocol_(); ProtocolVersion protocol_version_{UNKNOWN}; - ErrorCode error_code_{UNKNOWN_ERROR}; char device_marking_[11]{}; bool initialized_{false}; bool wait_for_ready_{true}; diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 72827ad51b5..387dbf3c485 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -37,7 +37,7 @@ void SGP30Component::setup() { // Serial Number identification uint16_t raw_serial_number[3]; if (!this->get_register(SGP30_CMD_GET_SERIAL_ID, raw_serial_number, 3)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->serial_number_ = (static_cast(raw_serial_number[0]) << 32) | @@ -48,28 +48,24 @@ void SGP30Component::setup() { // Featureset identification for future use uint16_t raw_featureset; if (!this->get_register(SGP30_CMD_GET_FEATURESET, raw_featureset)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->featureset_ = raw_featureset; if (uint16_t(this->featureset_ >> 12) != 0x0) { if (uint16_t(this->featureset_ >> 12) == 0x1) { // ID matching a different sensor: SGPC3 - this->error_code_ = UNSUPPORTED_ID; + this->mark_failed(LOG_STR("Unsupported ID")); } else { - // Unknown ID - this->error_code_ = INVALID_ID; + this->mark_failed(LOG_STR("Invalid ID")); } - this->mark_failed(); return; } ESP_LOGV(TAG, "Product version: 0x%0X", uint16_t(this->featureset_ & 0x1FF)); // Sensor initialization if (!this->write_command(SGP30_CMD_IAQ_INIT)) { - ESP_LOGE(TAG, "sgp30_iaq_init failed"); - this->error_code_ = MEASUREMENT_INIT_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Measurement initialization failed")); return; } @@ -227,25 +223,7 @@ void SGP30Component::write_iaq_baseline_(uint16_t eco2_baseline, uint16_t tvoc_b void SGP30Component::dump_config() { ESP_LOGCONFIG(TAG, "SGP30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case INVALID_ID: - ESP_LOGW(TAG, "Invalid ID"); - break; - case UNSUPPORTED_ID: - ESP_LOGW(TAG, "Unsupported ID"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, " Serial number: %" PRIu64, this->serial_number_); if (this->eco2_baseline_ != 0x0000 && this->tvoc_baseline_ != 0x0000) { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index fac3c01b58f..3e95072dcfe 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -46,14 +46,6 @@ class SGP30Component final : public PollingComponent, public sensirion_common::S uint16_t tvoc_baseline_{0x0000}; bool store_baseline_; - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - MEASUREMENT_INIT_FAILED, - INVALID_ID, - UNSUPPORTED_ID, - UNKNOWN - } error_code_{UNKNOWN}; - ESPPreferenceObject pref_; SGP30Baselines baselines_storage_; diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 854dddba059..2772aa4be82 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -13,9 +13,7 @@ void SGP4xComponent::setup() { // Serial Number identification uint16_t raw_serial_number[3]; if (!this->get_register(SGP4X_CMD_GET_SERIAL_ID, raw_serial_number, 3, 1)) { - ESP_LOGE(TAG, "Get serial number failed"); - this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Get serial number failed")); return; } this->serial_number_ = (uint64_t(raw_serial_number[0]) << 32) | (uint64_t(raw_serial_number[1]) << 16) | @@ -26,7 +24,7 @@ void SGP4xComponent::setup() { uint16_t featureset; if (!this->get_register(SGP4X_CMD_GET_FEATURESET, featureset, 1)) { ESP_LOGD(TAG, "Get feature set failed"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } featureset &= 0x1FF; @@ -44,7 +42,7 @@ void SGP4xComponent::setup() { this->measure_time_ = SGP41_MEASURE_TIME; } else { ESP_LOGD(TAG, "Unknown feature set 0x%0X", featureset); - this->mark_failed(); + this->mark_failed(LOG_STR("Unknown feature set")); return; } @@ -111,9 +109,7 @@ void SGP4xComponent::setup() { void SGP4xComponent::self_test_() { ESP_LOGD(TAG, "Starting self-test"); if (!this->write_command(SGP4X_CMD_SELF_TEST)) { - this->error_code_ = COMMUNICATION_FAILED; - ESP_LOGD(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -123,9 +119,8 @@ void SGP4xComponent::self_test_() { // failures bool passed = this->read_data(reply) && (this->sgp_type_ == SGP41 ? (reply & 0x0003) == 0 : (reply >> 8) == 0xD4); if (!passed) { - this->error_code_ = SELF_TEST_FAILED; ESP_LOGW(TAG, "Self-test failed (0x%X)", reply); - this->mark_failed(); + this->mark_failed(LOG_STR("Self-test failed")); return; } @@ -264,22 +259,7 @@ void SGP4xComponent::dump_config() { LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Store baseline: %s", YESNO(this->store_baseline_)); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case SERIAL_NUMBER_IDENTIFICATION_FAILED: - ESP_LOGW(TAG, "Get serial number failed"); - break; - case SELF_TEST_FAILED: - ESP_LOGW(TAG, "Self-test failed"); - break; - default: - ESP_LOGW(TAG, "Unknown error"); - break; - } - } else { + if (!this->is_failed()) { ESP_LOGCONFIG(TAG, " Type: %s\n" " Serial number: %" PRIu64 "\n" diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 2aaf06601b1..804f0cdcef9 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -59,16 +59,6 @@ class SGP4xComponent; class SGP4xComponent final : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { - enum ErrorCode { - COMMUNICATION_FAILED, - MEASUREMENT_INIT_FAILED, - INVALID_ID, - UNSUPPORTED_ID, - SERIAL_NUMBER_IDENTIFICATION_FAILED, - SELF_TEST_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - public: // SGP4xComponent() {}; void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 45de59fe0dc..d46020b2d9a 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -30,7 +30,7 @@ void SHT4XComponent::read_serial_number_() { void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -70,9 +70,6 @@ void SHT4XComponent::dump_config() { this->serial_number_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } if (this->serial_number_ == 0) { ESP_LOGW(TAG, "Get serial number failed"); }